Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,9 @@ User runs /plannotator-review command
Claude Code: plannotator review subcommand runs
OpenCode: event handler intercepts command
VCS diff captures local changes (git diff or jj diff)
VCS diff captures local changes (git diff or jj diff). When review runs from a
non-VCS parent that contains nested Git repos, child diffs are combined with
folder-prefixed paths.
Review server starts, opens browser with diff viewer
Expand Down Expand Up @@ -234,7 +236,7 @@ During normal plan review, an Archive sidebar tab provides the same browsing via

| Endpoint | Method | Purpose |
| --------------------- | ------ | ------------------------------------------ |
| `/api/diff` | GET | Returns `{ rawPatch, gitRef, origin, diffType, base, hideWhitespace, gitContext }` |
| `/api/diff` | GET | Returns `{ rawPatch, gitRef, origin, mode?, diffType, base, hideWhitespace, gitContext, agentCwd? }`. Workspace mode returns `mode: "workspace"` with folder-prefixed paths and no `gitContext`. |
| `/api/diff/switch` | POST | Switch diff type, base branch, or whitespace mode (body: `{ diffType, base?, hideWhitespace? }`) |
| `/api/file-content` | GET | Returns `{ oldContent, newContent }` for expandable diff context (`?path=&oldPath=&base=`) |
| `/api/git-add` | POST | Stage/unstage a file (body: `{ filePath, undo? }`) |
Expand Down
45 changes: 33 additions & 12 deletions apps/hook/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ import {
startAnnotateServer,
handleAnnotateServerReady,
} from "@plannotator/server/annotate";
import { type DiffType, prepareLocalReviewDiff, gitRuntime } from "@plannotator/server/vcs";
import { type DiffType, gitRuntime, prepareLocalReviewDiff, detectManagedVcs } from "@plannotator/server/vcs";
import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator/shared/config";
import { parseReviewArgs } from "@plannotator/shared/review-args";
import { stripAtPrefix, resolveAtReference } from "@plannotator/shared/at-reference";
Expand Down Expand Up @@ -108,6 +108,7 @@ import {
} from "./cli";
import path from "path";
import { tmpdir } from "os";
import { aggregateWorkspacePatch, buildLocalWorkspaceReview } from "@plannotator/server/review-workspace";

// Embed the built HTML at compile time
// @ts-ignore - Bun import attribute for text
Expand Down Expand Up @@ -304,6 +305,7 @@ if (args[0] === "sessions") {
let agentCwd: string | undefined;
let worktreePool: WorktreePool | undefined;
let worktreeCleanup: (() => void | Promise<void>) | undefined;
let workspace: Awaited<ReturnType<typeof buildLocalWorkspaceReview>> | undefined;

if (isPRMode) {
// --- PR Review Mode ---
Expand Down Expand Up @@ -494,16 +496,34 @@ if (args[0] === "sessions") {
} else {
// --- Local Review Mode ---
const config = loadConfig();
const diffResult = await prepareLocalReviewDiff({
vcsType: reviewArgs.vcsType,
configuredDiffType: resolveDefaultDiffType(config),
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
});
gitContext = diffResult.gitContext;
initialDiffType = diffResult.diffType;
rawPatch = diffResult.rawPatch;
gitRef = diffResult.gitRef;
diffError = diffResult.error;
const managedVcs = await detectManagedVcs(process.cwd(), reviewArgs.vcsType);

if (managedVcs) {
const diffResult = await prepareLocalReviewDiff({
vcsType: reviewArgs.vcsType,
configuredDiffType: resolveDefaultDiffType(config),
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
});
gitContext = diffResult.gitContext;
initialDiffType = diffResult.diffType;
rawPatch = diffResult.rawPatch;
gitRef = diffResult.gitRef;
diffError = diffResult.error;
} else {
workspace = await buildLocalWorkspaceReview(process.cwd(), {
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
});
if (workspace.repos.length === 0) {
console.error("Not in a git repo and no nested git repositories were found.");
process.exit(1);
}
const aggregate = aggregateWorkspacePatch(workspace.repos);
rawPatch = aggregate.rawPatch;
gitRef = aggregate.gitRef;
diffError = aggregate.errors.length > 0 ? aggregate.errors.join("\n") : undefined;
initialDiffType = "uncommitted";
agentCwd = workspace.root;
}
}

const reviewProject = (await detectProjectName()) ?? "_unknown";
Expand All @@ -514,9 +534,10 @@ if (args[0] === "sessions") {
gitRef,
error: diffError,
origin: detectedOrigin,
diffType: gitContext ? (initialDiffType ?? "unstaged") : undefined,
diffType: workspace ? (initialDiffType ?? "uncommitted") : gitContext ? (initialDiffType ?? "unstaged") : undefined,
gitContext,
prMetadata,
workspace,
agentCwd,
worktreePool,
sharingEnabled,
Expand Down
47 changes: 35 additions & 12 deletions apps/opencode-plugin/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
startAnnotateServer,
handleAnnotateServerReady,
} from "@plannotator/server/annotate";
import { type DiffType, prepareLocalReviewDiff } from "@plannotator/server/vcs";
import { type DiffType, prepareLocalReviewDiff, detectManagedVcs } from "@plannotator/server/vcs";
import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr";
import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator/shared/config";
import {
Expand All @@ -32,6 +32,7 @@ import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown";
import { parseAnnotateArgs } from "@plannotator/shared/annotate-args";
import { parseReviewArgs } from "@plannotator/shared/review-args";
import { urlToMarkdown, isConvertedSource } from "@plannotator/shared/url-to-markdown";
import { aggregateWorkspacePatch, buildLocalWorkspaceReview } from "@plannotator/server/review-workspace";
import { statSync } from "fs";
import path from "path";

Expand Down Expand Up @@ -63,6 +64,8 @@ export async function handleReviewCommand(
let userDiffType: DiffType | undefined;
let gitContext: Awaited<ReturnType<typeof prepareLocalReviewDiff>>["gitContext"] | undefined;
let prMetadata: Awaited<ReturnType<typeof fetchPR>>["metadata"] | undefined;
let workspace: Awaited<ReturnType<typeof buildLocalWorkspaceReview>> | undefined;
let agentCwd: string | undefined;

if (isPRMode) {
const prRef = parsePRUrl(urlArg);
Expand Down Expand Up @@ -94,17 +97,35 @@ export async function handleReviewCommand(
client.app.log({ level: "info", message: "Opening code review UI..." });

const config = loadConfig();
const diffResult = await prepareLocalReviewDiff({
cwd: directory,
vcsType: reviewArgs.vcsType,
configuredDiffType: resolveDefaultDiffType(config),
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
});
gitContext = diffResult.gitContext;
userDiffType = diffResult.diffType;
rawPatch = diffResult.rawPatch;
gitRef = diffResult.gitRef;
diffError = diffResult.error;
const cwd = directory ?? process.cwd();
const managedVcs = await detectManagedVcs(cwd, reviewArgs.vcsType);
if (managedVcs) {
const diffResult = await prepareLocalReviewDiff({
cwd: directory,
vcsType: reviewArgs.vcsType,
configuredDiffType: resolveDefaultDiffType(config),
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
});
gitContext = diffResult.gitContext;
userDiffType = diffResult.diffType;
rawPatch = diffResult.rawPatch;
gitRef = diffResult.gitRef;
diffError = diffResult.error;
} else {
workspace = await buildLocalWorkspaceReview(cwd, {
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
});
if (workspace.repos.length === 0) {
client.app.log({ level: "error", message: "Not in a git repo and no nested git repositories were found." });
return;
}
const aggregate = aggregateWorkspacePatch(workspace.repos);
rawPatch = aggregate.rawPatch;
gitRef = aggregate.gitRef;
diffError = aggregate.errors.length > 0 ? aggregate.errors.join("\n") : undefined;
userDiffType = "uncommitted";
agentCwd = workspace.root;
}
}

const server = await startReviewServer({
Expand All @@ -115,6 +136,8 @@ export async function handleReviewCommand(
diffType: isPRMode ? undefined : userDiffType,
gitContext,
prMetadata,
workspace,
agentCwd,
sharingEnabled: await getSharingEnabled(),
shareBaseUrl: getShareBaseUrl(),
htmlContent: reviewHtmlContent,
Expand Down
129 changes: 112 additions & 17 deletions apps/pi-extension/plannotator-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import {
prepareLocalReviewDiff,
reviewRuntime,
detectManagedVcs,
getVcsContext,
runVcsDiff,
startAnnotateServer,
startPlanReviewServer,
startReviewServer,
Expand All @@ -26,6 +29,12 @@ import {
import { parseRemoteUrl } from "./generated/repo.js";
import { fetchRef, createWorktree, removeWorktree, ensureObjectAvailable } from "./generated/worktree.js";
import { loadConfig, resolveDefaultDiffType } from "./generated/config.js";
import {
aggregateWorkspacePatch,
buildWorkspaceRepoLabels,
discoverWorkspaceRepoPaths,
prefixWorkspacePatchPaths,
} from "./generated/review-workspace-node.js";
export { getLastAssistantMessageText } from "./assistant-message.js";

export type AnnotateMode = "annotate" | "annotate-folder" | "annotate-last";
Expand Down Expand Up @@ -89,6 +98,74 @@ function openBrowserForServer(serverUrl: string, ctx: ExtensionContext): void {
}
}

interface WorkspaceRepoRuntimeState {
id: string;
label: string;
cwd: string;
selected: boolean;
source: "local";
platformUser: string | null;
diffType?: DiffType;
gitContext?: Awaited<ReturnType<typeof getVcsContext>>;
diffOptions?: Awaited<ReturnType<typeof getVcsContext>>["diffOptions"];
rawPatch: string;
gitRef: string;
error?: string;
}

interface LocalWorkspaceReview {
mode: "workspace";
root: string;
repos: WorkspaceRepoRuntimeState[];
}

async function buildLocalWorkspaceReview(
root: string,
options: { hideWhitespace?: boolean } = {},
): Promise<LocalWorkspaceReview> {
const resolvedRoot = resolve(root);
const repoPaths = discoverWorkspaceRepoPaths(resolvedRoot);
const labels = buildWorkspaceRepoLabels(resolvedRoot, repoPaths);
const repos = await Promise.all(repoPaths.map(async (cwd, index) => {
const label = labels[index];
try {
const gitContext = await getVcsContext(cwd, "git");
const diffType: DiffType = "uncommitted";
const diff = await runVcsDiff(diffType, gitContext.defaultBranch, cwd, {
hideWhitespace: options.hideWhitespace,
});
return {
id: `repo-${index + 1}`,
label,
cwd,
selected: !!diff.patch.trim(),
source: "local" as const,
platformUser: null,
diffType,
gitContext,
diffOptions: gitContext.diffOptions,
rawPatch: prefixWorkspacePatchPaths(diff.patch, label),
gitRef: diff.label,
error: diff.error,
};
} catch (err) {
return {
id: `repo-${index + 1}`,
label,
cwd,
selected: false,
source: "local" as const,
platformUser: null,
rawPatch: "",
gitRef: "",
error: err instanceof Error ? err.message : String(err),
};
}
}));

return { mode: "workspace", root: resolvedRoot, repos };
}

async function openBrowserAndWait<T>(
server: { url: string; stop: () => void },
ctx: ExtensionContext,
Expand Down Expand Up @@ -232,6 +309,7 @@ export async function startCodeReviewBrowserSession(
let worktreeCleanup: (() => void | Promise<void>) | undefined;
let worktreePool: WorktreePool | undefined;
let exitHandler: (() => void) | undefined;
let workspace: LocalWorkspaceReview | undefined;

if (isPRMode && urlArg) {
// --- PR Review Mode ---
Expand Down Expand Up @@ -399,23 +477,39 @@ export async function startCodeReviewBrowserSession(
// --- Local Review Mode ---
const cwd = options.cwd ?? ctx.cwd;
const config = loadConfig();
const result = await prepareLocalReviewDiff({
cwd,
vcsType: options.vcsType,
requestedDiffType: options.diffType,
requestedBase: options.defaultBranch,
configuredDiffType: resolveDefaultDiffType(config),
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
});
gitCtx = result.gitContext;
diffType = result.diffType;
rawPatch = result.rawPatch;
gitRef = result.gitRef;
diffError = result.error;
// Remember which base the initial diff was computed against so it can
// be forwarded to the server below. Only matters when the caller
// overrode the detected default; otherwise it matches gitCtx already.
initialBase = result.base;
const managedVcs = await detectManagedVcs(cwd, options.vcsType);
if (managedVcs) {
const result = await prepareLocalReviewDiff({
cwd,
vcsType: options.vcsType,
requestedDiffType: options.diffType,
requestedBase: options.defaultBranch,
configuredDiffType: resolveDefaultDiffType(config),
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
});
gitCtx = result.gitContext;
diffType = result.diffType;
rawPatch = result.rawPatch;
gitRef = result.gitRef;
diffError = result.error;
// Remember which base the initial diff was computed against so it can
// be forwarded to the server below. Only matters when the caller
// overrode the detected default; otherwise it matches gitCtx already.
initialBase = result.base;
} else {
workspace = await buildLocalWorkspaceReview(cwd, {
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
});
if (workspace.repos.length === 0) {
throw new Error("Not in a git repo and no nested git repositories were found.");
}
const aggregate = aggregateWorkspacePatch(workspace.repos);
rawPatch = aggregate.rawPatch;
gitRef = aggregate.gitRef;
diffError = aggregate.errors.length > 0 ? aggregate.errors.join("\n") : undefined;
diffType = "uncommitted";
agentCwd = workspace.root;
}
}

const server = await startReviewServer({
Expand All @@ -427,6 +521,7 @@ export async function startCodeReviewBrowserSession(
gitContext: gitCtx,
initialBase,
prMetadata,
workspace,
agentCwd,
worktreePool,
htmlContent: reviewHtmlContent,
Expand Down
Loading