void handleTabContextMenu(event, surface)}
className={cn(
"group flex h-7 min-w-25 max-w-44 shrink-0 items-center gap-1.5 rounded-md px-2 text-sm",
active
@@ -383,6 +458,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
) : null}
+ {props.layoutControls}
{props.activeSurfaceId === null ? (
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
index 87077b741fba..d6265c08650f 100644
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -57,6 +57,7 @@ import {
import { type ElementContextDraft } from "../../lib/elementContext";
import { useComposerPathSearch } from "../../lib/composerPathSearchState";
import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts";
+import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments";
import { ComposerPreviewAnnotationCards } from "./ComposerPreviewAnnotationCards";
import {
shouldUseCompactComposerPrimaryActions,
@@ -109,6 +110,7 @@ import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"
import { searchProviderSkills } from "../../providerSkillSearch";
import { useMediaQuery } from "../../hooks/useMediaQuery";
import { getRuntimeModeConfig, getRuntimeModeOptions } from "./runtimeModePresentation";
+import type { ReviewCommentContext } from "../../reviewCommentContext";
const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
@@ -384,6 +386,7 @@ export interface ChatComposerHandle {
terminalContexts: TerminalContextDraft[];
elementContexts: ElementContextDraft[];
previewAnnotations: PreviewAnnotationPayload[];
+ reviewComments: ReviewCommentContext[];
selectedPromptEffort: string | null;
selectedModelOptionsForDispatch: unknown;
selectedModelSelection: ModelSelection;
@@ -593,6 +596,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const composerTerminalContexts = composerDraft.terminalContexts;
const composerElementContexts = composerDraft.elementContexts;
const composerPreviewAnnotations = composerDraft.previewAnnotations;
+ const composerReviewComments = composerDraft.reviewComments;
const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;
const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
@@ -614,6 +618,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const removeComposerDraftPreviewAnnotation = useComposerDraftStore(
(store) => store.removePreviewAnnotation,
);
+ const removeComposerDraftReviewComment = useComposerDraftStore(
+ (store) => store.removeReviewComment,
+ );
const clearComposerDraftPersistedAttachments = useComposerDraftStore(
(store) => store.clearPersistedAttachments,
);
@@ -874,8 +881,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
prompt,
imageCount: composerImages.length,
terminalContexts: composerTerminalContexts,
+ elementContextCount:
+ composerElementContexts.length +
+ composerPreviewAnnotations.length +
+ composerReviewComments.length,
}),
- [composerImages.length, composerTerminalContexts, prompt],
+ [
+ composerElementContexts.length,
+ composerImages.length,
+ composerPreviewAnnotations.length,
+ composerReviewComments.length,
+ composerTerminalContexts,
+ prompt,
+ ],
);
// ------------------------------------------------------------------
@@ -1969,6 +1987,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
terminalContexts: composerTerminalContextsRef.current,
elementContexts: composerElementContextsRef.current,
previewAnnotations: composerPreviewAnnotations,
+ reviewComments: composerReviewComments,
selectedPromptEffort,
selectedModelOptionsForDispatch,
selectedModelSelection,
@@ -1988,6 +2007,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
composerTerminalContextsRef,
composerElementContextsRef,
composerPreviewAnnotations,
+ composerReviewComments,
isConnecting,
isComposerApprovalState,
pendingUserInputs.length,
@@ -2243,6 +2263,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
/>
)}
+ {!isComposerCollapsedMobile &&
+ !isComposerApprovalState &&
+ pendingUserInputs.length === 0 &&
+ composerReviewComments.length > 0 && (
+
+ removeComposerDraftReviewComment(composerDraftTarget, commentId)
+ }
+ className="mb-3"
+ />
+ )}
+
{!isComposerCollapsedMobile &&
!isComposerApprovalState &&
pendingUserInputs.length === 0 &&
diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx
index 9466a15f92c3..4222c6ad107c 100644
--- a/apps/web/src/components/chat/ChatHeader.tsx
+++ b/apps/web/src/components/chat/ChatHeader.tsx
@@ -77,15 +77,15 @@ export const ChatHeader = memo(function ChatHeader({
primaryEnvironmentId,
});
return (
-
-
+
+
{activeThreadTitle}
@@ -97,7 +97,7 @@ export const ChatHeader = memo(function ChatHeader({
diff --git a/apps/web/src/components/chat/ComposerPendingReviewComments.browser.tsx b/apps/web/src/components/chat/ComposerPendingReviewComments.browser.tsx
new file mode 100644
index 000000000000..a5aa6e224e81
--- /dev/null
+++ b/apps/web/src/components/chat/ComposerPendingReviewComments.browser.tsx
@@ -0,0 +1,41 @@
+import "../../index.css";
+
+import { afterEach, describe, expect, it, vi } from "vite-plus/test";
+import { page } from "vite-plus/test/browser";
+import { render } from "vitest-browser-react";
+
+import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments";
+
+describe("ComposerPendingReviewComments", () => {
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ it("renders a removable file comment pill", async () => {
+ const onRemove = vi.fn();
+ const screen = await render(
+
,
+ );
+
+ await expect.element(page.getByText("src/app.ts L2 to L3")).toBeVisible();
+ await page.getByRole("button", { name: "Remove comment on src/app.ts L2 to L3" }).click();
+ expect(onRemove).toHaveBeenCalledWith("comment-1");
+
+ await screen.unmount();
+ });
+});
diff --git a/apps/web/src/components/chat/ComposerPendingReviewComments.tsx b/apps/web/src/components/chat/ComposerPendingReviewComments.tsx
new file mode 100644
index 000000000000..0a47b02357ac
--- /dev/null
+++ b/apps/web/src/components/chat/ComposerPendingReviewComments.tsx
@@ -0,0 +1,60 @@
+import { MessageCircle, X } from "lucide-react";
+
+import {
+ COMPOSER_INLINE_CHIP_CLASS_NAME,
+ COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME,
+ COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
+ COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME,
+} from "../composerInlineChip";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
+import type { ReviewCommentContext } from "~/reviewCommentContext";
+import { cn } from "~/lib/utils";
+
+interface ComposerPendingReviewCommentsProps {
+ comments: ReadonlyArray
;
+ onRemove: (commentId: string) => void;
+ className?: string;
+}
+
+export function ComposerPendingReviewComments({
+ comments,
+ onRemove,
+ className,
+}: ComposerPendingReviewCommentsProps) {
+ if (comments.length === 0) return null;
+
+ return (
+
+ {comments.map((comment) => {
+ const label = `${comment.filePath} ${comment.rangeLabel}`;
+ return (
+
+
+
+ {label}
+
+
+ }
+ />
+
+ {comment.text}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx
index 4f25392dd23a..91aebdaa3c07 100644
--- a/apps/web/src/components/chat/MessagesTimeline.test.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx
@@ -298,6 +298,42 @@ describe("MessagesTimeline", () => {
expect(markup).not.toContain("</review_comment>");
});
+ it("renders file review comments as source code instead of diffs", async () => {
+ const { MessagesTimeline } = await import("./MessagesTimeline");
+ const markup = renderToStaticMarkup(
+ ',
+ "Clarify this.",
+ "```md",
+ "# Plan",
+ "- Step one",
+ "```",
+ "",
+ ].join("\n"),
+ createdAt: "2026-03-17T19:12:28.000Z",
+ streaming: false,
+ },
+ },
+ ]}
+ />,
+ );
+
+ expect(markup).toContain("plan.md");
+ expect(markup).toContain("Clarify this.");
+ expect(markup).toContain("# Plan");
+ expect(markup).not.toContain('data-testid="file-diff"');
+ });
+
it("renders a failure marker for failed tool lifecycle entries", async () => {
const { MessagesTimeline } = await import("./MessagesTimeline");
const markup = renderToStaticMarkup(
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index 1c4d18e1236f..6c4cfda771eb 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -103,6 +103,7 @@ import { SkillInlineText } from "./SkillInlineText";
import { formatWorkspaceRelativePath } from "../../filePathDisplay";
import {
buildReviewCommentRenderablePatch,
+ formatReviewCommentFence,
parseReviewCommentMessageSegments,
type ReviewCommentContext,
} from "../../reviewCommentContext";
@@ -1277,6 +1278,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentContext }) {
const ctx = use(TimelineRowCtx);
+ const fenceLanguage = comment.fenceLanguage ?? "diff";
const renderablePatch = getRenderablePatch(
buildReviewCommentRenderablePatch(comment),
`review-comment:${comment.id}`,
@@ -1297,6 +1299,15 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte
)}
+ {fenceLanguage !== "diff" && comment.diff.trim().length > 0 && (
+
+ )}
{renderablePatch?.kind === "files" &&
renderablePatch.files.map((fileDiff) => (
;
openInCwd: string | null;
+ compact?: boolean;
+ enableShortcut?: boolean;
}) {
const [preferredEditor, setPreferredEditor] = usePreferredEditor(availableEditors);
const options = useMemo(
@@ -184,6 +188,7 @@ export const OpenInPicker = memo(function OpenInPicker({
);
useEffect(() => {
+ if (!enableShortcut) return;
const handler = (e: globalThis.KeyboardEvent) => {
const api = readLocalApi();
if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
@@ -195,25 +200,38 @@ export const OpenInPicker = memo(function OpenInPicker({
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
- }, [preferredEditor, keybindings, openInCwd]);
+ }, [enableShortcut, preferredEditor, keybindings, openInCwd]);
return (
-
+
) : relativePath && file.data ? (
- file.data.truncated ? (
+ isMarkdown && renderMarkdown ? (
+
+ ) : file.data.truncated ? (
void;
+ onComment: (text: string) => void;
+ onDelete: () => void;
+}
+
+export function LocalCommentAnnotation({
+ kind,
+ rangeLabel,
+ text: savedText,
+ onCancel,
+ onComment,
+ onDelete,
+}: LocalCommentAnnotationProps) {
+ const [text, setText] = useState("");
+
+ if (kind === "comment") {
+ return (
+ event.stopPropagation()}
+ >
+
+
+ Local comment
+ {rangeLabel}
+
+
+
+ {savedText}
+
+
+ );
+ }
+
+ return (
+ event.stopPropagation()}
+ >
+
+
+ Local comment
+
+
Comment on lines {rangeLabel}
+
+ );
+}
diff --git a/apps/web/src/components/files/fileCommentAnnotations.ts b/apps/web/src/components/files/fileCommentAnnotations.ts
new file mode 100644
index 000000000000..cbd4394cbf74
--- /dev/null
+++ b/apps/web/src/components/files/fileCommentAnnotations.ts
@@ -0,0 +1,54 @@
+import type { LineAnnotation, SelectedLineRange } from "@pierre/diffs";
+
+export interface FileCommentAnnotationEntry {
+ id: string;
+ kind: "draft" | "comment";
+ startLine: number;
+ endLine: number;
+ text: string;
+}
+
+export interface FileCommentAnnotationGroup {
+ entries: FileCommentAnnotationEntry[];
+}
+
+export type FileCommentLineAnnotation = LineAnnotation;
+
+let fileCommentSequence = 0;
+
+export function nextFileCommentId(): string {
+ fileCommentSequence += 1;
+ return `file-comment-${Date.now()}-${fileCommentSequence}`;
+}
+
+export function normalizeFileCommentRange(range: SelectedLineRange): {
+ startLine: number;
+ endLine: number;
+} {
+ return {
+ startLine: Math.min(range.start, range.end),
+ endLine: Math.max(range.start, range.end),
+ };
+}
+
+export function formatFileCommentRange(startLine: number, endLine: number): string {
+ return startLine === endLine ? `L${startLine}` : `L${startLine} to L${endLine}`;
+}
+
+export function remapFileCommentAnnotations(
+ annotations: ReadonlyArray,
+): FileCommentLineAnnotation[] {
+ return annotations.map((annotation) => ({
+ ...annotation,
+ metadata: {
+ entries: annotation.metadata.entries.map((entry) => {
+ const lineCount = entry.endLine - entry.startLine;
+ return {
+ ...entry,
+ endLine: annotation.lineNumber,
+ startLine: Math.max(1, annotation.lineNumber - lineCount),
+ };
+ }),
+ },
+ }));
+}
diff --git a/apps/web/src/components/files/fileEditorDismissal.ts b/apps/web/src/components/files/fileEditorDismissal.ts
new file mode 100644
index 000000000000..1291cfe9aeea
--- /dev/null
+++ b/apps/web/src/components/files/fileEditorDismissal.ts
@@ -0,0 +1,53 @@
+interface FileEditorDismissalOptions {
+ root: HTMLElement;
+ editor: {
+ setSelections: (selections: []) => void;
+ };
+ isBlocked: () => boolean;
+ onDismiss: () => void;
+}
+
+function dismissFileEditorInteraction({
+ root,
+ editor,
+ onDismiss,
+}: Pick): void {
+ onDismiss();
+ editor.setSelections([]);
+
+ const file = root.querySelector("diffs-container");
+ const activeElement = file?.shadowRoot?.activeElement;
+ if (activeElement instanceof HTMLElement) {
+ activeElement.blur();
+ }
+}
+
+function isFileEditorFocused(root: HTMLElement): boolean {
+ const file = root.querySelector("diffs-container");
+ return file?.shadowRoot?.activeElement?.hasAttribute("data-content") === true;
+}
+
+export function installFileEditorDismissal({
+ root,
+ editor,
+ isBlocked,
+ onDismiss,
+}: FileEditorDismissalOptions): () => void {
+ const handlePointerDown = (event: PointerEvent) => {
+ if (isBlocked() || event.composedPath().includes(root)) return;
+ dismissFileEditorInteraction({ root, editor, onDismiss });
+ };
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== "Escape" || isBlocked() || !isFileEditorFocused(root)) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ dismissFileEditorInteraction({ root, editor, onDismiss });
+ };
+
+ document.addEventListener("pointerdown", handlePointerDown, true);
+ document.addEventListener("keydown", handleKeyDown, true);
+ return () => {
+ document.removeEventListener("pointerdown", handlePointerDown, true);
+ document.removeEventListener("keydown", handleKeyDown, true);
+ };
+}
diff --git a/apps/web/src/components/files/filePreviewMode.ts b/apps/web/src/components/files/filePreviewMode.ts
new file mode 100644
index 000000000000..63249dbc72f0
--- /dev/null
+++ b/apps/web/src/components/files/filePreviewMode.ts
@@ -0,0 +1,18 @@
+export const isMarkdownPreviewFile = (path: string): boolean => /\.(?:md|mdx)$/i.test(path);
+
+export function setMarkdownTaskChecked(
+ markdown: string,
+ markerOffset: number,
+ checked: boolean,
+): string {
+ if (
+ markerOffset < 0 ||
+ markdown[markerOffset] !== "[" ||
+ !/[ xX]/.test(markdown[markerOffset + 1] ?? "") ||
+ markdown[markerOffset + 2] !== "]"
+ ) {
+ return markdown;
+ }
+
+ return `${markdown.slice(0, markerOffset + 1)}${checked ? "x" : " "}${markdown.slice(markerOffset + 2)}`;
+}
diff --git a/apps/web/src/components/files/projectFilesQueryState.test.ts b/apps/web/src/components/files/projectFilesQueryState.test.ts
index 756cdb90b1d3..f9021b5a5d7a 100644
--- a/apps/web/src/components/files/projectFilesQueryState.test.ts
+++ b/apps/web/src/components/files/projectFilesQueryState.test.ts
@@ -19,6 +19,7 @@ import {
confirmProjectFileQueryData,
getProjectEntriesQueryAtom,
getProjectFileQueryAtom,
+ getOptimisticProjectFileQueryData,
resolveProjectFileQueryData,
setProjectFileQueryData,
} from "./projectFilesQueryState";
@@ -104,6 +105,10 @@ describe("project files queries", () => {
setProjectFileQueryData(environmentId, "/repo", "convex.json", '{"nodeVersion":"220"}');
setProjectFileQueryData(environmentId, "/repo", "convex.json", '{"nodeVersion":"22"}');
+ expect(getOptimisticProjectFileQueryData(environmentId, "/repo", "convex.json")?.contents).toBe(
+ '{"nodeVersion":"22"}',
+ );
+
expect(
confirmProjectFileQueryData(environmentId, "/repo", "convex.json", '{"nodeVersion":"220"}'),
).toBe(false);
diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts
index 17c6d9ef8d5d..37a2b2663572 100644
--- a/apps/web/src/components/files/projectFilesQueryState.ts
+++ b/apps/web/src/components/files/projectFilesQueryState.ts
@@ -121,6 +121,14 @@ export function setProjectFileQueryData(
});
}
+export function getOptimisticProjectFileQueryData(
+ environmentId: EnvironmentId,
+ cwd: string,
+ relativePath: string,
+): ProjectReadFileResult | null {
+ return optimisticProjectFiles.get(fileKey(environmentId, cwd, relativePath))?.data ?? null;
+}
+
export function confirmProjectFileQueryData(
environmentId: EnvironmentId,
cwd: string,
diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts
index 37470af4a6ba..1da5dadf74d2 100644
--- a/apps/web/src/composerDraftStore.test.ts
+++ b/apps/web/src/composerDraftStore.test.ts
@@ -615,6 +615,69 @@ describe("composerDraftStore element contexts", () => {
});
});
+describe("composerDraftStore review comments", () => {
+ const threadId = ThreadId.make("thread-review-comment");
+ const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
+ const comment = {
+ id: "comment-1",
+ sectionId: "file:src/app.ts",
+ sectionTitle: "File comment",
+ filePath: "src/app.ts",
+ startIndex: 1,
+ endIndex: 2,
+ rangeLabel: "L2 to L3",
+ text: "Keep this configurable.",
+ diff: "@@ -2,2 +2,2 @@\n two\n three",
+ } as const;
+
+ beforeEach(() => {
+ resetComposerDraftStore();
+ });
+
+ it("upserts and removes review comments by id", () => {
+ const store = useComposerDraftStore.getState();
+ store.addReviewComment(threadRef, comment);
+ store.addReviewComment(threadRef, { ...comment, text: "Updated comment." });
+
+ expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.reviewComments).toEqual([
+ { ...comment, text: "Updated comment." },
+ ]);
+
+ store.removeReviewComment(threadRef, comment.id);
+ expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined();
+ });
+
+ it("persists review comments and clears them with composer content", () => {
+ const store = useComposerDraftStore.getState();
+ store.addReviewComment(threadRef, comment);
+ const persistApi = useComposerDraftStore.persist as unknown as {
+ getOptions: () => {
+ partialize: (state: ReturnType) => unknown;
+ };
+ };
+ const persisted = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as {
+ draftsByThreadKey?: Record> }>;
+ };
+
+ expect(
+ persisted.draftsByThreadKey?.[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]
+ ?.reviewComments?.[0],
+ ).toMatchObject(comment);
+
+ store.clearComposerContent(threadRef);
+ expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined();
+ });
+
+ it("stores review comments against a new-thread draft id", () => {
+ const draftId = DraftId.make("draft-review-comment");
+ useComposerDraftStore.getState().addReviewComment(draftId, comment);
+
+ expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.reviewComments).toEqual([
+ comment,
+ ]);
+ });
+});
+
describe("composerDraftStore project draft thread mapping", () => {
const projectId = ProjectId.make("project-a");
const otherProjectId = ProjectId.make("project-b");
diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts
index a71c1690c239..aaaa94c1dd20 100644
--- a/apps/web/src/composerDraftStore.ts
+++ b/apps/web/src/composerDraftStore.ts
@@ -50,11 +50,13 @@ import { useShallow } from "zustand/react/shallow";
import { createDebouncedStorage, createMemoryStorage } from "./lib/storage";
import { getDefaultServerModel } from "./providerModels";
import { UnifiedSettings } from "@t3tools/contracts/settings";
+import { ReviewCommentContextSchema, type ReviewCommentContext } from "./reviewCommentContext";
const isRuntimeMode = Schema.is(RuntimeMode);
const isProviderDriverKind = Schema.is(ProviderDriverKind);
+const isReviewCommentContext = Schema.is(ReviewCommentContextSchema);
export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";
-const COMPOSER_DRAFT_STORAGE_VERSION = 7;
+const COMPOSER_DRAFT_STORAGE_VERSION = 8;
const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]);
export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type;
@@ -128,6 +130,7 @@ const PersistedComposerThreadDraftState = Schema.Struct({
terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)),
elementContexts: Schema.optionalKey(Schema.Array(PersistedElementContextDraft)),
previewAnnotations: Schema.optionalKey(Schema.Array(PreviewAnnotationPayloadSchema)),
+ reviewComments: Schema.optionalKey(Schema.Array(ReviewCommentContextSchema)),
// Keyed by `ProviderInstanceId` (open branded slug) so custom provider
// instances (e.g. `codex_personal`) round-trip alongside the built-in
// `codex` / `claudeAgent` / ... entries. Every prior `ProviderDriverKind`
@@ -256,6 +259,7 @@ export interface ComposerThreadDraftState {
*/
elementContexts: ElementContextDraft[];
previewAnnotations: PreviewAnnotationPayload[];
+ reviewComments: ReviewCommentContext[];
/**
* Per-instance model selection. Keyed by `ProviderInstanceId` (open
* branded slug) so a default `codex` instance and a user-authored
@@ -464,6 +468,12 @@ interface ComposerDraftStoreState {
annotations: ReadonlyArray,
) => void;
removePreviewAnnotation: (threadRef: ComposerThreadTarget, annotationId: string) => void;
+ addReviewComment: (threadRef: ComposerThreadTarget, comment: ReviewCommentContext) => void;
+ setReviewComments: (
+ threadRef: ComposerThreadTarget,
+ comments: ReadonlyArray,
+ ) => void;
+ removeReviewComment: (threadRef: ComposerThreadTarget, commentId: string) => void;
clearPersistedAttachments: (threadRef: ComposerThreadTarget) => void;
syncPersistedAttachments: (
threadRef: ComposerThreadTarget,
@@ -541,11 +551,13 @@ const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = [];
const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = [];
const EMPTY_ELEMENT_CONTEXTS: ElementContextDraft[] = [];
const EMPTY_PREVIEW_ANNOTATIONS: PreviewAnnotationPayload[] = [];
+const EMPTY_REVIEW_COMMENTS: ReviewCommentContext[] = [];
Object.freeze(EMPTY_IMAGES);
Object.freeze(EMPTY_IDS);
Object.freeze(EMPTY_PERSISTED_ATTACHMENTS);
Object.freeze(EMPTY_ELEMENT_CONTEXTS);
Object.freeze(EMPTY_PREVIEW_ANNOTATIONS);
+Object.freeze(EMPTY_REVIEW_COMMENTS);
const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial> =
Object.freeze({});
const EMPTY_COMPOSER_DRAFT_MODEL_STATE = Object.freeze({
@@ -561,6 +573,7 @@ const EMPTY_THREAD_DRAFT = Object.freeze({
terminalContexts: EMPTY_TERMINAL_CONTEXTS,
elementContexts: EMPTY_ELEMENT_CONTEXTS,
previewAnnotations: EMPTY_PREVIEW_ANNOTATIONS,
+ reviewComments: EMPTY_REVIEW_COMMENTS,
modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER,
activeProvider: null,
runtimeMode: null,
@@ -582,6 +595,7 @@ export function createEmptyThreadDraft(): ComposerThreadDraftState {
terminalContexts: [],
elementContexts: [],
previewAnnotations: [],
+ reviewComments: [],
modelSelectionByProvider: {},
activeProvider: null,
runtimeMode: null,
@@ -654,6 +668,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {
draft.terminalContexts.length === 0 &&
draft.elementContexts.length === 0 &&
draft.previewAnnotations.length === 0 &&
+ draft.reviewComments.length === 0 &&
Object.keys(draft.modelSelectionByProvider).length === 0 &&
draft.activeProvider === null &&
draft.runtimeMode === null &&
@@ -1624,6 +1639,9 @@ function normalizePersistedDraftsByThreadId(
return normalized ? [normalized] : [];
})
: [];
+ const reviewComments = Array.isArray(draftCandidate.reviewComments)
+ ? draftCandidate.reviewComments.filter(isReviewCommentContext)
+ : [];
const runtimeMode = isRuntimeMode(draftCandidate.runtimeMode)
? draftCandidate.runtimeMode
: null;
@@ -1688,6 +1706,7 @@ function normalizePersistedDraftsByThreadId(
attachments.length === 0 &&
terminalContexts.length === 0 &&
elementContexts.length === 0 &&
+ reviewComments.length === 0 &&
!hasModelData &&
!runtimeMode &&
!interactionMode
@@ -1711,6 +1730,7 @@ function normalizePersistedDraftsByThreadId(
attachments,
...(terminalContexts.length > 0 ? { terminalContexts } : {}),
...(elementContexts.length > 0 ? { elementContexts } : {}),
+ ...(reviewComments.length > 0 ? { reviewComments } : {}),
...(hasModelData
? {
modelSelectionByProvider: compactModelSelectionByProvider(modelSelectionByProvider),
@@ -1795,6 +1815,7 @@ function partializeComposerDraftStoreState(
draft.terminalContexts.length === 0 &&
draft.elementContexts.length === 0 &&
draft.previewAnnotations.length === 0 &&
+ draft.reviewComments.length === 0 &&
!hasModelData &&
draft.runtimeMode === null &&
draft.interactionMode === null
@@ -1841,6 +1862,11 @@ function partializeComposerDraftStoreState(
),
}
: {}),
+ ...(draft.reviewComments.length > 0
+ ? {
+ reviewComments: draft.reviewComments.map((comment) => ({ ...comment })),
+ }
+ : {}),
...(hasModelData
? {
modelSelectionByProvider: compactModelSelectionByProvider(
@@ -2083,6 +2109,7 @@ function toHydratedThreadDraft(
})) ?? [],
previewAnnotations:
persistedDraft.previewAnnotations?.map((annotation) => ({ ...annotation })) ?? [],
+ reviewComments: persistedDraft.reviewComments?.map((comment) => ({ ...comment })) ?? [],
modelSelectionByProvider,
activeProvider,
runtimeMode: persistedDraft.runtimeMode ?? null,
@@ -3148,6 +3175,55 @@ const composerDraftStore = create()(
return { draftsByThreadKey: nextDraftsByThreadKey };
});
},
+ addReviewComment: (threadRef, comment) => {
+ const threadKey = resolveComposerDraftKey(get(), threadRef);
+ if (!threadKey || !isReviewCommentContext(comment)) return;
+ set((state) => {
+ const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
+ const reviewComments = existing.reviewComments.filter(
+ (entry) => entry.id !== comment.id,
+ );
+ return {
+ draftsByThreadKey: {
+ ...state.draftsByThreadKey,
+ [threadKey]: {
+ ...existing,
+ reviewComments: [...reviewComments, { ...comment }],
+ },
+ },
+ };
+ });
+ },
+ setReviewComments: (threadRef, comments) => {
+ const threadKey = resolveComposerDraftKey(get(), threadRef);
+ if (!threadKey) return;
+ const reviewComments = comments
+ .filter(isReviewCommentContext)
+ .map((comment) => ({ ...comment }));
+ set((state) => {
+ const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft();
+ const nextDraft = { ...existing, reviewComments };
+ const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
+ if (shouldRemoveDraft(nextDraft)) delete nextDraftsByThreadKey[threadKey];
+ else nextDraftsByThreadKey[threadKey] = nextDraft;
+ return { draftsByThreadKey: nextDraftsByThreadKey };
+ });
+ },
+ removeReviewComment: (threadRef, commentId) => {
+ const threadKey = resolveComposerDraftKey(get(), threadRef);
+ if (!threadKey || !commentId) return;
+ set((state) => {
+ const current = state.draftsByThreadKey[threadKey];
+ if (!current) return state;
+ const reviewComments = current.reviewComments.filter((entry) => entry.id !== commentId);
+ if (reviewComments.length === current.reviewComments.length) return state;
+ const nextDraft = { ...current, reviewComments };
+ const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
+ if (shouldRemoveDraft(nextDraft)) delete nextDraftsByThreadKey[threadKey];
+ else nextDraftsByThreadKey[threadKey] = nextDraft;
+ return { draftsByThreadKey: nextDraftsByThreadKey };
+ });
+ },
clearPersistedAttachments: (threadRef) => {
const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
if (threadKey.length === 0) {
@@ -3222,6 +3298,7 @@ const composerDraftStore = create()(
terminalContexts: [],
elementContexts: [],
previewAnnotations: [],
+ reviewComments: [],
};
const nextDraftsByThreadKey = { ...state.draftsByThreadKey };
if (shouldRemoveDraft(nextDraft)) {
diff --git a/apps/web/src/reviewCommentContext.test.ts b/apps/web/src/reviewCommentContext.test.ts
index d729bf489dcc..819c6768ab60 100644
--- a/apps/web/src/reviewCommentContext.test.ts
+++ b/apps/web/src/reviewCommentContext.test.ts
@@ -1,8 +1,15 @@
+import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
import { describe, expect, it } from "vite-plus/test";
import {
+ appendReviewCommentsToPrompt,
+ buildDiffReviewComment,
+ buildFileReviewComment,
buildReviewCommentRenderablePatch,
+ formatReviewCommentContext,
+ inferReviewCommentFenceLanguage,
parseReviewCommentMessageSegments,
+ restoreDiffReviewCommentRange,
} from "./reviewCommentContext";
describe("review comment context parsing", () => {
@@ -74,4 +81,195 @@ describe("review comment context parsing", () => {
].join("\n"),
);
});
+
+ it("formats editable file comments with the mobile review-comment contract", () => {
+ const comment = buildFileReviewComment({
+ id: "comment-1",
+ filePath: "src/app.ts",
+ startLine: 2,
+ endLine: 3,
+ text: "Keep this configurable.",
+ contents: ["one", "two", "three", "four"].join("\n"),
+ });
+ const prompt = appendReviewCommentsToPrompt("Please update this.", [comment]);
+ const segments = parseReviewCommentMessageSegments(prompt);
+
+ expect(segments).toHaveLength(2);
+ expect(segments[1]).toEqual(
+ expect.objectContaining({
+ kind: "review-comment",
+ comment: expect.objectContaining({
+ filePath: "src/app.ts",
+ startIndex: 1,
+ endIndex: 2,
+ rangeLabel: "L2 to L3",
+ text: "Keep this configurable.",
+ diff: "two\nthree",
+ fenceLanguage: "ts",
+ }),
+ }),
+ );
+ expect(prompt).toContain("```ts\ntwo\nthree\n```");
+ });
+
+ it("formats mixed diff-side selections with the mobile review-comment contract", () => {
+ const [fileDiff] = parsePatchFiles(
+ [
+ "diff --git a/src/app.ts b/src/app.ts",
+ "--- a/src/app.ts",
+ "+++ b/src/app.ts",
+ "@@ -1,4 +1,4 @@",
+ " one",
+ "-two",
+ "+TWO",
+ " three",
+ " four",
+ ].join("\n"),
+ "review-comment-test",
+ )[0]!.files;
+
+ const comment = buildDiffReviewComment({
+ id: "comment-2",
+ sectionId: "turn:2",
+ sectionTitle: "Turn 2",
+ filePath: "src/app.ts",
+ fileDiff: fileDiff!,
+ range: {
+ start: 2,
+ side: "deletions",
+ end: 2,
+ endSide: "additions",
+ },
+ text: "Keep this compatible.",
+ });
+
+ expect(comment).toEqual(
+ expect.objectContaining({
+ sectionId: "turn:2",
+ sectionTitle: "Turn 2",
+ filePath: "src/app.ts",
+ startIndex: 1,
+ endIndex: 2,
+ rangeLabel: "2",
+ text: "Keep this compatible.",
+ diff: "@@ -2,1 +2,1 @@\n-two\n+TWO",
+ fenceLanguage: "diff",
+ }),
+ );
+ });
+
+ it("uses file extensions for source comments and preserves nested markdown fences", () => {
+ expect(inferReviewCommentFenceLanguage("docs/plan.md")).toBe("md");
+ expect(inferReviewCommentFenceLanguage("src/view.tsx")).toBe("tsx");
+
+ const serialized = formatReviewCommentContext({
+ id: "comment-3",
+ sectionId: "file:docs/plan.md",
+ sectionTitle: "File comment",
+ filePath: "docs/plan.md",
+ startIndex: 0,
+ endIndex: 2,
+ rangeLabel: "L1 to L3",
+ text: "Update this example.",
+ diff: ["# Example", "```ts", "const value = 1;", "```"].join("\n"),
+ fenceLanguage: "md",
+ });
+ const [segment] = parseReviewCommentMessageSegments(serialized);
+
+ expect(serialized).toContain("````md");
+ expect(segment).toEqual(
+ expect.objectContaining({
+ kind: "review-comment",
+ comment: expect.objectContaining({
+ fenceLanguage: "md",
+ diff: ["# Example", "```ts", "const value = 1;", "```"].join("\n"),
+ }),
+ }),
+ );
+ });
+
+ it("round-trips greater-than signs in attributes", () => {
+ const serialized = formatReviewCommentContext({
+ id: "comment-4",
+ sectionId: "turn:4",
+ sectionTitle: "Changes > 5",
+ filePath: "src/app.ts",
+ startIndex: 0,
+ endIndex: 0,
+ rangeLabel: "+1",
+ text: "Check this.",
+ diff: "@@ -0,0 +1,1 @@\n+one",
+ fenceLanguage: "diff",
+ });
+ const [segment] = parseReviewCommentMessageSegments(serialized);
+
+ expect(serialized).toContain('sectionTitle="Changes > 5"');
+ expect(segment).toEqual(
+ expect.objectContaining({
+ kind: "review-comment",
+ comment: expect.objectContaining({ sectionTitle: "Changes > 5" }),
+ }),
+ );
+ });
+
+ it("keeps fenced examples in comment text separate from the final context fence", () => {
+ const text = ["Try this:", "```ts", "const value = 1;", "```", "Then retry."].join("\n");
+ const serialized = formatReviewCommentContext({
+ id: "comment-5",
+ sectionId: "turn:5",
+ sectionTitle: "Turn 5",
+ filePath: "src/app.ts",
+ startIndex: 0,
+ endIndex: 0,
+ rangeLabel: "+1",
+ text,
+ diff: "@@ -0,0 +1,1 @@\n+one",
+ fenceLanguage: "diff",
+ });
+ const [segment] = parseReviewCommentMessageSegments(serialized);
+
+ expect(segment).toEqual(
+ expect.objectContaining({
+ kind: "review-comment",
+ comment: expect.objectContaining({
+ text,
+ diff: "@@ -0,0 +1,1 @@\n+one",
+ fenceLanguage: "diff",
+ }),
+ }),
+ );
+ });
+
+ it("restores Pierre line selections from persisted diff comment row indexes", () => {
+ const fileDiff = parsePatchFiles(
+ [
+ "diff --git a/src/app.ts b/src/app.ts",
+ "--- a/src/app.ts",
+ "+++ b/src/app.ts",
+ "@@ -1,3 +1,3 @@",
+ " one",
+ "-two",
+ "+TWO",
+ " three",
+ ].join("\n"),
+ "restore-review-comment-range",
+ )[0]!.files[0]!;
+ const comment = buildDiffReviewComment({
+ id: "comment-6",
+ sectionId: "turn:6",
+ sectionTitle: "Turn 6",
+ filePath: "src/app.ts",
+ fileDiff,
+ range: { start: 2, side: "deletions", end: 2, endSide: "additions" },
+ text: "Keep both sides.",
+ });
+
+ expect(comment).not.toBeNull();
+ expect(restoreDiffReviewCommentRange(fileDiff, comment!)).toEqual({
+ start: 2,
+ side: "deletions",
+ end: 2,
+ endSide: "additions",
+ });
+ });
});
diff --git a/apps/web/src/reviewCommentContext.ts b/apps/web/src/reviewCommentContext.ts
index 698c5bce49d0..f3613cee3e8c 100644
--- a/apps/web/src/reviewCommentContext.ts
+++ b/apps/web/src/reviewCommentContext.ts
@@ -1,3 +1,19 @@
+import type { FileDiffMetadata, SelectedLineRange, SelectionSide } from "@pierre/diffs";
+import * as Schema from "effect/Schema";
+
+export const ReviewCommentContextSchema = Schema.Struct({
+ id: Schema.String,
+ sectionId: Schema.String,
+ sectionTitle: Schema.String,
+ filePath: Schema.String,
+ startIndex: Schema.Number,
+ endIndex: Schema.Number,
+ rangeLabel: Schema.String,
+ text: Schema.String,
+ diff: Schema.String,
+ fenceLanguage: Schema.optional(Schema.String),
+});
+
export interface ReviewCommentContext {
readonly id: string;
readonly sectionId: string;
@@ -8,6 +24,14 @@ export interface ReviewCommentContext {
readonly rangeLabel: string;
readonly text: string;
readonly diff: string;
+ readonly fenceLanguage?: string | undefined;
+}
+
+interface DiffReviewLine {
+ readonly change: "context" | "add" | "delete";
+ readonly oldLineNumber: number | null;
+ readonly newLineNumber: number | null;
+ readonly content: string;
}
export type ReviewCommentMessageSegment =
@@ -23,11 +47,20 @@ export type ReviewCommentMessageSegment =
const REVIEW_COMMENT_BLOCK_PATTERN = /]*)>\s*([\s\S]*?)<\/review_comment>/g;
const REVIEW_COMMENT_ATTRIBUTE_PATTERN = /([a-zA-Z][a-zA-Z0-9_-]*)="([^"]*)"/g;
-const REVIEW_COMMENT_DIFF_FENCE_PATTERN = /```diff\s*\n([\s\S]*?)\n```/;
+const REVIEW_COMMENT_FENCE_PATTERN = /(`{3,})([^\s`]*)[^\n]*\n([\s\S]*?)\n\1/g;
+
+function escapeReviewCommentAttribute(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(/"/g, """)
+ .replace(//g, ">");
+}
function unescapeReviewCommentAttribute(value: string): string {
return value
.replace(/</g, "<")
+ .replace(/>/g, ">")
.replace(/"/g, '"')
.replace(/&/g, "&");
}
@@ -47,14 +80,19 @@ function readNonNegativeInteger(value: string | undefined): number | null {
return Number(value);
}
-function extractReviewCommentText(rawBody: string): string {
- const fenceIndex = rawBody.indexOf("```diff");
- const commentBody = fenceIndex >= 0 ? rawBody.slice(0, fenceIndex) : rawBody;
- return commentBody.trim();
-}
-
-function extractReviewCommentDiff(rawBody: string): string {
- return rawBody.match(REVIEW_COMMENT_DIFF_FENCE_PATTERN)?.[1]?.trim() ?? "";
+function extractReviewCommentBody(rawBody: string): {
+ text: string;
+ language: string;
+ contents: string;
+} {
+ const matches = Array.from(rawBody.matchAll(REVIEW_COMMENT_FENCE_PATTERN));
+ const match = matches.at(-1);
+ const fenceIndex = match?.index;
+ return {
+ text: rawBody.slice(0, fenceIndex ?? rawBody.length).trim(),
+ language: match?.[2]?.trim() || "diff",
+ contents: match?.[3] ?? "",
+ };
}
function parseReviewCommentContext(
@@ -70,6 +108,7 @@ function parseReviewCommentContext(
if (!filePath || !sectionId || startIndex === null || endIndex === null) {
return null;
}
+ const body = extractReviewCommentBody(rawBody);
return {
id: `review-comment:${index}:${sectionId}:${filePath}:${startIndex}:${endIndex}`,
@@ -79,8 +118,9 @@ function parseReviewCommentContext(
startIndex: Math.min(startIndex, endIndex),
endIndex: Math.max(startIndex, endIndex),
rangeLabel: attributes.rangeLabel?.trim() || "line",
- text: extractReviewCommentText(rawBody),
- diff: extractReviewCommentDiff(rawBody),
+ text: body.text,
+ diff: body.contents,
+ fenceLanguage: body.language,
};
}
@@ -135,7 +175,272 @@ export function hasReviewCommentMessageSegments(value: string): boolean {
);
}
+export function formatReviewCommentFence(language: string, contents: string): string {
+ const longestBacktickRun = Math.max(
+ 0,
+ ...Array.from(contents.matchAll(/`+/g), (match) => match[0].length),
+ );
+ const fence = "`".repeat(Math.max(3, longestBacktickRun + 1));
+ return [`${fence}${language}`, contents.trimEnd(), fence].join("\n");
+}
+
+export function formatReviewCommentContext(comment: ReviewCommentContext): string {
+ return [
+ [
+ "",
+ ].join(""),
+ comment.text.trim(),
+ formatReviewCommentFence(comment.fenceLanguage ?? "diff", comment.diff),
+ "",
+ ].join("\n");
+}
+
+export function appendReviewCommentsToPrompt(
+ prompt: string,
+ comments: ReadonlyArray,
+): string {
+ const blocks = comments.map(formatReviewCommentContext);
+ if (blocks.length === 0) return prompt;
+ const trimmedPrompt = prompt.trim();
+ return trimmedPrompt.length > 0
+ ? `${trimmedPrompt}\n\n${blocks.join("\n\n")}`
+ : blocks.join("\n\n");
+}
+
+export function buildFileReviewComment(input: {
+ id: string;
+ filePath: string;
+ startLine: number;
+ endLine: number;
+ text: string;
+ contents: string;
+}): ReviewCommentContext {
+ const startLine = Math.max(1, Math.min(input.startLine, input.endLine));
+ const endLine = Math.max(startLine, Math.max(input.startLine, input.endLine));
+ const selectedLines = input.contents.split("\n").slice(startLine - 1, endLine);
+ return {
+ id: input.id,
+ sectionId: `file:${input.filePath}`,
+ sectionTitle: "File comment",
+ filePath: input.filePath,
+ startIndex: startLine - 1,
+ endIndex: endLine - 1,
+ rangeLabel: startLine === endLine ? `L${startLine}` : `L${startLine} to L${endLine}`,
+ text: input.text.trim(),
+ diff: selectedLines.join("\n"),
+ fenceLanguage: inferReviewCommentFenceLanguage(input.filePath),
+ };
+}
+
+export function inferReviewCommentFenceLanguage(filePath: string): string {
+ const normalizedPath = filePath.replaceAll("\\", "/");
+ const fileName = normalizedPath.slice(normalizedPath.lastIndexOf("/") + 1).toLowerCase();
+ const extensionIndex = fileName.lastIndexOf(".");
+ if (extensionIndex > 0 && extensionIndex < fileName.length - 1) {
+ return fileName.slice(extensionIndex + 1);
+ }
+ if (fileName.startsWith(".") && fileName.length > 1) {
+ return fileName.slice(1);
+ }
+ return "text";
+}
+
+function stripTrailingNewline(value: string): string {
+ return value.endsWith("\n") ? value.slice(0, -1) : value;
+}
+
+function buildDiffReviewLines(fileDiff: FileDiffMetadata): ReadonlyArray {
+ const rows: DiffReviewLine[] = [];
+
+ for (const hunk of fileDiff.hunks) {
+ let oldLineNumber = hunk.deletionStart;
+ let newLineNumber = hunk.additionStart;
+ let deletionLineIndex = hunk.deletionLineIndex;
+ let additionLineIndex = hunk.additionLineIndex;
+
+ for (const segment of hunk.hunkContent) {
+ if (segment.type === "context") {
+ for (let index = 0; index < segment.lines; index += 1) {
+ rows.push({
+ change: "context",
+ oldLineNumber,
+ newLineNumber,
+ content: stripTrailingNewline(
+ fileDiff.additionLines[additionLineIndex] ??
+ fileDiff.deletionLines[deletionLineIndex] ??
+ "",
+ ),
+ });
+ oldLineNumber += 1;
+ newLineNumber += 1;
+ deletionLineIndex += 1;
+ additionLineIndex += 1;
+ }
+ continue;
+ }
+
+ for (let index = 0; index < segment.deletions; index += 1) {
+ rows.push({
+ change: "delete",
+ oldLineNumber,
+ newLineNumber: null,
+ content: stripTrailingNewline(fileDiff.deletionLines[deletionLineIndex] ?? ""),
+ });
+ oldLineNumber += 1;
+ deletionLineIndex += 1;
+ }
+
+ for (let index = 0; index < segment.additions; index += 1) {
+ rows.push({
+ change: "add",
+ oldLineNumber: null,
+ newLineNumber,
+ content: stripTrailingNewline(fileDiff.additionLines[additionLineIndex] ?? ""),
+ });
+ newLineNumber += 1;
+ additionLineIndex += 1;
+ }
+ }
+ }
+
+ return rows;
+}
+
+function getDiffReviewSelectionPoint(
+ line: DiffReviewLine,
+): { lineNumber: number; side: SelectionSide } | null {
+ if (line.change === "delete" && line.oldLineNumber !== null) {
+ return { lineNumber: line.oldLineNumber, side: "deletions" };
+ }
+ if (line.newLineNumber !== null) {
+ return { lineNumber: line.newLineNumber, side: "additions" };
+ }
+ if (line.oldLineNumber !== null) {
+ return { lineNumber: line.oldLineNumber, side: "deletions" };
+ }
+ return null;
+}
+
+export function restoreDiffReviewCommentRange(
+ fileDiff: FileDiffMetadata,
+ comment: ReviewCommentContext,
+): SelectedLineRange | null {
+ const lines = buildDiffReviewLines(fileDiff);
+ const startLine = lines[comment.startIndex];
+ const endLine = lines[comment.endIndex];
+ if (!startLine || !endLine) return null;
+ const start = getDiffReviewSelectionPoint(startLine);
+ const end = getDiffReviewSelectionPoint(endLine);
+ if (!start || !end) return null;
+ return {
+ start: start.lineNumber,
+ side: start.side,
+ end: end.lineNumber,
+ endSide: end.side,
+ };
+}
+
+function findDiffReviewLineIndex(
+ lines: ReadonlyArray,
+ lineNumber: number,
+ side: SelectionSide | undefined,
+): number {
+ const preferredKey = side === "deletions" ? "oldLineNumber" : "newLineNumber";
+ const preferredIndex = lines.findIndex((line) => line[preferredKey] === lineNumber);
+ if (preferredIndex >= 0) return preferredIndex;
+ const fallbackKey = preferredKey === "oldLineNumber" ? "newLineNumber" : "oldLineNumber";
+ return lines.findIndex((line) => line[fallbackKey] === lineNumber);
+}
+
+function getDiffRange(
+ lines: ReadonlyArray,
+ key: "oldLineNumber" | "newLineNumber",
+): { start: number; count: number } {
+ const numberedLines = lines.filter((line) => line[key] !== null);
+ return {
+ start: numberedLines[0]?.[key] ?? 0,
+ count: numberedLines.length,
+ };
+}
+
+function getDiffChangeMarker(change: DiffReviewLine["change"]): string {
+ if (change === "add") return "+";
+ if (change === "delete") return "-";
+ return " ";
+}
+
+function formatDiffReviewRangeLabel(lines: ReadonlyArray): string {
+ const firstLine = lines[0];
+ const lastLine = lines.at(-1);
+ if (!firstLine || !lastLine) return "line";
+ const firstNumber = firstLine.newLineNumber ?? firstLine.oldLineNumber;
+ const lastNumber = lastLine.newLineNumber ?? lastLine.oldLineNumber;
+ if (firstNumber === null || lastNumber === null) {
+ return lines.length === 1 ? "line" : `${lines.length} lines`;
+ }
+
+ const firstMarker = getDiffChangeMarker(firstLine.change).trim();
+ const marker =
+ firstMarker.length > 0 && lines.every((line) => line.change === firstLine.change)
+ ? firstMarker
+ : "";
+ return firstNumber === lastNumber
+ ? `${marker}${firstNumber}`
+ : `${marker}${firstNumber} to ${marker}${lastNumber}`;
+}
+
+export function buildDiffReviewComment(input: {
+ id: string;
+ sectionId: string;
+ sectionTitle: string;
+ filePath: string;
+ fileDiff: FileDiffMetadata;
+ range: SelectedLineRange;
+ text: string;
+}): ReviewCommentContext | null {
+ const lines = buildDiffReviewLines(input.fileDiff);
+ const startIndex = findDiffReviewLineIndex(lines, input.range.start, input.range.side);
+ const endIndex = findDiffReviewLineIndex(
+ lines,
+ input.range.end,
+ input.range.endSide ?? input.range.side,
+ );
+ if (startIndex < 0 || endIndex < 0) return null;
+
+ const normalizedStartIndex = Math.min(startIndex, endIndex);
+ const normalizedEndIndex = Math.max(startIndex, endIndex);
+ const selectedLines = lines.slice(normalizedStartIndex, normalizedEndIndex + 1);
+ const oldRange = getDiffRange(selectedLines, "oldLineNumber");
+ const newRange = getDiffRange(selectedLines, "newLineNumber");
+
+ return {
+ id: input.id,
+ sectionId: input.sectionId,
+ sectionTitle: input.sectionTitle,
+ filePath: input.filePath,
+ startIndex: normalizedStartIndex,
+ endIndex: normalizedEndIndex,
+ rangeLabel: formatDiffReviewRangeLabel(selectedLines),
+ text: input.text.trim(),
+ diff: [
+ `@@ -${oldRange.start},${oldRange.count} +${newRange.start},${newRange.count} @@`,
+ ...selectedLines.map((line) => `${getDiffChangeMarker(line.change)}${line.content}`),
+ ].join("\n"),
+ fenceLanguage: "diff",
+ };
+}
+
export function buildReviewCommentRenderablePatch(comment: ReviewCommentContext): string {
+ if ((comment.fenceLanguage ?? "diff") !== "diff") {
+ return "";
+ }
const diff = comment.diff.trim();
if (diff.length === 0) {
return "";
diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts
index 723eb9aeb166..bf15164d5140 100644
--- a/apps/web/src/rightPanelStore.test.ts
+++ b/apps/web/src/rightPanelStore.test.ts
@@ -304,6 +304,47 @@ describe("rightPanelStore", () => {
});
});
+ it("closing other surfaces keeps the selected surface active", () => {
+ useRightPanelStore.getState().openBrowser(refA, "tab-a");
+ useRightPanelStore.getState().openFile(refA, "src/index.ts");
+ useRightPanelStore.getState().openTerminal(refA, "term-1");
+
+ useRightPanelStore.getState().closeOtherSurfaces(refA, "file:src/index.ts");
+
+ expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
+ isOpen: true,
+ activeSurfaceId: "file:src/index.ts",
+ surfaces: [{ id: "file:src/index.ts", kind: "file", relativePath: "src/index.ts" }],
+ });
+ });
+
+ it("closing surfaces to the right activates the selected surface when active was removed", () => {
+ useRightPanelStore.getState().openBrowser(refA, "tab-a");
+ useRightPanelStore.getState().openFile(refA, "src/index.ts");
+ useRightPanelStore.getState().openTerminal(refA, "term-1");
+
+ useRightPanelStore.getState().closeSurfacesToRight(refA, "browser:tab-a");
+
+ expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
+ isOpen: true,
+ activeSurfaceId: "browser:tab-a",
+ surfaces: [{ id: "browser:tab-a", kind: "preview", resourceId: "tab-a" }],
+ });
+ });
+
+ it("closing all surfaces leaves the panel open and empty", () => {
+ useRightPanelStore.getState().openBrowser(refA, "tab-a");
+ useRightPanelStore.getState().openFile(refA, "src/index.ts");
+
+ useRightPanelStore.getState().closeAllSurfaces(refA);
+
+ expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
+ isOpen: true,
+ activeSurfaceId: null,
+ surfaces: [],
+ });
+ });
+
it("reconciles browser surfaces without deleting other surface kinds", () => {
useRightPanelStore.getState().openTerminal(refA, "term-1");
useRightPanelStore.getState().openBrowser(refA, "tab-a");
diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts
index e92de5d84717..40164ffdd93a 100644
--- a/apps/web/src/rightPanelStore.ts
+++ b/apps/web/src/rightPanelStore.ts
@@ -58,6 +58,9 @@ interface RightPanelStoreState {
closeTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void;
activateSurface: (ref: ScopedThreadRef, surfaceId: string) => void;
closeSurface: (ref: ScopedThreadRef, surfaceId: string) => void;
+ closeOtherSurfaces: (ref: ScopedThreadRef, surfaceId: string) => void;
+ closeSurfacesToRight: (ref: ScopedThreadRef, surfaceId: string) => void;
+ closeAllSurfaces: (ref: ScopedThreadRef) => void;
reconcileBrowserSurfaces: (ref: ScopedThreadRef, tabIds: readonly string[]) => void;
reconcileFileSurfaces: (ref: ScopedThreadRef, workspaceAvailable: boolean) => void;
show: (ref: ScopedThreadRef) => void;
@@ -334,6 +337,43 @@ export const useRightPanelStore = create()(
return { ...current, surfaces, activeSurfaceId: fallback?.id ?? null };
}),
})),
+ closeOtherSurfaces: (ref, surfaceId) =>
+ set((state) => ({
+ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
+ const surface = current.surfaces.find((entry) => entry.id === surfaceId);
+ if (!surface || current.surfaces.length === 1) return current;
+ return {
+ ...current,
+ isOpen: true,
+ surfaces: [surface],
+ activeSurfaceId: surface.id,
+ };
+ }),
+ })),
+ closeSurfacesToRight: (ref, surfaceId) =>
+ set((state) => ({
+ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
+ const index = current.surfaces.findIndex((surface) => surface.id === surfaceId);
+ if (index < 0 || index === current.surfaces.length - 1) return current;
+ const surfaces = current.surfaces.slice(0, index + 1);
+ const activeStillExists = surfaces.some(
+ (surface) => surface.id === current.activeSurfaceId,
+ );
+ return {
+ ...current,
+ surfaces,
+ activeSurfaceId: activeStillExists ? current.activeSurfaceId : surfaceId,
+ };
+ }),
+ })),
+ closeAllSurfaces: (ref) =>
+ set((state) => ({
+ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>
+ current.surfaces.length === 0
+ ? current
+ : { ...current, isOpen: true, surfaces: [], activeSurfaceId: null },
+ ),
+ })),
reconcileBrowserSurfaces: (ref, tabIds) =>
set((state) => ({
byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {