From a71610fd63ad340dcabd55dbd16b976a9eb9a5c7 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 1 Sep 2026 21:41:07 +0200 Subject: [PATCH] feat(cloud-agent): send saved diff reviews to chats --- .../cloud-agent-next/CloudAgentProvider.tsx | 89 ++- .../cloud-agent-next/CloudChatPage.tsx | 99 ++- .../cloud-agent-next/WorktreeFilePane.tsx | 24 + .../cloud-agent-next/WorktreeFileRenderer.tsx | 209 +++-- .../cloud-agent-next/WorktreeReviewDialog.tsx | 358 +++++++++ .../cloud-agent-next/WorktreeReviewEditor.tsx | 455 +++++++++++ .../cloud-agent-next/terminal-tabs.test.ts | 7 +- .../cloud-agent-next/useWorktreeReview.ts | 383 +++++++++ ...worktree-review-bindings.renderer.test.mts | 755 ++++++++++++++++++ .../worktree-review-bindings.renderer.test.ts | 12 + .../worktree-review-bindings.ts | 53 ++ .../worktree-review-selection.ts | 253 ++++++ .../worktree-review-send.test.ts | 535 +++++++++++++ .../cloud-agent-next/worktree-review-send.ts | 271 +++++++ .../worktree-review-state.test.mts | 564 +++++++++++++ .../worktree-review-state.test.ts | 22 + .../cloud-agent-next/worktree-review-state.ts | 311 ++++++++ .../cloud-agent-next/worktree-review.test.mts | 709 ++++++++++++++++ .../cloud-agent-next/worktree-review.test.ts | 22 + .../cloud-agent-next/worktree-review.ts | 417 ++++++++++ .../cloud-agent-client.test.ts | 66 ++ .../cloud-agent-next/cloud-agent-client.ts | 21 + .../worktree-review-access.ts | 28 + .../routers/cloud-agent-next-router.test.ts | 113 +++ .../src/routers/cloud-agent-next-router.ts | 36 +- .../src/routers/cloud-agent-next-schemas.ts | 32 +- ...ganization-cloud-agent-next-router.test.ts | 137 ++++ .../organization-cloud-agent-next-router.ts | 31 +- 28 files changed, 5903 insertions(+), 109 deletions(-) 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/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-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 4556a5f44f..b2e5b16386 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,96 @@ 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 reviewAgainFile = useRef<{ + userId: string; + organizationId?: string; + workspaceScope: string; + kiloSessionId: string; + cloudAgentSessionId: string; + path: string; + } | null>(null); + const handleReviewAgain = (comment: WorktreeReviewComment) => { + if (!review.scope || review.locked) return; + const source = review.destinations.find( + destination => + destination.cloudAgentSessionId === comment.anchor.capture.sourceCloudAgentSessionId + ); + if (!source || !review.removeComment(comment.id)) 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)); }, []); @@ -1152,6 +1246,7 @@ export default function CloudChatPage({ /> )} + {sessionIdFromParams &&
{sessionActions}
} @@ -1413,6 +1508,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)) } diff --git a/apps/web/src/components/cloud-agent-next/WorktreeFilePane.tsx b/apps/web/src/components/cloud-agent-next/WorktreeFilePane.tsx index 850e72285d..982b824cdd 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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; 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')); @@ -88,12 +90,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, @@ -101,6 +107,22 @@ export function WorktreeFilePane({ path, }); const statusProps = { path, isFetching, onReload: () => void reload() }; + 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 624b9da156..d0629d7a01 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, @@ -25,6 +25,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 { @@ -37,8 +44,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); @@ -99,18 +107,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 ( @@ -279,6 +298,8 @@ export default function WorktreeFileRenderer({ onModeChange, isFetching = false, onReload, + review, + reviewCapture, }: { file: WorktreeFileRecord; mode: WorktreeFileViewMode; @@ -286,6 +307,8 @@ export default function WorktreeFileRenderer({ onModeChange?: (mode: WorktreeFileViewMode) => void; isFetching?: boolean; onReload?: () => void; + review?: WorktreeFileReviewBindings; + reviewCapture?: WorktreeReviewCapture; }) { const patch = file.diff.status === 'available' ? file.diff.patch : undefined; const parsed = useMemo( @@ -317,76 +340,99 @@ 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} +
); } @@ -491,9 +537,22 @@ export default function WorktreeFileRenderer({
-
- {body} -
+ {review && reviewCapture && viewMode !== 'preview' ? ( + + + {renderBody} + + + ) : ( + renderBody() + )} ); } diff --git a/apps/web/src/components/cloud-agent-next/WorktreeReviewDialog.tsx b/apps/web/src/components/cloud-agent-next/WorktreeReviewDialog.tsx new file mode 100644 index 0000000000..d80bb8350a --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/WorktreeReviewDialog.tsx @@ -0,0 +1,358 @@ +'use client'; + +import { useId } from 'react'; +import { MessageSquareText } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { + MAX_WORKTREE_REVIEW_COMMENT_LENGTH, + type WorktreeReviewAnchor, + type WorktreeReviewComment, +} from './worktree-review'; +import type { WorktreeReviewEditor } from './worktree-review-bindings'; +import type { useWorktreeReview } from './useWorktreeReview'; + +function SavedReviewQuote({ anchor }: { anchor: WorktreeReviewAnchor }) { + return ( +
+

+ {anchor.range.side === 'deletions' ? 'Old side' : 'New side'} · Lines{' '} + {anchor.range.startLine}–{anchor.range.endLine} · Capture {anchor.capture.revision} +

+
+        {anchor.quote.lines.map(line => line.text).join('')}
+      
+
+ + Saved capture details + +
+
Captured at
+
{anchor.capture.capturedAt}
+
Source session
+
{anchor.capture.sourceCloudAgentSessionId}
+
Comparison
+
{anchor.capture.comparison.baseRef}
+
Merge base
+
{anchor.capture.comparison.mergeBase}
+
HEAD
+
{anchor.capture.comparison.head}
+
+
+
+ ); +} + +function ReviewEditor({ + editor, + review, +}: { + editor: WorktreeReviewEditor; + review: ReturnType; +}) { + const id = useId(); + return ( +
+ +