diff --git a/apps/mobile/src/features/review/reviewCommentSelection.test.ts b/apps/mobile/src/features/review/reviewCommentSelection.test.ts index 25e5f9ce4821..b61735f955cf 100644 --- a/apps/mobile/src/features/review/reviewCommentSelection.test.ts +++ b/apps/mobile/src/features/review/reviewCommentSelection.test.ts @@ -78,4 +78,68 @@ describe("review comment serialization", () => { ); expect(segments[2]).toEqual(expect.objectContaining({ kind: "text", text: "\nAfter" })); }); + + it("parses source-language review comments created by the web file viewer", () => { + const [segment] = parseReviewCommentMessageSegments( + [ + '', + "Clarify this.", + "```md", + "# Plan", + "- Step one", + "```", + "", + ].join("\n"), + ); + + expect(segment).toEqual( + expect.objectContaining({ + kind: "review-comment", + comment: expect.objectContaining({ + filePath: "docs/plan.md", + fenceLanguage: "md", + diff: "# Plan\n- Step one", + }), + }), + ); + }); + + it("keeps fenced examples in comment prose separate from the context fence", () => { + const [segment] = parseReviewCommentMessageSegments( + [ + '', + "Try this:", + "```ts", + "const value = 1;", + "```", + "Then retry.", + "```diff", + "@@ -0,0 +1,1 @@", + "+one", + "```", + "", + ].join("\n"), + ); + + expect(segment).toEqual( + expect.objectContaining({ + kind: "review-comment", + comment: expect.objectContaining({ + text: ["Try this:", "```ts", "const value = 1;", "```", "Then retry."].join("\n"), + diff: "@@ -0,0 +1,1 @@\n+one", + }), + }), + ); + }); + + it("round-trips greater-than signs in review attributes", () => { + const serialized = formatReviewCommentContext( + { ...makeTarget(), sectionTitle: "Changes > 5" }, + "Check this.", + ); + const [comment] = parseReviewInlineComments(serialized); + + expect(serialized).toContain('sectionTitle="Changes > 5"'); + expect(comment?.sectionTitle).toBe("Changes > 5"); + }); }); diff --git a/apps/mobile/src/features/review/reviewCommentSelection.ts b/apps/mobile/src/features/review/reviewCommentSelection.ts index 09e1927d179b..5e6e1c3683a9 100644 --- a/apps/mobile/src/features/review/reviewCommentSelection.ts +++ b/apps/mobile/src/features/review/reviewCommentSelection.ts @@ -21,6 +21,7 @@ export interface ReviewInlineComment { readonly rangeLabel: string; readonly text: string; readonly diff: string; + readonly fenceLanguage?: string; } export type ReviewCommentMessageSegment = @@ -38,6 +39,7 @@ let currentTarget: ReviewCommentTarget | null = null; const listeners = new Set<() => void>(); 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_FENCE_PATTERN = /(`{3,})([^\s`]*)[^\n]*\n([\s\S]*?)\n\1/g; function emitChange() { listeners.forEach((listener) => listener()); @@ -170,12 +172,17 @@ function formatReviewSelectedDiff(target: ReviewCommentTarget): string { } 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, "&"); } @@ -195,15 +202,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 { - const match = rawBody.match(/```diff\s*\n([\s\S]*?)\n```/); - return match?.[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 parseReviewInlineComment( @@ -219,6 +230,7 @@ function parseReviewInlineComment( if (!filePath || !sectionId || startIndex === null || endIndex === null) { return null; } + const body = extractReviewCommentBody(rawBody); return { id: `review-comment:${index}:${sectionId}:${filePath}:${startIndex}:${endIndex}`, @@ -228,13 +240,20 @@ function parseReviewInlineComment( 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, }; } export function formatReviewCommentContext(target: ReviewCommentTarget, comment: string): string { const rangeLabel = formatReviewSelectedRangeLabel(target); + const diff = formatReviewSelectedDiff(target); + const longestBacktickRun = Math.max( + 0, + ...Array.from(diff.matchAll(/`+/g), (match) => match[0].length), + ); + const fence = "`".repeat(Math.max(3, longestBacktickRun + 1)); return [ [ "", ].join(""), comment.trim(), - "```diff", - formatReviewSelectedDiff(target), - "```", + `${fence}diff`, + diff, + fence, "", ].join("\n"); } diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 4f06e0812c99..a23a9caffe97 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1193,6 +1193,9 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { }); function buildReviewCommentPatch(comment: ReviewInlineComment): string { + if ((comment.fenceLanguage ?? "diff") !== "diff") { + return ""; + } const diff = comment.diff.trim(); if (!diff) { return ""; diff --git a/apps/web/src/components/ChatMarkdown.browser.tsx b/apps/web/src/components/ChatMarkdown.browser.tsx index 4eeab3e80759..7ef340976640 100644 --- a/apps/web/src/components/ChatMarkdown.browser.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.tsx @@ -69,6 +69,26 @@ describe("ChatMarkdown", () => { document.body.innerHTML = ""; }); + it("makes task-list checkboxes interactive when a change handler is provided", async () => { + const onTaskListChange = vi.fn(); + const screen = await render( + , + ); + + try { + const checkbox = page.getByRole("checkbox", { name: "Toggle task" }); + await expect.element(checkbox).not.toBeDisabled(); + await checkbox.click(); + expect(onTaskListChange).toHaveBeenCalledWith({ markerOffset: 2, checked: true }); + } finally { + await screen.unmount(); + } + }); + it("rewrites file uri hrefs into direct paths before rendering", async () => { const filePath = "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts"; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index a6f3036e4ec4..90b13bd0ef36 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -92,6 +92,7 @@ interface ChatMarkdownProps { text: string; cwd: string | undefined; threadRef?: ScopedThreadRef | undefined; + onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined; isStreaming?: boolean; skills?: ReadonlyArray>; className?: string; @@ -109,6 +110,17 @@ const highlightedCodeCache = new LRUCache( MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, ); const highlighterPromiseCache = new Map>(); + +function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null { + const firstLineEnd = markdown.indexOf("\n", listItemStart); + const firstLine = markdown.slice( + listItemStart, + firstLineEnd === -1 ? markdown.length : firstLineEnd, + ); + const match = firstLine.match(/^(?:\s*(?:[-+*]|\d+[.)])\s+)(\[[ xX]\])/); + if (!match?.[1]) return null; + return listItemStart + firstLine.indexOf(match[1]); +} const CHAT_MARKDOWN_SANITIZE_SCHEMA = { ...defaultSchema, attributes: { @@ -1171,6 +1183,7 @@ function ChatMarkdown({ text, cwd, threadRef, + onTaskListChange, isStreaming = false, skills = EMPTY_MARKDOWN_SKILLS, className, @@ -1216,8 +1229,44 @@ function ChatMarkdown({ p({ node: _node, children, ...props }) { return

{renderSkillInlineMarkdownChildren(children, skills)}

; }, - li({ node: _node, children, ...props }) { - return
  • {renderSkillInlineMarkdownChildren(children, skills)}
  • ; + li({ node, children, ...props }) { + const listItemStart = node?.position?.start.offset; + const markerOffset = + typeof listItemStart === "number" ? findTaskListMarkerOffset(text, listItemStart) : null; + return ( +
  • + {renderSkillInlineMarkdownChildren(children, skills)} +
  • + ); + }, + input({ node: _node, type, checked, disabled: _disabled, ...props }) { + if (type !== "checkbox" || !onTaskListChange) { + return ( + + ); + } + return ( + { + const markerOffset = Number( + event.currentTarget.closest("li")?.dataset.taskMarkerOffset, + ); + if (!Number.isSafeInteger(markerOffset)) return; + onTaskListChange({ markerOffset, checked: event.currentTarget.checked }); + }} + /> + ); }, a({ node, href, children, ...props }) { const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; @@ -1386,9 +1435,11 @@ function ChatMarkdown({ fileLinkParentSuffixByPath, isStreaming, markdownFileLinkMetaByHref, + onTaskListChange, threadRef, resolvedTheme, skills, + text, ], ); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 695d89bf0425..eaa47c670b26 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2110,6 +2110,48 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("keeps the compact chat header on one row", async () => { + const mounted = await mountChatView({ + viewport: COMPACT_FOOTER_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-compact-header" as MessageId, + targetText: "keep the compact header aligned", + }), + }); + + try { + const chatHeader = await waitForElement( + () => document.querySelector("[data-chat-header]"), + "Unable to find chat header.", + ); + const threadTitle = await waitForElement( + () => chatHeader.querySelector("h2"), + "Unable to find thread title.", + ); + const headerActions = await waitForElement( + () => document.querySelector("[data-chat-header-actions]"), + "Unable to find chat header actions.", + ); + + const headerRect = chatHeader.getBoundingClientRect(); + const titleRect = threadTitle.getBoundingClientRect(); + const actionsRect = headerActions.getBoundingClientRect(); + const headerCenter = headerRect.top + headerRect.height / 2; + + expect(headerRect.height).toBe(52); + expect(titleRect.top).toBeGreaterThanOrEqual(headerRect.top); + expect(titleRect.bottom).toBeLessThanOrEqual(headerRect.bottom); + expect(actionsRect.top).toBeGreaterThanOrEqual(headerRect.top); + expect(actionsRect.bottom).toBeLessThanOrEqual(headerRect.bottom); + expect(Math.abs(titleRect.top + titleRect.height / 2 - headerCenter)).toBeLessThanOrEqual(1); + expect(Math.abs(actionsRect.top + actionsRect.height / 2 - headerCenter)).toBeLessThanOrEqual( + 1, + ); + } finally { + await mounted.cleanup(); + } + }); + it("keeps panel toggles fixed and can maximize the right panel", async () => { const mounted = await mountChatView({ viewport: WIDE_FOOTER_VIEWPORT, @@ -2276,6 +2318,151 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("renders the plan surface in the inline right panel", async () => { + useRightPanelStore.getState().open(THREAD_REF, "plan"); + + const mounted = await mountChatView({ + viewport: WIDE_FOOTER_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-inline-plan-panel" as MessageId, + targetText: "show the inline plan panel", + }), + }); + + try { + await waitForElement( + () => + Array.from(document.querySelectorAll("p")).find( + (element) => element.textContent?.trim() === "No active plan yet.", + ) ?? null, + "Unable to find inline plan panel content.", + ); + + expect( + document.querySelector("[data-right-panel-tabbar]")?.textContent, + ).toContain("Plan"); + expect(document.body.textContent).toContain("Plans will appear here when generated."); + } finally { + await mounted.cleanup(); + } + }); + + it("renders the shared panel toggles in the responsive right-panel sheet", async () => { + useRightPanelStore.getState().open(THREAD_REF, "plan"); + useRightPanelStore.getState().openTerminal(THREAD_REF, DEFAULT_TERMINAL_ID); + useRightPanelStore.getState().activateSurface(THREAD_REF, "plan"); + const baseSnapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-responsive-plan-panel-controls" as MessageId, + targetText: "show responsive plan panel controls", + }); + const snapshot: OrchestrationReadModel = { + ...baseSnapshot, + threads: baseSnapshot.threads.map((thread) => + thread.id === THREAD_ID + ? { + ...thread, + activities: [ + { + id: EventId.make("activity-responsive-panel-plan"), + tone: "info", + kind: "turn.plan.updated", + summary: "Plan updated", + payload: { + explanation: "Claude Tasks", + plan: [{ step: "Keep terminal navigation available", status: "inProgress" }], + }, + turnId: null, + sequence: 1, + createdAt: isoAt(1_000), + }, + ], + } + : thread, + ), + }; + + const mounted = await mountChatView({ + viewport: COMPACT_FOOTER_VIEWPORT, + snapshot, + }); + + try { + const sheet = await waitForElement( + () => document.querySelector('[data-slot="sheet-popup"]'), + "Unable to find responsive right-panel sheet.", + ); + const controls = await waitForElement( + () => sheet.querySelector("[data-panel-layout-controls]"), + "Unable to find shared controls in the responsive right-panel sheet.", + ); + const tabbar = await waitForElement( + () => sheet.querySelector("[data-right-panel-tabbar]"), + "Unable to find responsive right-panel tabbar.", + ); + const controlButtons = Array.from(controls.querySelectorAll("button")); + const tabbarRect = tabbar.getBoundingClientRect(); + const controlsRect = controls.getBoundingClientRect(); + + expect(controlButtons.map((button) => button.getAttribute("aria-label"))).toEqual([ + "Toggle terminal drawer", + "Toggle right panel", + ]); + expect(tabbarRect.height).toBe(52); + expect(controlsRect.height).toBe(52); + expect(controlsRect.top).toBe(tabbarRect.top); + expect(window.innerWidth - controlsRect.right).toBe(12); + for (const button of controlButtons) { + const rect = button.getBoundingClientRect(); + const buttonCenter = rect.top + rect.height / 2; + const tabbarCenter = tabbarRect.top + tabbarRect.height / 2; + expect(rect.width).toBe(32); + expect(rect.height).toBe(32); + expect(Math.abs(buttonCenter - tabbarCenter)).toBeLessThanOrEqual(1); + } + expect( + controlButtons[1]!.getBoundingClientRect().left - + controlButtons[0]!.getBoundingClientRect().right, + ).toBe(4); + expect(sheet.querySelector('button[aria-label="Maximize panel"]')).toBeNull(); + expect(sheet.querySelector('button[aria-label="Close tasks sidebar"]')).toBeNull(); + + const terminalTab = Array.from( + sheet.querySelectorAll("[data-right-panel-tab-list] button"), + ).find((button) => button.textContent?.includes("Terminal")); + terminalTab?.click(); + + await vi.waitFor(() => { + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) + .activeSurfaceId, + ).toBe(`terminal:${DEFAULT_TERMINAL_ID}`); + expect(sheet.querySelector('[data-terminal-owner="right-panel"]')).not.toBeNull(); + expect(sheet.textContent).not.toContain("Claude Tasks"); + }); + + sheet.querySelector('button[aria-label="Close Plan"]')?.click(); + + await vi.waitFor(() => { + const panelState = selectThreadRightPanelState( + useRightPanelStore.getState().byThreadKey, + THREAD_REF, + ); + expect(panelState.surfaces.some((surface) => surface.kind === "plan")).toBe(false); + expect(panelState.activeSurfaceId).toBe(`terminal:${DEFAULT_TERMINAL_ID}`); + }); + + controls.querySelector('button[aria-label="Toggle right panel"]')?.click(); + + await vi.waitFor(() => { + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF).isOpen, + ).toBe(false); + }); + } finally { + await mounted.cleanup(); + } + }); + it("loads file previews from the active thread worktree", async () => { const worktreePath = "/repo/worktrees/file-preview-thread"; const snapshot = createSnapshotForTargetUser({ diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 673a24dbe02c..593d7a9fe2b3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -164,13 +164,14 @@ import { formatElementContextLabel, } from "../lib/elementContext"; import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation"; +import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; -import { PanelLayoutControls } from "./chat/PanelLayoutControls"; import { normalizeRuntimeModeForProvider } from "./chat/runtimeModePresentation"; +import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; @@ -1120,6 +1121,7 @@ function ChatViewContent(props: ChatViewProps) { const setComposerDraftPreviewAnnotations = useComposerDraftStore( (store) => store.setPreviewAnnotations, ); + const setComposerDraftReviewComments = useComposerDraftStore((store) => store.setReviewComments); const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); const setComposerDraftInteractionMode = useComposerDraftStore( @@ -1332,9 +1334,8 @@ function ChatViewContent(props: ChatViewProps) { const planSidebarOpen = activeRightPanelKind === "plan"; const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); const rightPanelOpen = rightPanelState.isOpen; - const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; const rightPanelMaximized = - canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; + rightPanelOpen && !shouldUsePlanSidebarSheet && maximizedRightPanelThreadKey === routeThreadKey; const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; useEffect(() => { @@ -3065,23 +3066,25 @@ function ChatViewContent(props: ChatViewProps) { const toggleInteractionMode = useCallback(() => { handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); }, [handleInteractionModeChange, interactionMode]); + const dismissPlanSidebarForCurrentTurn = useCallback(() => { + planSidebarDismissedForTurnRef.current = + activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; + }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); const togglePlanSidebar = useCallback(() => { if (!activeThreadRef) return; if (planSidebarOpen) { - planSidebarDismissedForTurnRef.current = - activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; + dismissPlanSidebarForCurrentTurn(); } else { planSidebarDismissedForTurnRef.current = null; } useRightPanelStore.getState().toggle(activeThreadRef, "plan"); - }, [activePlan?.turnId, activeThreadRef, planSidebarOpen, sidebarProposedPlan?.turnId]); + }, [activeThreadRef, dismissPlanSidebarForCurrentTurn, planSidebarOpen]); const closePlanSidebar = useCallback(() => { if (!activeThreadRef) return; setMaximizedRightPanelThreadKey(null); useRightPanelStore.getState().close(activeThreadRef); - planSidebarDismissedForTurnRef.current = - activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; - }, [activePlan?.turnId, activeThreadRef, sidebarProposedPlan?.turnId]); + dismissPlanSidebarForCurrentTurn(); + }, [activeThreadRef, dismissPlanSidebarForCurrentTurn]); const closePreviewPanel = useCallback(() => { if (!activeThreadRef) return; setMaximizedRightPanelThreadKey(null); @@ -3090,6 +3093,11 @@ function ChatViewContent(props: ChatViewProps) { const activateRightPanelSurface = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; + if (surface.kind === "plan") { + planSidebarDismissedForTurnRef.current = null; + } else if (planSidebarOpen) { + dismissPlanSidebarForCurrentTurn(); + } useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); if (surface.kind === "preview" && surface.resourceId) { usePreviewStateStore.getState().setActiveTab(activeThreadRef, surface.resourceId); @@ -3114,41 +3122,51 @@ function ChatViewContent(props: ChatViewProps) { }); } }, - [activeThreadRef, diffOpen, environmentId, navigate, onDiffPanelOpen, threadId], + [ + activeThreadRef, + diffOpen, + dismissPlanSidebarForCurrentTurn, + environmentId, + navigate, + onDiffPanelOpen, + planSidebarOpen, + threadId, + ], ); const toggleRightPanel = useCallback(() => { if (!activeThreadRef) return; if (rightPanelOpen) { - setMaximizedRightPanelThreadKey(null); + if (planSidebarOpen) { + closePlanSidebar(); + } else { + closePreviewPanel(); + } + return; } useRightPanelStore.getState().toggleVisibility(activeThreadRef); - }, [activeThreadRef, rightPanelOpen]); + }, [activeThreadRef, closePlanSidebar, closePreviewPanel, planSidebarOpen, rightPanelOpen]); const toggleRightPanelMaximized = () => { - if (!canMaximizeRightPanel) return; + if (!rightPanelOpen || shouldUsePlanSidebarSheet) return; setMaximizedRightPanelThreadKey((threadKey) => threadKey === routeThreadKey ? null : routeThreadKey, ); }; - const closeRightPanelSurface = useCallback( - (surface: RightPanelSurface) => { + const cleanupRightPanelSurfaces = useCallback( + (surfaces: readonly RightPanelSurface[]) => { if (!activeThreadRef) return; - if (surface.kind === "preview" && surface.resourceId) { - usePreviewStateStore.getState().removeSession(activeThreadRef, surface.resourceId); - const api = readEnvironmentApi(activeThreadRef.environmentId); - void api?.preview - .close({ threadId: activeThreadRef.threadId, tabId: surface.resourceId }) - .catch(() => undefined); - } - useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id); - const nextActiveSurface = selectActiveRightPanelSurface( - useRightPanelStore.getState().byThreadKey, - activeThreadRef, - ); - if (nextActiveSurface?.kind === "preview" && nextActiveSurface.resourceId) { - usePreviewStateStore.getState().setActiveTab(activeThreadRef, nextActiveSurface.resourceId); + if (surfaces.some((surface) => surface.kind === "plan")) { + dismissPlanSidebarForCurrentTurn(); } - if (surface.kind === "terminal") { - const api = readEnvironmentApi(activeThreadRef.environmentId); + + const api = readEnvironmentApi(activeThreadRef.environmentId); + for (const surface of surfaces) { + if (surface.kind === "preview" && surface.resourceId) { + usePreviewStateStore.getState().removeSession(activeThreadRef, surface.resourceId); + void api?.preview + .close({ threadId: activeThreadRef.threadId, tabId: surface.resourceId }) + .catch(() => undefined); + } + if (surface.kind !== "terminal") continue; for (const terminalId of surface.terminalIds) { void api?.terminal .close({ @@ -3159,7 +3177,8 @@ function ChatViewContent(props: ChatViewProps) { .catch(() => undefined); } } - if (surface.kind === "diff" && diffOpen) { + + if (diffOpen && surfaces.some((surface) => surface.kind === "diff")) { void navigate({ to: "/$environmentId/$threadId", params: { environmentId, threadId }, @@ -3168,8 +3187,102 @@ function ChatViewContent(props: ChatViewProps) { }); } }, - [activeThreadRef, diffOpen, environmentId, navigate, threadId], + [ + activeThreadRef, + diffOpen, + dismissPlanSidebarForCurrentTurn, + environmentId, + navigate, + threadId, + ], + ); + const syncActivePreviewSurface = useCallback(() => { + if (!activeThreadRef) return; + const nextActiveSurface = selectActiveRightPanelSurface( + useRightPanelStore.getState().byThreadKey, + activeThreadRef, + ); + if (nextActiveSurface?.kind === "preview" && nextActiveSurface.resourceId) { + usePreviewStateStore.getState().setActiveTab(activeThreadRef, nextActiveSurface.resourceId); + } + }, [activeThreadRef]); + const closeRightPanelSurface = useCallback( + (surface: RightPanelSurface) => { + if (!activeThreadRef) return; + cleanupRightPanelSurfaces([surface]); + useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id); + syncActivePreviewSurface(); + }, + [activeThreadRef, cleanupRightPanelSurfaces, syncActivePreviewSurface], + ); + const closeOtherRightPanelSurfaces = useCallback( + (surface: RightPanelSurface) => { + if (!activeThreadRef) return; + const surfaces = rightPanelState.surfaces.filter((entry) => entry.id !== surface.id); + cleanupRightPanelSurfaces(surfaces); + useRightPanelStore.getState().closeOtherSurfaces(activeThreadRef, surface.id); + syncActivePreviewSurface(); + }, + [ + activeThreadRef, + cleanupRightPanelSurfaces, + rightPanelState.surfaces, + syncActivePreviewSurface, + ], + ); + const closeRightPanelSurfacesToRight = useCallback( + (surface: RightPanelSurface) => { + if (!activeThreadRef) return; + const surfaceIndex = rightPanelState.surfaces.findIndex((entry) => entry.id === surface.id); + if (surfaceIndex < 0) return; + const surfaces = rightPanelState.surfaces.slice(surfaceIndex + 1); + cleanupRightPanelSurfaces(surfaces); + useRightPanelStore.getState().closeSurfacesToRight(activeThreadRef, surface.id); + syncActivePreviewSurface(); + }, + [ + activeThreadRef, + cleanupRightPanelSurfaces, + rightPanelState.surfaces, + syncActivePreviewSurface, + ], ); + const closeAllRightPanelSurfaces = useCallback(() => { + if (!activeThreadRef) return; + cleanupRightPanelSurfaces(rightPanelState.surfaces); + useRightPanelStore.getState().closeAllSurfaces(activeThreadRef); + }, [activeThreadRef, cleanupRightPanelSurfaces, rightPanelState.surfaces]); + const copyRightPanelFilePath = useCallback((relativePath: string) => { + if (typeof window === "undefined" || !navigator.clipboard?.writeText) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to copy path", + description: "Clipboard API unavailable.", + }), + ); + return; + } + + void navigator.clipboard.writeText(relativePath).then( + () => { + toastManager.add({ + type: "success", + title: "Path copied", + description: relativePath, + }); + }, + (error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to copy path", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, + ); + }, []); const persistThreadSettingsForNextTurn = useCallback( async (input: { threadId: ThreadId; @@ -3667,6 +3780,7 @@ function ChatViewContent(props: ChatViewProps) { terminalContexts: composerTerminalContexts, elementContexts: composerElementContexts, previewAnnotations: composerPreviewAnnotations, + reviewComments: composerReviewComments, selectedProvider: ctxSelectedProvider, selectedModel: ctxSelectedModel, selectedProviderModels: ctxSelectedProviderModels, @@ -3683,7 +3797,10 @@ function ChatViewContent(props: ChatViewProps) { prompt: promptForSend, imageCount: composerImages.length, terminalContexts: composerTerminalContexts, - elementContextCount: composerElementContexts.length + composerPreviewAnnotations.length, + elementContextCount: + composerElementContexts.length + + composerPreviewAnnotations.length + + composerReviewComments.length, }); if (showPlanFollowUpPrompt && activeProposedPlan) { const followUp = resolvePlanFollowUpSubmission({ @@ -3703,7 +3820,8 @@ function ChatViewContent(props: ChatViewProps) { composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 && composerElementContexts.length === 0 && - composerPreviewAnnotations.length === 0 + composerPreviewAnnotations.length === 0 && + composerReviewComments.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; if (standaloneSlashCommand) { @@ -3753,14 +3871,19 @@ function ChatViewContent(props: ChatViewProps) { const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; const composerElementContextsSnapshot = [...composerElementContexts]; const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; + const composerReviewCommentsSnapshot: ReviewCommentContext[] = [...composerReviewComments]; const messageTextWithContexts = appendElementContextsToPrompt( appendTerminalContextsToPrompt(promptForSend, composerTerminalContextsSnapshot), composerElementContextsSnapshot, ); - const messageTextForSend = composerPreviewAnnotationsSnapshot.reduce( + const messageTextWithPreviewAnnotations = composerPreviewAnnotationsSnapshot.reduce( (text, annotation) => appendPreviewAnnotationPrompt(text, annotation), messageTextWithContexts, ); + const messageTextForSend = appendReviewCommentsToPrompt( + messageTextWithPreviewAnnotations, + composerReviewCommentsSnapshot, + ); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ @@ -3930,6 +4053,8 @@ function ChatViewContent(props: ChatViewProps) { composerTerminalContextsRef.current.length === 0 && composerElementContextsRef.current.length === 0 && (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.previewAnnotations + .length ?? 0) === 0 && + (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments .length ?? 0) === 0 ) { setOptimisticUserMessages((existing) => { @@ -3950,6 +4075,7 @@ function ChatViewContent(props: ChatViewProps) { setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); setComposerDraftElementContexts(composerDraftTarget, composerElementContextsSnapshot); setComposerDraftPreviewAnnotations(composerDraftTarget, composerPreviewAnnotationsSnapshot); + setComposerDraftReviewComments(composerDraftTarget, composerReviewCommentsSnapshot); composerRef.current?.resetCursorState({ cursor: collapseExpandedComposerCursor(promptForSend, promptForSend.length), prompt: promptForSend, @@ -4589,7 +4715,7 @@ function ChatViewContent(props: ChatViewProps) { return ; } - const panelLayoutControls = ( + const panelToggleControls = ( ); + const panelLayoutControls = ( +
    + {rightPanelOpen && !shouldUsePlanSidebarSheet ? ( + + ) : null} + {panelToggleControls} +
    + ); + const rightPanelContent = activeThreadRef ? ( + activeRightPanelSurface?.kind === "preview" ? ( + + + + ) : activeRightPanelSurface?.kind === "terminal" ? ( + + ) : activeRightPanelSurface?.kind === "diff" ? ( + + + + ) : activeRightPanelSurface?.kind === "plan" ? ( + + ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") && + activeProject && + activeWorkspaceRoot ? ( + + + + ) : null + ) : null; return (
    {isElectron && activeThreadRef ? ( ) : null} - {rightPanelOpen ? panelLayoutControls : null} + {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null}
    - {activeRightPanelSurface?.kind === "preview" ? ( - - - - ) : activeRightPanelSurface?.kind === "terminal" ? ( - - ) : activeRightPanelSurface?.kind === "diff" ? ( - - - - ) : (activeRightPanelSurface?.kind === "files" || - activeRightPanelSurface?.kind === "file") && - activeProject && - activeWorkspaceRoot ? ( - - - - ) : null} + {rightPanelContent} ) : null} {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( - + - {activeRightPanelSurface?.kind === "preview" ? ( - - - - ) : activeRightPanelSurface?.kind === "terminal" ? ( - - ) : activeRightPanelSurface?.kind === "diff" ? ( - - - - ) : activeRightPanelSurface?.kind === "plan" ? ( - - ) : (activeRightPanelSurface?.kind === "files" || - activeRightPanelSurface?.kind === "file") && - activeProject && - activeWorkspaceRoot ? ( - - - - ) : null} + {rightPanelContent} ) : null} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 0015d596640d..cb70013844e9 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,7 +1,7 @@ -import { FileDiff, Virtualizer } from "@pierre/diffs/react"; +import { Virtualizer } from "@pierre/diffs/react"; import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { scopeThreadRef } from "@t3tools/client-runtime"; -import type { TurnId } from "@t3tools/contracts"; +import type { ScopedThreadRef, TurnId } from "@t3tools/contracts"; import { ChevronDownIcon, ChevronLeftIcon, @@ -20,6 +20,7 @@ import { useState, } from "react"; import { openInPreferredEditor } from "../editorPreferences"; +import { type DraftId } from "../composerDraftStore"; import { useCheckpointDiff } from "~/lib/checkpointDiffState"; import { useVcsStatus } from "~/lib/vcsStatusState"; import { cn } from "~/lib/utils"; @@ -41,6 +42,7 @@ import { createThreadSelectorByRef } from "../storeSelectors"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; +import { AnnotatableFileDiff } from "./diffs/AnnotatableFileDiff"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -147,11 +149,12 @@ const DIFF_PANEL_UNSAFE_CSS = ` interface DiffPanelProps { mode?: DiffPanelMode; + composerDraftTarget: ScopedThreadRef | DraftId; } export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; -export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { +export default function DiffPanel({ mode = "inline", composerDraftTarget }: DiffPanelProps) { const navigate = useNavigate(); const { settings } = useAppSettings(); const { resolvedTheme } = useTheme(); @@ -218,6 +221,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const selectedCheckpointTurnCount = selectedTurn && (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]); + const reviewSectionId = selectedTurn ? `turn:${selectedTurn.turnId}` : "conversation"; + const reviewSectionTitle = selectedTurn + ? `Turn ${selectedCheckpointTurnCount ?? "?"}` + : "All turns"; const selectedCheckpointRange = useMemo( () => typeof selectedCheckpointTurnCount === "number" @@ -677,8 +684,12 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { openDiffFile(filePath); }} > - ( void; } const PlanSidebar = memo(function PlanSidebar({ @@ -74,7 +72,6 @@ const PlanSidebar = memo(function PlanSidebar({ workspaceRoot, timestampFormat, mode = "sidebar", - onClose, }: PlanSidebarProps) { const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); @@ -182,15 +179,6 @@ const PlanSidebar = memo(function PlanSidebar({ ) : null} -
    diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index bf99a7f20487..ead306f7c906 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,11 +1,20 @@ -import type { PreviewSessionSnapshot } from "@t3tools/contracts"; +import type { ContextMenuItem, PreviewSessionSnapshot } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { ClipboardList, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react"; -import { type ReactElement, type ReactNode, useEffect, useRef, useState } from "react"; +import { + type MouseEvent as ReactMouseEvent, + type ReactElement, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import { isElectron } from "~/env"; import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; +import { readLocalApi } from "~/localApi"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; import { ScrollArea } from "~/components/ui/scroll-area"; @@ -18,6 +27,7 @@ import { PierreEntryIcon } from "./chat/PierreEntryIcon"; interface RightPanelTabsProps { mode: PreviewPanelMode; maximized?: boolean; + layoutControls?: ReactNode; surfaces: readonly RightPanelSurface[]; activeSurfaceId: string | null; pendingSurfaceIds: ReadonlySet; @@ -25,6 +35,10 @@ interface RightPanelTabsProps { terminalLabelsById: ReadonlyMap; onActivate: (surface: RightPanelSurface) => void; onCloseSurface: (surface: RightPanelSurface) => void; + onCloseOtherSurfaces: (surface: RightPanelSurface) => void; + onCloseSurfacesToRight: (surface: RightPanelSurface) => void; + onCloseAllSurfaces: () => void; + onCopyFilePath: (relativePath: string) => void; onAddBrowser: () => void; onAddTerminal: () => void; onAddDiff: () => void; @@ -41,6 +55,8 @@ const SURFACE_DISABLED_REASONS = { diff: "Diff is only available for server threads in Git repositories.", } as const; +type TabContextMenuAction = "copy-path" | "close" | "close-others" | "close-to-right" | "close-all"; + function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { return ( @@ -257,6 +273,64 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const { resolvedTheme } = useTheme(); const tabListRef = useRef(null); + const handleTabContextMenu = useCallback( + async (event: ReactMouseEvent, surface: RightPanelSurface) => { + event.preventDefault(); + event.stopPropagation(); + + const api = readLocalApi(); + if (!api) return; + + const surfaceIndex = props.surfaces.findIndex((entry) => entry.id === surface.id); + if (surfaceIndex < 0) return; + + const items: ContextMenuItem[] = []; + if (surface.kind === "file") { + items.push({ id: "copy-path", label: "Copy path" }); + } + items.push( + { id: "close", label: "Close" }, + { + id: "close-others", + label: "Close others", + disabled: props.surfaces.length <= 1, + }, + { + id: "close-to-right", + label: "Close to the right", + disabled: surfaceIndex >= props.surfaces.length - 1, + }, + { + id: "close-all", + label: "Close all", + disabled: props.surfaces.length === 0, + }, + ); + + const action = await api.contextMenu.show(items, { x: event.clientX, y: event.clientY }); + switch (action) { + case "copy-path": + if (surface.kind === "file") props.onCopyFilePath(surface.relativePath); + break; + case "close": + props.onCloseSurface(surface); + break; + case "close-others": + props.onCloseOtherSurfaces(surface); + break; + case "close-to-right": + props.onCloseSurfacesToRight(surface); + break; + case "close-all": + props.onCloseAllSurfaces(); + break; + case null: + break; + } + }, + [props], + ); + useEffect(() => { const activeTab = tabListRef.current?.querySelector("[data-active-tab='true']"); activeTab?.scrollIntoView({ block: "nearest", inline: "nearest" }); @@ -269,8 +343,8 @@ export function RightPanelTabs(props: RightPanelTabsProps) { >
    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 ( - + } + render={ + + + ); +} + +async function getEditableFile() { + const file = await vi.waitFor(() => { + const element = document.querySelector("diffs-container"); + expect(element?.shadowRoot).not.toBeNull(); + return element!; + }); + const content = await vi.waitFor(() => { + const element = file?.shadowRoot?.querySelector("[data-content]") ?? null; + expect(element).not.toBeNull(); + return element!; + }); + return { file, content }; +} + +describe("editable Pierre file annotations", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("keeps gutter selection and annotations enabled while the file is editable", async () => { + const screen = await render(); + + try { + const { file, content } = await getEditableFile(); + const secondLineNumber = await vi.waitFor(() => { + const element = + file?.shadowRoot?.querySelector('[data-column-number="2"]') ?? null; + expect(element).not.toBeNull(); + return element; + }); + await vi.waitFor(() => { + expect( + file?.shadowRoot?.querySelector("pre")?.hasAttribute("data-interactive-line-numbers"), + ).toBe(true); + }); + + dispatchPointer(secondLineNumber!, "pointerdown", 1); + dispatchPointer(secondLineNumber!, "pointerup", 1); + + await vi.waitFor(() => { + expect(document.querySelector("[data-test-file-annotation]")?.textContent).toBe("2:2"); + }); + + expect(content.contentEditable).toBe("true"); + expect(content.getAttribute("role")).toBe("textbox"); + } finally { + await screen.unmount(); + } + }); + + it("dismisses editor focus and selection with outside click or Escape", async () => { + const screen = await render(); + + try { + const { file, content } = await getEditableFile(); + content.focus(); + expect(file?.shadowRoot?.activeElement).toBe(content); + + await page.getByRole("button", { name: "Outside file" }).click(); + await vi.waitFor(() => { + expect(file?.shadowRoot?.activeElement).not.toBe(content); + }); + + content.focus(); + expect(file?.shadowRoot?.activeElement).toBe(content); + content.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + composed: true, + }), + ); + await vi.waitFor(() => { + expect(file?.shadowRoot?.activeElement).not.toBe(content); + }); + } finally { + await screen.unmount(); + } + }); +}); diff --git a/apps/web/src/components/files/FilePreviewPanel.test.ts b/apps/web/src/components/files/FilePreviewPanel.test.ts new file mode 100644 index 000000000000..3b5295f180eb --- /dev/null +++ b/apps/web/src/components/files/FilePreviewPanel.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + formatFileCommentRange, + normalizeFileCommentRange, + remapFileCommentAnnotations, +} from "./fileCommentAnnotations"; +import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode"; + +describe("file comment annotations", () => { + it("normalizes and formats selected line ranges", () => { + expect(normalizeFileCommentRange({ start: 16, end: 7 })).toEqual({ + startLine: 7, + endLine: 16, + }); + expect(formatFileCommentRange(7, 7)).toBe("L7"); + expect(formatFileCommentRange(7, 16)).toBe("L7 to L16"); + }); + + it("keeps an annotation range attached when Pierre remaps its anchor line", () => { + expect( + remapFileCommentAnnotations([ + { + lineNumber: 20, + metadata: { + entries: [ + { + id: "comment-1", + kind: "comment", + startLine: 7, + endLine: 16, + text: "Keep this guarded.", + }, + ], + }, + }, + ]), + ).toEqual([ + { + lineNumber: 20, + metadata: { + entries: [ + { + id: "comment-1", + kind: "comment", + startLine: 11, + endLine: 20, + text: "Keep this guarded.", + }, + ], + }, + }, + ]); + }); +}); + +describe("isMarkdownPreviewFile", () => { + it("recognizes markdown and MDX files case-insensitively", () => { + expect(isMarkdownPreviewFile("README.md")).toBe(true); + expect(isMarkdownPreviewFile("docs/guide.MDX")).toBe(true); + }); + + it("does not treat other text files as markdown", () => { + expect(isMarkdownPreviewFile("docs/guide.txt")).toBe(false); + expect(isMarkdownPreviewFile("docs/markdown.ts")).toBe(false); + }); +}); + +describe("setMarkdownTaskChecked", () => { + const markdown = "- [ ] First\n- [x] Second\n"; + + it("checks and unchecks the task marker at the supplied offset", () => { + expect(setMarkdownTaskChecked(markdown, 2, true)).toBe("- [x] First\n- [x] Second\n"); + expect(setMarkdownTaskChecked(markdown, 14, false)).toBe("- [ ] First\n- [ ] Second\n"); + expect(setMarkdownTaskChecked("1. [X] Ordered\n", 3, false)).toBe("1. [ ] Ordered\n"); + }); + + it("leaves the document unchanged for a stale or invalid marker offset", () => { + expect(setMarkdownTaskChecked(markdown, 0, true)).toBe(markdown); + expect(setMarkdownTaskChecked(markdown, 200, true)).toBe(markdown); + }); +}); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index ec76ee84d463..d3d24a79bd6a 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -1,23 +1,51 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import type { + EditorId, + EnvironmentId, + ResolvedKeybindingsConfig, + ScopedThreadRef, +} from "@t3tools/contracts"; +import type { SelectedLineRange } from "@pierre/diffs"; import { Editor } from "@pierre/diffs/editor"; import { EditorProvider, File, Virtualizer } from "@pierre/diffs/react"; -import { ChevronRight, FolderTree, LoaderCircle } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; +import ChatMarkdown from "~/components/ChatMarkdown"; +import { OpenInPicker } from "~/components/chat/OpenInPicker"; import { ensureEnvironmentApi } from "~/environmentApi"; +import { usePrimaryEnvironmentId } from "~/environments/primary/context"; import { useTheme } from "~/hooks/useTheme"; import { resolveDiffThemeName } from "~/lib/diffRendering"; import { cn } from "~/lib/utils"; +import { isPreviewSupportedInRuntime } from "~/previewStateStore"; +import { resolvePathLinkTarget } from "~/terminal-links"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Toggle } from "~/components/ui/toggle"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; +import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; +import { buildFileReviewComment } from "~/reviewCommentContext"; import FileBrowserPanel from "./FileBrowserPanel"; +import { + type FileCommentAnnotationEntry, + type FileCommentAnnotationGroup, + type FileCommentLineAnnotation, + formatFileCommentRange, + nextFileCommentId, + normalizeFileCommentRange, + remapFileCommentAnnotations, +} from "./fileCommentAnnotations"; +import { installFileEditorDismissal } from "./fileEditorDismissal"; +import { LocalCommentAnnotation } from "./LocalCommentAnnotation"; import { projectFileCacheKey } from "./fileContentRevision"; import { fileBreadcrumbs } from "./filePath"; +import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode"; import { FileSaveCoordinator } from "./fileSaveCoordinator"; import { confirmProjectFileQueryData, + getOptimisticProjectFileQueryData, setProjectFileQueryData, useProjectFileQuery, } from "./projectFilesQueryState"; @@ -27,6 +55,10 @@ interface FilePreviewPanelProps { cwd: string; projectName: string; relativePath: string | null; + threadRef: ScopedThreadRef; + composerDraftTarget: ScopedThreadRef | DraftId; + keybindings: ResolvedKeybindingsConfig; + availableEditors: ReadonlyArray; onOpenFile: (relativePath: string) => void; onPendingChange: (relativePath: string, pending: boolean) => void; } @@ -38,20 +70,22 @@ interface EditableFileSurfaceProps { environmentId: EnvironmentId; cwd: string; relativePath: string; + composerDraftTarget: ScopedThreadRef | DraftId; contents: string; resolvedTheme: "light" | "dark"; onPendingChange: (relativePath: string, pending: boolean) => void; } -function EditableFileSurface({ +function useFileSaveCoordinator({ environmentId, cwd, relativePath, - contents, - resolvedTheme, onPendingChange, -}: EditableFileSurfaceProps) { - const saveCoordinator = useMemo( +}: Pick< + EditableFileSurfaceProps, + "environmentId" | "cwd" | "relativePath" | "onPendingChange" +>): FileSaveCoordinator { + const coordinator = useMemo( () => new FileSaveCoordinator({ debounceMs: FILE_SAVE_DEBOUNCE_MS, @@ -69,54 +103,270 @@ function EditableFileSurface({ }), [cwd, environmentId, onPendingChange, relativePath], ); + + useEffect(() => () => coordinator.dispose(), [coordinator]); + return coordinator; +} + +function EditableFileSurface({ + environmentId, + cwd, + relativePath, + composerDraftTarget, + contents, + resolvedTheme, + onPendingChange, +}: EditableFileSurfaceProps) { + const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); + const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); + const [lineAnnotations, setLineAnnotations] = useState([]); + const [selectedRange, setSelectedRange] = useState(null); + const surfaceRef = useRef(null); + const saveCoordinator = useFileSaveCoordinator({ + environmentId, + cwd, + relativePath, + onPendingChange, + }); const editor = useMemo( () => - new Editor({ - onChange: (file) => { + new Editor({ + onChange: (file, nextLineAnnotations) => { setProjectFileQueryData(environmentId, cwd, relativePath, file.contents); saveCoordinator.change(file.contents); + if (nextLineAnnotations) { + const remapped = remapFileCommentAnnotations( + nextLineAnnotations as FileCommentLineAnnotation[], + ); + setLineAnnotations(remapped); + for (const annotation of remapped) { + for (const entry of annotation.metadata.entries) { + if (entry.kind !== "comment") continue; + addReviewComment( + composerDraftTarget, + buildFileReviewComment({ + id: entry.id, + filePath: relativePath, + startLine: entry.startLine, + endLine: entry.endLine, + text: entry.text, + contents: file.contents, + }), + ); + } + } + } }, }), - [cwd, environmentId, relativePath, saveCoordinator], + [addReviewComment, composerDraftTarget, cwd, environmentId, relativePath, saveCoordinator], ); useEffect( () => () => { editor.cleanUp(); - saveCoordinator.dispose(); }, - [editor, saveCoordinator], + [editor], + ); + + const removeAnnotationEntry = useCallback( + (entryId: string) => { + setSelectedRange(null); + removeReviewComment(composerDraftTarget, entryId); + setLineAnnotations((current) => { + return current.flatMap((annotation) => { + const entries = annotation.metadata.entries.filter((entry) => entry.id !== entryId); + return entries.length > 0 ? [{ ...annotation, metadata: { entries } }] : []; + }); + }); + }, + [composerDraftTarget, removeReviewComment], + ); + + const submitAnnotationEntry = useCallback( + (entryId: string, text: string) => { + setSelectedRange(null); + const entry = lineAnnotations + .flatMap((annotation) => annotation.metadata.entries) + .find((candidate) => candidate.id === entryId); + if (entry) { + addReviewComment( + composerDraftTarget, + buildFileReviewComment({ + id: entry.id, + filePath: relativePath, + startLine: entry.startLine, + endLine: entry.endLine, + text, + contents, + }), + ); + } + setLineAnnotations((current) => + current.map((annotation) => ({ + ...annotation, + metadata: { + entries: annotation.metadata.entries.map((annotationEntry) => + annotationEntry.id === entryId + ? { ...annotationEntry, kind: "comment", text } + : annotationEntry, + ), + }, + })), + ); + }, + [addReviewComment, composerDraftTarget, contents, lineAnnotations, relativePath], + ); + + const beginComment = useCallback((range: SelectedLineRange) => { + const { startLine, endLine } = normalizeFileCommentRange(range); + const draftEntry: FileCommentAnnotationEntry = { + id: nextFileCommentId(), + kind: "draft", + startLine, + endLine, + text: "", + }; + setLineAnnotations((current) => { + const withoutDraft = current.flatMap((annotation) => { + const entries = annotation.metadata.entries.filter((entry) => entry.kind !== "draft"); + return entries.length > 0 ? [{ ...annotation, metadata: { entries } }] : []; + }); + const existingIndex = withoutDraft.findIndex( + (annotation) => annotation.lineNumber === endLine, + ); + if (existingIndex < 0) { + return [ + ...withoutDraft, + { + lineNumber: endLine, + metadata: { entries: [draftEntry] }, + }, + ]; + } + return withoutDraft.map((annotation, index) => + index === existingIndex + ? { + ...annotation, + metadata: { entries: [...annotation.metadata.entries, draftEntry] }, + } + : annotation, + ); + }); + }, []); + const hasOpenCommentForm = lineAnnotations.some((annotation) => + annotation.metadata.entries.some((entry) => entry.kind === "draft"), + ); + useEffect(() => { + const root = surfaceRef.current; + if (!root) return; + return installFileEditorDismissal({ + root, + editor, + isBlocked: () => hasOpenCommentForm, + onDismiss: () => setSelectedRange(null), + }); + }, [editor, hasOpenCommentForm]); + const handleLineSelectionEnd = useCallback( + (range: SelectedLineRange | null) => { + setSelectedRange(range); + if (range) { + beginComment(range); + } + }, + [beginComment], ); return ( - - + - + > + + file={{ + name: relativePath, + contents, + cacheKey: projectFileCacheKey(cwd, relativePath, contents), + }} + options={{ + disableFileHeader: true, + enableGutterUtility: !hasOpenCommentForm, + enableLineSelection: !hasOpenCommentForm, + onGutterUtilityClick: setSelectedRange, + onLineSelectionChange: setSelectedRange, + onLineSelectionEnd: handleLineSelectionEnd, + overflow: "scroll", + theme: resolveDiffThemeName(resolvedTheme), + themeType: resolvedTheme, + }} + selectedLines={selectedRange} + lineAnnotations={lineAnnotations} + renderAnnotation={(annotation) => ( +
    + {annotation.metadata.entries.map((entry) => ( + removeAnnotationEntry(entry.id)} + onComment={(text) => submitAnnotationEntry(entry.id, text)} + onDelete={() => removeAnnotationEntry(entry.id)} + /> + ))} +
    + )} + className="min-h-full" + contentEditable + /> +
    +
    ); } +function RenderedMarkdownSurface({ + environmentId, + cwd, + relativePath, + contents, + threadRef, + onPendingChange, +}: Omit & { + threadRef: ScopedThreadRef; +}) { + const saveCoordinator = useFileSaveCoordinator({ + environmentId, + cwd, + relativePath, + onPendingChange, + }); + + return ( + + { + const currentContents = + getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ?? + contents; + const nextContents = setMarkdownTaskChecked(currentContents, markerOffset, checked); + if (nextContents === currentContents) return; + setProjectFileQueryData(environmentId, cwd, relativePath, nextContents); + saveCoordinator.change(nextContents); + }} + /> + + ); +} + function initialExplorerOpen(): boolean { try { return window.localStorage.getItem(FILE_EXPLORER_STORAGE_KEY) !== "false"; @@ -130,13 +380,24 @@ export default function FilePreviewPanel({ cwd, projectName, relativePath, + threadRef, + composerDraftTarget, + keybindings, + availableEditors, onOpenFile, onPendingChange, }: FilePreviewPanelProps) { const { resolvedTheme } = useTheme(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); const file = useProjectFileQuery(environmentId, cwd, relativePath); const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); + const [renderedMarkdownPath, setRenderedMarkdownPath] = useState(null); const breadcrumbRef = useRef(null); + const isMarkdown = relativePath ? isMarkdownPreviewFile(relativePath) : false; + const renderMarkdown = isMarkdown && renderedMarkdownPath === relativePath; + const canOpenInBrowser = + relativePath !== null && isPreviewSupportedInRuntime() && isBrowserPreviewFile(relativePath); + const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null; const breadcrumbs = useMemo( () => (relativePath ? fileBreadcrumbs(projectName, relativePath) : []), [projectName, relativePath], @@ -159,6 +420,19 @@ export default function FilePreviewPanel({ }); }; + const handleOpenInBrowser = () => { + if (!absolutePath) return; + void openFileInPreview(threadRef, absolutePath).catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file in browser", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + }; + return (
    {relativePath ? ( @@ -195,6 +469,57 @@ export default function FilePreviewPanel({ ))}
    + {absolutePath && environmentId === primaryEnvironmentId ? ( + + ) : null} + {isMarkdown ? ( + + + setRenderedMarkdownPath(pressed ? relativePath : null) + } + aria-label={renderMarkdown ? "Show markdown source" : "Show rendered markdown"} + variant="ghost" + size="sm" + > + {renderMarkdown ? : } + + } + /> + + {renderMarkdown ? "Show markdown source" : "Show rendered markdown"} + + + ) : null} + {canOpenInBrowser ? ( + + + + + } + /> + Open file in preview browser + + ) : null}
    ) : 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}
    +