From e08b0c8488013f78d80cc1a7c3d72018ea5b6ee3 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 15 Sep 2026 10:15:50 +0200 Subject: [PATCH 1/3] feat(cloud-agent): send saved diff reviews to chats - Add saved-diff line/range comments, cross-file drafts, and one batched Send review to agent action. - Preserve immutable reviewed context and explicit stale-review handling across file and chat switches. - Pin and authorize the destination worktree/chat, retaining the same message identity for uncertain retries. Restacked onto latest main and squashed to one commit. --- .../cloud-agent-next/CloudAgentProvider.tsx | 89 +- .../cloud-agent-next/CloudChatPage.tsx | 109 +- .../ConversationMessages.test.ts | 3 + .../cloud-agent-next/MessageBubble.test.ts | 92 ++ .../cloud-agent-next/MessageBubble.tsx | 15 +- .../cloud-agent-next/WorktreeChanges.tsx | 54 +- .../cloud-agent-next/WorktreeFilePane.tsx | 24 + .../cloud-agent-next/WorktreeFileRenderer.tsx | 210 ++-- .../WorktreeReviewCommentForm.tsx | 98 ++ .../cloud-agent-next/WorktreeReviewDialog.tsx | 161 +++ .../cloud-agent-next/WorktreeReviewEditor.tsx | 333 +++++ .../cloud-agent-next/WorktreeReviewList.tsx | 173 +++ .../WorktreeReviewMessageCard.tsx | 55 + .../cloud-agent-next/terminal-tabs.test.ts | 7 +- .../useWorktreeReview.test.ts | 485 ++++++++ .../cloud-agent-next/useWorktreeReview.ts | 453 +++++++ ...worktree-review-bindings.renderer.test.mts | 1066 +++++++++++++++++ .../worktree-review-bindings.renderer.test.ts | 12 + .../worktree-review-bindings.ts | 154 +++ .../worktree-review-persistence.test.mts | 131 ++ .../worktree-review-persistence.test.ts | 22 + .../worktree-review-persistence.ts | 233 ++++ .../worktree-review-selection.ts | 300 +++++ .../worktree-review-send.test.ts | 535 +++++++++ .../cloud-agent-next/worktree-review-send.ts | 271 +++++ .../worktree-review-state.test.mts | 902 ++++++++++++++ .../worktree-review-state.test.ts | 22 + .../cloud-agent-next/worktree-review-state.ts | 557 +++++++++ .../cloud-agent-next/worktree-review.test.mts | 1059 ++++++++++++++++ .../cloud-agent-next/worktree-review.test.ts | 32 + .../cloud-agent-next/worktree-review.ts | 746 ++++++++++++ .../cloud-agent-client.test.ts | 74 ++ .../cloud-agent-next/cloud-agent-client.ts | 32 + .../worktree-review-access.ts | 28 + .../routers/cloud-agent-next-router.test.ts | 113 ++ .../src/routers/cloud-agent-next-router.ts | 38 +- .../src/routers/cloud-agent-next-schemas.ts | 32 +- ...ganization-cloud-agent-next-router.test.ts | 137 +++ .../organization-cloud-agent-next-router.ts | 33 +- .../src/cloud-agent-worktree-changes.ts | 49 +- services/kilo-mcp/catalog.json | 62 + 41 files changed, 8864 insertions(+), 137 deletions(-) create mode 100644 apps/web/src/components/cloud-agent-next/WorktreeReviewCommentForm.tsx create mode 100644 apps/web/src/components/cloud-agent-next/WorktreeReviewDialog.tsx create mode 100644 apps/web/src/components/cloud-agent-next/WorktreeReviewEditor.tsx create mode 100644 apps/web/src/components/cloud-agent-next/WorktreeReviewList.tsx create mode 100644 apps/web/src/components/cloud-agent-next/WorktreeReviewMessageCard.tsx create mode 100644 apps/web/src/components/cloud-agent-next/useWorktreeReview.test.ts create mode 100644 apps/web/src/components/cloud-agent-next/useWorktreeReview.ts create mode 100644 apps/web/src/components/cloud-agent-next/worktree-review-bindings.renderer.test.mts create mode 100644 apps/web/src/components/cloud-agent-next/worktree-review-bindings.renderer.test.ts create mode 100644 apps/web/src/components/cloud-agent-next/worktree-review-bindings.ts create mode 100644 apps/web/src/components/cloud-agent-next/worktree-review-persistence.test.mts create mode 100644 apps/web/src/components/cloud-agent-next/worktree-review-persistence.test.ts create mode 100644 apps/web/src/components/cloud-agent-next/worktree-review-persistence.ts create mode 100644 apps/web/src/components/cloud-agent-next/worktree-review-selection.ts create mode 100644 apps/web/src/components/cloud-agent-next/worktree-review-send.test.ts create mode 100644 apps/web/src/components/cloud-agent-next/worktree-review-send.ts create mode 100644 apps/web/src/components/cloud-agent-next/worktree-review-state.test.mts create mode 100644 apps/web/src/components/cloud-agent-next/worktree-review-state.test.ts create mode 100644 apps/web/src/components/cloud-agent-next/worktree-review-state.ts create mode 100644 apps/web/src/components/cloud-agent-next/worktree-review.test.mts create mode 100644 apps/web/src/components/cloud-agent-next/worktree-review.test.ts create mode 100644 apps/web/src/components/cloud-agent-next/worktree-review.ts create mode 100644 apps/web/src/lib/cloud-agent-next/worktree-review-access.ts diff --git a/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx b/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx index ba1b2add08..bfcf35c1c5 100644 --- a/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx @@ -21,9 +21,13 @@ import { CLOUD_AGENT_NEXT_WS_URL, SESSION_INGEST_WS_URL } from '@/lib/constants' import { normalizeAlias } from './session-config'; import { usePostHog } from 'posthog-js/react'; import { fetchWebSessionSnapshotPage } from './session-page-adapter'; +import type { CloudAgentApi } from '@kilocode/cloud-agent-sdk/transport'; +import { cloudAgentWorktreeIdSchema } from '@kilocode/session-ingest-contracts'; +import { createWorktreeReviewSend, type WorktreeReviewSendApi } from './worktree-review-send'; const ManagerContext = createContext(null); const UserWebConnectionContext = createContext(null); +const CloudAgentContext = createContext(null); type CloudAgentProviderProps = { children: ReactNode; @@ -82,6 +86,53 @@ export function CloudAgentProvider({ children, organizationId }: CloudAgentProvi // Create manager once per provider instance. // trpcClient is stable (from context); organizationId is stable per provider mount. const managerRef = useRef(null); + const reviewSendRef = useRef(null); + const send = async ( + input: Parameters[0] & { expectedWorktreeId?: string } + ) => { + const payload = { + cloudAgentSessionId: input.sessionId, + payload: input.payload, + autoCommit: true, + messageId: input.messageId, + attachments: input.attachments ?? input.images, + expectedWorktreeId: input.expectedWorktreeId + ? cloudAgentWorktreeIdSchema.parse(input.expectedWorktreeId) + : undefined, + }; + return organizationId + ? trpcClient.organizations.cloudAgentNext.sendMessage.mutate( + { ...payload, organizationId }, + { context: { skipBatch: true } } + ) + : trpcClient.cloudAgentNext.sendMessage.mutate(payload, { context: { skipBatch: true } }); + }; + if (reviewSendRef.current === null) { + reviewSendRef.current = createWorktreeReviewSend({ + organizationId, + getSession: session_id => trpcClient.cliSessionsV2.getWithRuntimeState.query({ session_id }), + send: submission => + send({ + sessionId: submission.destinationCloudAgentSessionId, + payload: submission.payload, + messageId: submission.messageId, + expectedWorktreeId: submission.expectedWorktreeId, + }), + getMessageResult: submission => { + const input = { + cloudAgentSessionId: submission.destinationCloudAgentSessionId, + messageId: submission.messageId, + expectedWorktreeId: cloudAgentWorktreeIdSchema.parse(submission.expectedWorktreeId), + }; + return organizationId + ? trpcClient.organizations.cloudAgentNext.getMessageResult.query({ + ...input, + organizationId, + }) + : trpcClient.cloudAgentNext.getMessageResult.query(input); + }, + }); + } if (managerRef.current === null) { managerRef.current = createSessionManager({ store: storeRef.current, @@ -157,31 +208,7 @@ export function CloudAgentProvider({ children, organizationId }: CloudAgentProvi lifecycleHooks: createBrowserLifecycleHooks(), api: { - send: async input => { - if (organizationId) { - return trpcClient.organizations.cloudAgentNext.sendMessage.mutate( - { - cloudAgentSessionId: input.sessionId, - payload: input.payload, - autoCommit: true, - organizationId, - messageId: input.messageId, - attachments: input.attachments ?? input.images, - }, - { context: { skipBatch: true } } - ); - } - return trpcClient.cloudAgentNext.sendMessage.mutate( - { - cloudAgentSessionId: input.sessionId, - payload: input.payload, - autoCommit: true, - messageId: input.messageId, - attachments: input.attachments ?? input.images, - }, - { context: { skipBatch: true } } - ); - }, + send, interrupt: async payload => { if (organizationId) { @@ -362,12 +389,22 @@ export function CloudAgentProvider({ children, organizationId }: CloudAgentProvi return ( - {children} + + + {children} + + ); } +export function useCloudAgent(): WorktreeReviewSendApi { + const api = useContext(CloudAgentContext); + if (!api) throw new Error('useCloudAgent must be used within CloudAgentProvider'); + return api; +} + export function useOptionalManager(): SessionManager | null { return useContext(ManagerContext); } diff --git a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx index d90fd84a64..c1a0bed42c 100644 --- a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx @@ -10,7 +10,7 @@ import { Button } from '@/components/ui/button'; import { v4 as uuidv4 } from 'uuid'; import type { KiloSessionId } from '@kilocode/cloud-agent-sdk'; -import { useManager } from './CloudAgentProvider'; +import { useCloudAgent, useManager } from './CloudAgentProvider'; import { useWorktreeChatCreation, useWorktreeChatTabs } from './CloudSidebarLayout'; import { MobileSidebarToggle } from './MobileSidebarToggle'; import { ChatHeader } from './ChatHeader'; @@ -56,6 +56,9 @@ import { CloudAgentWorkspaceTabs } from './CloudAgentWorkspaceTabs'; import { WorktreeChangesDrawer } from './WorktreeChanges'; import { WorktreeFilePane } from './WorktreeFilePane'; import { commitsByMessageAnchor, isCommitSummaryRepresented } from './message-presentation'; +import { WorktreeReviewDialog } from './WorktreeReviewDialog'; +import { useWorktreeReview } from './useWorktreeReview'; +import type { WorktreeReviewComment } from './worktree-review'; import { Tabs, TabsContent } from '@/components/ui/tabs'; import { canOpenWorktreeChanges } from './worktree-changes'; import { @@ -146,6 +149,7 @@ export default function CloudChatPage({ organizationRole, }: CloudChatPageProps) { const manager = useManager(); + const reviewApi = useCloudAgent(); const { createWorktreeChat, creatingWorktreeSourceSessionId } = useWorktreeChatCreation(); const { selectedWorktreeId, @@ -681,6 +685,105 @@ export default function CloudChatPage({ [canOpenChanges] ); + const handleReviewAccepted = useCallback( + (destinationKiloSessionId: string) => { + setWorkspaceTabs(state => selectWorkspaceTab(state, CHAT_TAB_ID)); + openSession(destinationKiloSessionId); + }, + [openSession] + ); + const review = useWorktreeReview({ + userId: currentUserId, + organizationId, + worktreeId: selectedWorktreeId, + activeKiloSessionId: sessionIdFromParams, + activeSessionConfig: + isCurrentSession && + activeSessionType === 'cloud-agent' && + sessionConfig?.sessionId === sessionId + ? sessionConfig + : null, + enabled: Boolean( + currentUserId && + selectedWorktreeId && + (!sessionIdFromParams || + (canOpenChanges && + activeSessionType === 'cloud-agent' && + fetchedSessionData?.worktreeId === selectedWorktreeId && + !deletingSessionIds.includes(sessionIdFromParams))) + ), + worktreeChats, + deletingSessionIds, + api: reviewApi, + onAccepted: handleReviewAccepted, + }); + const reviewCommentCounts = useMemo(() => { + const counts = new Map(); + if (!sessionId) return counts; + for (const comment of review.draft?.comments ?? []) { + if (comment.anchor.capture.sourceCloudAgentSessionId !== sessionId) continue; + counts.set(comment.anchor.path, (counts.get(comment.anchor.path) ?? 0) + 1); + } + return counts; + }, [review.draft?.comments, sessionId]); + const reviewAgainFile = useRef<{ + userId: string; + organizationId?: string; + workspaceScope: string; + kiloSessionId: string; + cloudAgentSessionId: string; + path: string; + } | null>(null); + const handleOpenReviewComment = (comment: WorktreeReviewComment) => { + if (!review.scope || review.locked) return; + const source = review.destinations.find( + destination => + destination.cloudAgentSessionId === comment.anchor.capture.sourceCloudAgentSessionId + ); + if (!source) return; + review.setOpen(false); + if (source.sessionId === sessionIdFromParams && canOpenChanges) { + setWorkspaceTabs(state => + setFileTabMode(openFileTab(state, comment.anchor.path), comment.anchor.path, 'diff') + ); + } else { + reviewAgainFile.current = { + ...review.scope, + kiloSessionId: source.sessionId, + cloudAgentSessionId: source.cloudAgentSessionId, + path: comment.anchor.path, + }; + openSession(source.sessionId); + } + }; + useEffect(() => { + const file = reviewAgainFile.current; + if (!file) return; + if ( + file.userId !== currentUserId || + file.organizationId !== organizationId || + file.workspaceScope !== workspaceTabScope + ) { + reviewAgainFile.current = null; + return; + } + if ( + !canOpenChanges || + sessionIdFromParams !== file.kiloSessionId || + sessionId !== file.cloudAgentSessionId + ) + return; + reviewAgainFile.current = null; + setWorkspaceTabs(state => setFileTabMode(openFileTab(state, file.path), file.path, 'diff')); + }, [ + canOpenChanges, + currentUserId, + organizationId, + sessionId, + sessionIdFromParams, + workspaceTabScope, + ]); + const handleCloseFileTab = useCallback((path: string) => { setWorkspaceTabs(state => closeFileTab(state, path)); }, []); @@ -1157,6 +1260,7 @@ export default function CloudChatPage({ /> )} + {sessionIdFromParams &&
{sessionActions}
} @@ -1418,6 +1522,8 @@ export default function CloudChatPage({ organizationId={organizationId} path={tab.path} mode={tab.mode} + review={review.scope ? review.bindings : undefined} + reviewScope={review.scope ?? undefined} onModeChange={mode => setWorkspaceTabs(state => setFileTabMode(state, tab.path, mode)) } @@ -1460,6 +1566,7 @@ export default function CloudChatPage({ onCloseAutoFocus={handleChangesDrawerCloseAutoFocus} onSelectFile={handleSelectWorktreeFile} portalContainer={childSessionDrawerContainer} + commentCounts={reviewCommentCounts} /> )} diff --git a/apps/web/src/components/cloud-agent-next/ConversationMessages.test.ts b/apps/web/src/components/cloud-agent-next/ConversationMessages.test.ts index 733d95b978..860166e597 100644 --- a/apps/web/src/components/cloud-agent-next/ConversationMessages.test.ts +++ b/apps/web/src/components/cloud-agent-next/ConversationMessages.test.ts @@ -37,6 +37,9 @@ jest.mock('react-markdown', () => ({ React.createElement('p', null, children), })); jest.mock('remark-gfm', () => ({ __esModule: true, default: () => undefined })); +jest.mock('../../../node_modules/@pierre/diffs/dist/utils/iterateOverDiff.js', () => ({ + iterateOverDiff: () => [], +})); import { ConversationMessages } from './ConversationMessages'; import { commitsByMessageAnchor } from './message-presentation'; diff --git a/apps/web/src/components/cloud-agent-next/MessageBubble.test.ts b/apps/web/src/components/cloud-agent-next/MessageBubble.test.ts index 7659340389..8d575d5ccc 100644 --- a/apps/web/src/components/cloud-agent-next/MessageBubble.test.ts +++ b/apps/web/src/components/cloud-agent-next/MessageBubble.test.ts @@ -2,13 +2,73 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import type { AssistantMessage } from '@/types/opencode.gen'; import type { StoredMessage } from './types'; +import { WORKTREE_REVIEW_PROMPT_INTRO, type WorktreeReviewComment } from './worktree-review'; jest.mock('./PartRenderer', () => ({ PartRenderer: () => null })); jest.mock('@/components/shared/TimeAgo', () => ({ TimeAgo: () => null })); jest.mock('@/components/shared/CopyMessageButton', () => ({ CopyMessageButton: () => null })); +jest.mock('../../../node_modules/@pierre/diffs/dist/utils/iterateOverDiff.js', () => ({ + iterateOverDiff: () => [], +})); import { MessageBubble } from './MessageBubble'; +const reviewComment: WorktreeReviewComment = { + id: 'comment-1', + anchor: { + capture: { + userId: 'user-1', + organizationId: undefined, + workspaceScope: 'workspace-1', + sourceCloudAgentSessionId: 'source-session', + revision: 3, + capturedAt: '2026-09-09T09:00:00.000Z', + comparison: { + baseRef: 'main', + mergeBase: 'a'.repeat(40), + head: 'b'.repeat(40), + }, + }, + path: 'src/example.ts', + range: { side: 'additions', startLine: 4, endLine: 4 }, + quote: { + source: 'saved-patch', + lines: [{ lineNumber: 4, kind: 'addition', text: 'const value = 1;\n' }], + }, + }, + text: 'Use the shared value helper.', +}; + +function reviewMessage(overall?: string): string { + return `${WORKTREE_REVIEW_PROMPT_INTRO}\n\n${JSON.stringify({ + version: 1, + overall: overall?.trim() || undefined, + comments: [{ ...reviewComment, contextStatus: 'current-saved-capture' }], + })}`; +} + +function userMessage(text: string): StoredMessage { + return { + info: { + id: 'msg-review', + sessionID: 'ses-1', + role: 'user', + time: { created: 1 }, + agent: 'build', + model: { providerID: 'openrouter', modelID: 'anthropic/claude-sonnet-4' }, + }, + parts: [ + { + id: 'part-review', + sessionID: 'ses-1', + messageID: 'msg-review', + type: 'text', + text, + }, + ], + }; +} + describe('MessageBubble', () => { it('renders a sanitized string assistant error', () => { const info: AssistantMessage = { @@ -142,4 +202,36 @@ describe('MessageBubble', () => { expect(html).not.toContain('href="javascript:'); expect(html).toContain('javascript:alert(1)'); }); + + it('renders a parsed review as a compact card without exposing the JSON payload', () => { + const raw = reviewMessage(' Check the boundary first. '); + const html = renderToStaticMarkup( + React.createElement(MessageBubble, { message: userMessage(raw) }) + ); + + expect(html).toContain('Code review feedback'); + expect(html).toContain('1 file · 1 comment'); + expect(html).toContain('cursor-pointer'); + expect(html).not.toContain('Check the boundary first.'); + expect(html).not.toContain('Use the shared value helper.'); + expect(html).not.toContain('"version":1'); + expect(html).not.toContain('Please address the following worktree review feedback'); + expect(html).toContain(' { + const malformed = `${reviewMessage()} trailing text`; + const malformedHtml = renderToStaticMarkup( + React.createElement(MessageBubble, { message: userMessage(malformed) }) + ); + const ordinaryHtml = renderToStaticMarkup( + React.createElement(MessageBubble, { message: userMessage('Please inspect this file.') }) + ); + + expect(malformedHtml).not.toContain('Code review feedback'); + expect(malformedHtml).toContain('Please address the following worktree review feedback'); + expect(ordinaryHtml).not.toContain('Code review feedback'); + expect(ordinaryHtml).toContain('Please inspect this file.'); + }); }); diff --git a/apps/web/src/components/cloud-agent-next/MessageBubble.tsx b/apps/web/src/components/cloud-agent-next/MessageBubble.tsx index df19e6a145..a0a7a6604d 100644 --- a/apps/web/src/components/cloud-agent-next/MessageBubble.tsx +++ b/apps/web/src/components/cloud-agent-next/MessageBubble.tsx @@ -24,6 +24,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { stripImageContext } from '@/lib/app-builder/message-utils'; import { toSafeHttpUrl } from '@/lib/safe-http-url'; import { getDeliveryBadge, type DeliveryBadge } from './delivery-badge'; +import { parseWorktreeReviewMessage } from './worktree-review'; +import { WorktreeReviewMessageCard } from './WorktreeReviewMessageCard'; import LinkifyIt from 'linkify-it'; @@ -252,6 +254,7 @@ export function MessageBubble({ } const userContent = getUserTextContent(message.parts); + const review = parseWorktreeReviewMessage(userContent); const fileParts = message.parts.filter(isFilePart); const imageFileParts = fileParts.filter(part => part.mime.startsWith('image/')); const nonImageFileParts = fileParts.filter(part => !part.mime.startsWith('image/')); @@ -260,10 +263,14 @@ export function MessageBubble({
{deliveryBadge && } - {userContent && ( -

- -

+ {review ? ( + + ) : ( + userContent && ( +

+ +

+ ) )} {imageFileParts.length > 0 && ( diff --git a/apps/web/src/components/cloud-agent-next/WorktreeChanges.tsx b/apps/web/src/components/cloud-agent-next/WorktreeChanges.tsx index 2c63e214d0..e6a83e2655 100644 --- a/apps/web/src/components/cloud-agent-next/WorktreeChanges.tsx +++ b/apps/web/src/components/cloud-agent-next/WorktreeChanges.tsx @@ -3,7 +3,15 @@ import { useEffect, useMemo, useRef, type MouseEvent, type RefObject } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useAtomValue } from 'jotai'; -import { ChevronRight, FileDiff, GitBranch, List, ListTree, RefreshCw } from 'lucide-react'; +import { + ChevronRight, + FileDiff, + GitBranch, + List, + ListTree, + MessageSquareText, + RefreshCw, +} from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; import { Button } from '@/components/ui/button'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; @@ -200,6 +208,7 @@ export function WorktreeChangesDrawer({ onSelectFile, onCloseAutoFocus, portalContainer, + commentCounts, }: { cloudAgentSessionId: string; organizationId?: string; @@ -208,6 +217,7 @@ export function WorktreeChangesDrawer({ onSelectFile: (path: string) => void; onCloseAutoFocus: (event: Event) => void; portalContainer: HTMLElement | null; + commentCounts?: ReadonlyMap; }) { const [viewMode, setViewMode] = useLocalStorage( 'cloud-agent:worktree-changes-view-mode', @@ -249,6 +259,7 @@ export function WorktreeChangesDrawer({ onViewModeChange={setViewMode} onSelectFile={onSelectFile} activeTabRef={activeTabRef} + commentCounts={commentCounts} /> @@ -258,9 +269,11 @@ export function WorktreeChangesDrawer({ function ChangedFile({ file, onSelectFile, + commentCount = 0, }: { file: WorktreeChangesFile; onSelectFile: (path: string) => void; + commentCount?: number; }) { const status = fileStatusStyles[file.status]; const name = file.path.slice(file.path.lastIndexOf('/') + 1); @@ -284,6 +297,15 @@ function ChangedFile({ {name} {file.path} + {commentCount > 0 && ( + + + )} {file.binary ? ( Binary file; line counts unavailable. ) : ( @@ -301,13 +323,20 @@ function ChangedFile({ function ChangedFileTree({ nodes, onSelectFile, + commentCounts, }: { nodes: WorktreeChangesTreeNode[]; onSelectFile: (path: string) => void; + commentCounts?: ReadonlyMap; }) { return nodes.map(node => node.kind === 'file' ? ( - + ) : (
  • @@ -328,7 +357,11 @@ function ChangedFileTree({
      - +
  • @@ -345,6 +378,7 @@ function WorktreeChanges({ onViewModeChange, onSelectFile, activeTabRef, + commentCounts, }: { cloudAgentSessionId: string; organizationId?: string; @@ -353,6 +387,7 @@ function WorktreeChanges({ onViewModeChange: (value: WorktreeChangesViewMode) => void; onSelectFile: (path: string) => void; activeTabRef: RefObject; + commentCounts?: ReadonlyMap; }) { const trpcClient = useRawTRPCClient(); const queryClient = useQueryClient(); @@ -467,7 +502,12 @@ function WorktreeChanges({
      {files.map(file => ( - + ))}
    @@ -478,7 +518,11 @@ function WorktreeChanges({ {tree.length > 0 && (
      - +
    )}
    diff --git a/apps/web/src/components/cloud-agent-next/WorktreeFilePane.tsx b/apps/web/src/components/cloud-agent-next/WorktreeFilePane.tsx index 80f77412a6..3b53631f98 100644 --- a/apps/web/src/components/cloud-agent-next/WorktreeFilePane.tsx +++ b/apps/web/src/components/cloud-agent-next/WorktreeFilePane.tsx @@ -6,6 +6,8 @@ import { Button } from '@/components/ui/button'; import { CopyMessageButton } from '@/components/shared/CopyMessageButton'; import { useWorktreeFile } from './useWorktreeFile'; import type { WorktreeFileViewMode } from './workspace-tabs'; +import type { WorktreeReviewCapture } from './worktree-review'; +import type { WorktreeFileReviewBindings } from './worktree-review-bindings'; const WorktreeFileRenderer = lazy(() => import('./WorktreeFileRenderer')); @@ -91,12 +93,16 @@ export function WorktreeFilePane({ path, mode, onModeChange, + review, + reviewScope, }: { cloudAgentSessionId: string; organizationId?: string; path: string; mode?: WorktreeFileViewMode; onModeChange: (mode: WorktreeFileViewMode) => void; + review?: WorktreeFileReviewBindings; + reviewScope?: { userId: string; organizationId?: string; workspaceScope: string }; }) { const { state, isFetching, reload } = useWorktreeFile({ cloudAgentSessionId, @@ -111,6 +117,22 @@ export function WorktreeFilePane({ const statusProps = { path, isFetching, onReload }; const showReload = !('file' in state) && (state.status === 'error' || (state.status === 'stale' && !isFetching)); + const reviewCapture: WorktreeReviewCapture | undefined = + review && + reviewScope && + reviewScope.organizationId === organizationId && + cloudAgentSessionId.startsWith('workspace_') && + 'file' in state + ? { + userId: reviewScope.userId, + organizationId: reviewScope.organizationId, + workspaceScope: reviewScope.workspaceScope, + sourceCloudAgentSessionId: cloudAgentSessionId, + revision: state.file.revision, + capturedAt: state.capturedAt, + comparison: state.comparison, + } + : undefined; return (
    diff --git a/apps/web/src/components/cloud-agent-next/WorktreeFileRenderer.tsx b/apps/web/src/components/cloud-agent-next/WorktreeFileRenderer.tsx index fb13fa6df0..0a54a57e5d 100644 --- a/apps/web/src/components/cloud-agent-next/WorktreeFileRenderer.tsx +++ b/apps/web/src/components/cloud-agent-next/WorktreeFileRenderer.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useState, type ReactNode } from 'react'; +import { Suspense, lazy, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; import { getFiletypeFromFileName, getHighlighterOptions, @@ -26,6 +26,13 @@ import { worktreeFileOmissionMessages, } from './worktree-file'; import { getWorktreeDiffExpansion } from './worktree-file-diff'; +import type { WorktreeReviewDiffProps } from './WorktreeReviewEditor'; +import type { WorktreeReviewCapture } from './worktree-review'; +import type { WorktreeFileReviewBindings } from './worktree-review-bindings'; + +const WorktreeReviewEditor = lazy(() => + import('./WorktreeReviewEditor').then(module => ({ default: module.WorktreeReviewEditor })) +); const rendererCSS = ` :host { @@ -38,8 +45,9 @@ const rendererCSS = ` --diffs-addition-color-override: var(--diff-add-text); --diffs-deletion-color-override: var(--diff-delete-text); } -[data-line-type="change-addition"] { background-color: var(--diff-add-surface); } -[data-line-type="change-deletion"] { background-color: var(--diff-delete-surface); } +[data-line-type="change-addition"]:not([data-selected-line]) { background-color: var(--diff-add-surface); } +[data-line-type="change-deletion"]:not([data-selected-line]) { background-color: var(--diff-delete-surface); } +[data-code] [data-line][tabindex]:focus-visible, [data-expand-index] [data-expand-button]:focus-visible, [data-expand-index] [data-unmodified-lines]:focus-visible { outline: 2px solid var(--ring); @@ -100,18 +108,15 @@ export function prepareWorktreeFileHighlighter( }; } +type WorktreeFileHighlighterState = WorktreeFileHighlighterResult | { status: 'loading' }; + function WorktreeFileHighlighter({ - path, + result, children, }: { - path: string; + result: WorktreeFileHighlighterState; children: (lang: SupportedLanguages) => ReactNode; }) { - const [result, setResult] = useState({ - status: 'loading', - }); - useEffect(() => prepareWorktreeFileHighlighter(path, setResult), [path]); - if (result.status !== 'ready') { return (

    ({ ...diff, lang }), [diff, lang]); + const reviewPostRender = reviewProps?.options?.onPostRender; + const onPostRender = useCallback< + NonNullable['onPostRender']> + >( + (node, instance, phase) => { + if (phase !== 'unmount') prepareExpansionControls(node); + reviewPostRender?.(node, instance, phase); + }, + [reviewPostRender] + ); return ( @@ -277,10 +299,14 @@ export default function WorktreeFileRenderer({ file, mode, onModeChange, + review, + reviewCapture, }: { file: WorktreeFileRecord; mode: WorktreeFileViewMode; onModeChange?: (mode: WorktreeFileViewMode) => void; + review?: WorktreeFileReviewBindings; + reviewCapture?: WorktreeReviewCapture; }) { const patch = file.diff.status === 'available' ? file.diff.patch : undefined; const parsed = useMemo( @@ -312,77 +338,96 @@ export default function WorktreeFileRenderer({ ? 'Show changes' : 'Preview Markdown'; const highlighterKey = JSON.stringify([file.path, file.revision]); + const renderedDiff = expansion?.status === 'available' ? expansion.diff : parsed; + const needsHighlighter = parsed !== null && (parsed.hunks.length > 0 || expanded); + const [highlighter, setHighlighter] = useState<{ + key: string; + result: WorktreeFileHighlighterResult; + }>(); + const highlighterResult: WorktreeFileHighlighterState = + highlighter?.key === highlighterKey ? highlighter.result : { status: 'loading' }; useEffect(() => { if (mode !== viewMode) onModeChange?.(viewMode); }, [mode, viewMode, onModeChange]); - let body: ReactNode; - if (viewMode === 'preview' && file.content.status === 'available') { - body = - file.content.text === '' ? ( + useEffect(() => { + if (!needsHighlighter) return; + return prepareWorktreeFileHighlighter(file.path, result => { + setHighlighter({ key: highlighterKey, result }); + }); + }, [file.path, highlighterKey, needsHighlighter]); + + function renderBody(reviewProps?: WorktreeReviewDiffProps) { + let body: ReactNode; + if (viewMode === 'preview' && file.content.status === 'available') { + body = + file.content.text === '' ? ( +

    + This saved file is empty. +

    + ) : ( +
    + + {file.content.text} + +
    + ); + } else if (file.diff.status === 'omitted') { + body = (

    - This saved file is empty. + Diff omitted. {worktreeFileOmissionMessages[file.diff.reason]}

    - ) : ( -
    - - {file.content.text} - -
    ); - } else if (file.diff.status === 'omitted') { - body = ( -

    - Diff omitted. {worktreeFileOmissionMessages[file.diff.reason]} -

    - ); - } else if (!parsed) { - body = ( -

    - This saved diff could not be rendered. -

    - ); - } else if (parsed.hunks.length === 0) { - const text = expanded && file.content.status === 'available' ? file.content.text : undefined; - body = ( - <> -
    -

    Metadata-only change. No text hunks were saved.

    - {parsed.type === 'new' &&

    Empty file added.

    } - {parsed.type === 'deleted' &&

    Empty file deleted.

    } - {parsed.prevMode && parsed.mode && parsed.prevMode !== parsed.mode && ( -

    - File mode: {parsed.prevMode} → {parsed.mode} -

    - )} -
    - {text !== undefined && ( - - {lang => ( - + } else if (!parsed) { + body = ( +

    + This saved diff could not be rendered. +

    + ); + } else if (parsed.hunks.length === 0) { + const text = expanded && file.content.status === 'available' ? file.content.text : undefined; + body = ( + <> +
    +

    Metadata-only change. No text hunks were saved.

    + {parsed.type === 'new' &&

    Empty file added.

    } + {parsed.type === 'deleted' &&

    Empty file deleted.

    } + {parsed.prevMode && parsed.mode && parsed.prevMode !== parsed.mode && ( +

    + File mode: {parsed.prevMode} → {parsed.mode} +

    )} - - )} - - ); - } else { - body = ( - - {lang => ( - - )} - - ); +
    + {text !== undefined && ( + + {lang => ( + + )} + + )} + + ); + } else { + body = ( + + {lang => ( + + )} + + ); + } + return
    {body}
    ; } return ( @@ -452,7 +497,22 @@ export default function WorktreeFileRenderer({ )}
    -
    {body}
    + {review && reviewCapture && viewMode !== 'preview' ? ( + + + {renderBody} + + + ) : ( + renderBody() + )} ); } diff --git a/apps/web/src/components/cloud-agent-next/WorktreeReviewCommentForm.tsx b/apps/web/src/components/cloud-agent-next/WorktreeReviewCommentForm.tsx new file mode 100644 index 0000000000..e804dfec43 --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/WorktreeReviewCommentForm.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { useEffect, useId, useRef, type ReactNode } from 'react'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { MAX_WORKTREE_REVIEW_COMMENT_LENGTH } from './worktree-review'; +import type { WorktreeReviewEditor } from './worktree-review-bindings'; + +export function WorktreeReviewCommentForm({ + editor, + header, + error, + disabled = false, + disabledReason, + onChange, + onSave, + containerClassName = 'min-w-0 space-y-4', + formClassName = 'space-y-4', + textareaClassName, +}: { + editor: WorktreeReviewEditor; + header?: ReactNode; + error?: string; + disabled?: boolean; + disabledReason?: string; + onChange: (editor: WorktreeReviewEditor | null) => void; + onSave: () => void; + containerClassName?: string; + formClassName?: string; + textareaClassName?: string; +}) { + const id = useId(); + const textarea = useRef(null); + useEffect(() => textarea.current?.focus(), []); + const tooLong = editor.text.length > MAX_WORKTREE_REVIEW_COMMENT_LENGTH; + const fieldError = tooLong + ? `Use no more than ${MAX_WORKTREE_REVIEW_COMMENT_LENGTH} characters.` + : error; + const isDisabled = disabled || Boolean(disabledReason); + return ( +
    + {header} +
    { + event.preventDefault(); + if (!isDisabled && editor.text.trim() && !tooLong) onSave(); + }} + > +
    + +