From 4d86f3295c24b357cdb7639c94d69c085ff64e13 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 1 Sep 2026 20:32:39 +0200 Subject: [PATCH] feat(cloud-agent-next): add saved worktree file viewer --- apps/web/package.json | 1 + .../CloudAgentWorkspaceTabs.test.ts | 39 +- .../CloudAgentWorkspaceTabs.tsx | 88 +- .../cloud-agent-next/CloudChatPage.tsx | 290 ++-- .../cloud-agent-next/WorktreeChanges.tsx | 75 +- .../cloud-agent-next/WorktreeFilePane.tsx | 147 ++ .../cloud-agent-next/WorktreeFileRenderer.tsx | 499 ++++++ .../cloud-agent-next/terminal-tabs.test.ts | 61 +- .../cloud-agent-next/terminal-tabs.ts | 97 -- .../cloud-agent-next/useWorktreeFile.ts | 86 + .../cloud-agent-next/workspace-tabs.test.ts | 379 +++++ .../cloud-agent-next/workspace-tabs.ts | 160 ++ .../cloud-agent-next/worktree-file-diff.ts | 111 ++ .../cloud-agent-next/worktree-file.test.mts | 1390 +++++++++++++++++ .../cloud-agent-next/worktree-file.test.ts | 12 + .../cloud-agent-next/worktree-file.ts | 62 + .../cloud-agent-client.test.ts | 117 +- .../cloud-agent-next/cloud-agent-client.ts | 27 + .../routers/cloud-agent-next-router.test.ts | 129 +- .../src/routers/cloud-agent-next-router.ts | 11 + .../routers/cloud-agent-next-schemas.test.ts | 35 + .../src/routers/cloud-agent-next-schemas.ts | 5 + ...ganization-cloud-agent-next-router.test.ts | 197 ++- .../organization-cloud-agent-next-router.ts | 23 + .../src/cloud-agent-worktree-changes.test.ts | 307 ++++ .../src/cloud-agent-worktree-changes.ts | 131 ++ pnpm-lock.yaml | 219 +++ .../src/persistence/SandboxControl.ts | 4 +- .../handlers/session-worktree-changes.test.ts | 117 +- .../handlers/session-worktree-changes.ts | 25 + .../cloud-agent-next/src/router/schemas.ts | 5 + .../src/sandbox-control/frames.test.ts | 223 ++- .../src/sandbox-control/frames.ts | 4 +- .../src/sandbox-control/socket.test.ts | 344 ++-- .../src/sandbox-control/socket.ts | 3 +- .../src/sandbox-session/SandboxSession.ts | 60 +- .../session-message-queue.test.ts | 19 +- .../sandbox-session/worktree-changes.test.ts | 386 ++++- .../src/sandbox-session/worktree-changes.ts | 123 +- .../src/shared/sandbox-control-protocol.ts | 19 +- .../src/shared/worktree-changes-wire.test.ts | 262 +++- .../src/shared/worktree-changes-wire.ts | 112 ++ .../test/integration/sandbox-control.test.ts | 603 ++++++- .../test/unit/wrapper/utils.test.ts | 106 +- .../control/sandbox-control-client.test.ts | 132 +- .../src/control/sandbox-control-client.ts | 8 +- .../control/sandbox-control-handlers.test.ts | 320 ++++ .../src/control/sandbox-control-handlers.ts | 52 +- .../src/control/worktree-changes.test.ts | 1097 ++++++++++++- .../wrapper/src/control/worktree-changes.ts | 606 ++++++- .../src/control/worktree-file-capture.ts | 172 ++ .../cloud-agent-next/wrapper/src/utils.ts | 49 +- 52 files changed, 8686 insertions(+), 863 deletions(-) create mode 100644 apps/web/src/components/cloud-agent-next/WorktreeFilePane.tsx create mode 100644 apps/web/src/components/cloud-agent-next/WorktreeFileRenderer.tsx delete mode 100644 apps/web/src/components/cloud-agent-next/terminal-tabs.ts create mode 100644 apps/web/src/components/cloud-agent-next/useWorktreeFile.ts create mode 100644 apps/web/src/components/cloud-agent-next/workspace-tabs.test.ts create mode 100644 apps/web/src/components/cloud-agent-next/workspace-tabs.ts create mode 100644 apps/web/src/components/cloud-agent-next/worktree-file-diff.ts create mode 100644 apps/web/src/components/cloud-agent-next/worktree-file.test.mts create mode 100644 apps/web/src/components/cloud-agent-next/worktree-file.test.ts create mode 100644 apps/web/src/components/cloud-agent-next/worktree-file.ts create mode 100644 services/cloud-agent-next/wrapper/src/control/worktree-file-capture.ts diff --git a/apps/web/package.json b/apps/web/package.json index cc507f272d..19163bb817 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -79,6 +79,7 @@ "@paper-design/shaders-react": "^0.0.76", "@peculiar/asn1-schema": "2.8.0", "@peculiar/asn1-x509": "2.8.0", + "@pierre/diffs": "1.3.6", "@qdrant/js-client-rest": "1.17.0", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-avatar": "1.1.11", diff --git a/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.test.ts b/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.test.ts index d65314e596..b314f22143 100644 --- a/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.test.ts +++ b/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.test.ts @@ -6,7 +6,8 @@ import { DropdownMenuItem } from '@/components/ui/dropdown-menu'; import type * as DropdownMenuComponents from '@/components/ui/dropdown-menu'; import type * as DropdownMenuPrimitives from '@radix-ui/react-dropdown-menu'; import { CloudAgentWorkspaceTabs } from './CloudAgentWorkspaceTabs'; -import { CHAT_TAB_ID, terminalTabId, type WorkspaceTabId } from './terminal-tabs'; +import { CHAT_TAB_ID, fileTabId, terminalTabId, type WorkspaceTabId } from './workspace-tabs'; +import { Tabs, TabsContent } from '@/components/ui/tabs'; import type { StoredSession } from './types'; Object.assign(globalThis, { React }); @@ -75,6 +76,8 @@ function renderWorkspaceTabs( onCreateChat: () => undefined, onRenameChat: async () => undefined, terminals: [], + files: [], + onCloseFile: () => undefined, terminalStatuses: {}, canCreateTerminal: false, onSelectTab: () => undefined, @@ -83,7 +86,22 @@ function renderWorkspaceTabs( ...overrides, }; - return renderToStaticMarkup(createElement(CloudAgentWorkspaceTabs, props)); + const worktreeId = + props.worktreeId === undefined + ? props.chatSessions.find(chat => chat.sessionId === props.currentSessionId)?.worktreeId + : props.worktreeId; + const value = + props.activeTabId === CHAT_TAB_ID && worktreeId && props.currentSessionId + ? `chat:${props.currentSessionId}` + : props.activeTabId; + return renderToStaticMarkup( + createElement( + Tabs, + { value }, + createElement(CloudAgentWorkspaceTabs, props), + createElement(TabsContent, { value }, 'Selected workspace panel') + ) + ); } function findButtonMarkup(html: string, text: string): string { @@ -117,6 +135,23 @@ function getSessionMenuItemProps(title: string): ComponentProps { + it('composes selected file and sibling chat values with their shared Radix panels', () => { + for (const activeTabId of [CHAT_TAB_ID, fileTabId('src/file.ts')] as const) { + const html = renderWorkspaceTabs({ + activeTabId, + files: [{ path: 'src/file.ts' }], + worktreeId: 'worktree_shared', + }); + const active = (html.match(/]*>/g) ?? []).filter( + button => button.includes('role="tab"') && button.includes('data-state="active"') + ); + expect(active).toHaveLength(1); + const controls = active[0]?.match(/aria-controls="([^"]+)"/)?.[1]; + expect(controls).toBeDefined(); + expect(html).toContain(`id="${controls}"`); + expect(html).toContain('Selected workspace panel'); + } + }); it('renders complete grouped chat titles and selects only the current session tab', () => { const firstTitle = 'Investigate the complete authentication regression across every provider'; const secondTitle = 'Fix the separate billing synchronization flow'; diff --git a/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.tsx b/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.tsx index 229b93701f..35a2b5de69 100644 --- a/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState, type RefObject } from 'react'; import { SessionStatusIndicator } from '@/components/shared/SessionStatusIndicator'; import { TimeAgo } from '@/components/shared/TimeAgo'; import { Button } from '@/components/ui/button'; @@ -12,14 +12,22 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; -import { ChevronDown, LoaderCircle, MessageSquare, Plus, Terminal, X } from 'lucide-react'; +import { + ChevronDown, + FileDiff, + LoaderCircle, + MessageSquare, + Plus, + Terminal, + X, +} from 'lucide-react'; import { toast } from 'sonner'; import { SessionPrIndicator } from './SessionPrIndicator'; -import { CHAT_TAB_ID, terminalTabId } from './terminal-tabs'; -import type { TerminalWorkspaceTab, WorkspaceTabId } from './terminal-tabs'; +import { CHAT_TAB_ID, fileTabId, terminalTabId } from './workspace-tabs'; +import type { FileWorkspaceTab, TerminalWorkspaceTab, WorkspaceTabId } from './workspace-tabs'; import type { StoredSession } from './types'; type TerminalStatusSummary = { @@ -51,6 +59,7 @@ const renameHint = 'Double-click to rename.'; export function CloudAgentWorkspaceTabs({ activeTabId, + activeTabRef, chatSessions, currentSessionId, worktreeId, @@ -63,6 +72,8 @@ export function CloudAgentWorkspaceTabs({ onRenameChat, deletingSessionIds = [], terminals, + files, + onCloseFile, terminalStatuses, canCreateTerminal, onSelectTab, @@ -71,6 +82,9 @@ export function CloudAgentWorkspaceTabs({ className, }: { activeTabId: WorkspaceTabId; + activeTabRef?: RefObject; + files: FileWorkspaceTab[]; + onCloseFile: (path: string) => void; chatSessions: StoredSession[]; currentSessionId: string | null; worktreeId?: string | null; @@ -99,7 +113,8 @@ export function CloudAgentWorkspaceTabs({ const touchStartRef = useRef(null); const lastTapRef = useRef(null); const lastPointerTypeRef = useRef(''); - const selectedTabRef = useRef(null); + const localSelectedTabRef = useRef(null); + const selectedTabRef = activeTabRef ?? localSelectedTabRef; const activeTabWrapperRef = useRef(null); const tabListRef = useRef(null); const tabOptionsTriggerRef = useRef(null); @@ -152,7 +167,7 @@ export function CloudAgentWorkspaceTabs({ resizeObserver.observe(tabList); if (activeTabWrapperRef.current) resizeObserver.observe(activeTabWrapperRef.current); return () => resizeObserver.disconnect(); - }, [selectedValue, visibleChatSessions.length, terminals.length]); + }, [selectedValue, visibleChatSessions.length, terminals.length, files.length, selectedTabRef]); const handleStartRename = (session: StoredSession, trigger: HTMLButtonElement) => { if ( @@ -203,29 +218,8 @@ export function CloudAgentWorkspaceTabs({ } }; - const handleValueChange = (value: string) => { - if (value === CHAT_TAB_ID) { - onSelectTab(CHAT_TAB_ID); - return; - } - - const selectedChat = visibleChatSessions.find(session => value === `chat:${session.sessionId}`); - if (selectedChat) { - if (activeTabId !== CHAT_TAB_ID) onSelectTab(CHAT_TAB_ID); - if (selectedChat.sessionId !== currentSessionId) onSelectChat(selectedChat.sessionId); - return; - } - - const selectedTerminal = terminals.find(terminal => value === terminalTabId(terminal.id)); - if (selectedTerminal) onSelectTab(terminalTabId(selectedTerminal.id)); - }; - return ( - +
); })} + {files.map(tab => { + const tabId = fileTabId(tab.path); + const active = activeTabId === tabId; + const name = tab.path.slice(tab.path.lastIndexOf('/') + 1); + return ( +
+ + + +
+ ); + })}
@@ -597,6 +625,6 @@ export function CloudAgentWorkspaceTabs({ ) : null}
- +
); } diff --git a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx index 376acfb81b..cd4b9713f7 100644 --- a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx @@ -54,17 +54,25 @@ import { billingPayerPresentation } from './billing-payer-presentation'; import type { OrganizationRole } from '@/lib/organizations/organization-types'; import { CloudAgentWorkspaceTabs } from './CloudAgentWorkspaceTabs'; import { WorktreeChangesDrawer } from './WorktreeChanges'; +import { WorktreeFilePane } from './WorktreeFilePane'; +import { Tabs, TabsContent } from '@/components/ui/tabs'; import { canOpenWorktreeChanges } from './worktree-changes'; import { CHAT_TAB_ID, addTerminalTab, + clearFileTabs, + closeFileTab, closeTerminalTab, createWorkspaceTabsState, + fileTabId, getWorkspaceTabScope, + openFileTab, resetWorkspaceTabs, selectWorkspaceTab, + setFileTabMode, + terminalIdFromTabId, terminalTabId, -} from './terminal-tabs'; +} from './workspace-tabs'; import { createRemoteModelOverride, useSessionModels, @@ -81,7 +89,7 @@ import { SetPageTitle } from '@/components/SetPageTitle'; import { formatShortModelDisplayName } from '@/lib/format-model-name'; import type { AgentMode } from './types'; import type { PreparationAttempt } from '@kilocode/cloud-agent-sdk'; -import type { WorkspaceTabId } from './terminal-tabs'; +import type { WorkspaceTabId } from './workspace-tabs'; import type { TerminalStatus } from './useCloudAgentTerminal'; // --------------------------------------------------------------------------- @@ -166,7 +174,8 @@ export default function CloudChatPage({ const [preparationDrawerAttemptId, setPreparationDrawerAttemptId] = useState(null); const preparationDrawerFocusTargetRef = useRef(null); const [changesDrawerSessionId, setChangesDrawerSessionId] = useState(null); - const changesDrawerFocusTargetRef = useRef(null); + const changesDrawerFocusTargetRef = useRef(null); + const activeWorkspaceTabRef = useRef(null); // URL-driven session switching const sessionIdFromParams = searchParams?.get('sessionId') ?? null; @@ -247,9 +256,6 @@ export default function CloudChatPage({ Record >({}); const preserveTerminalSelectionRef = useRef(false); - const chatTabActive = - workspaceTabs.activeTabId === CHAT_TAB_ID && - (sessionIdFromParams !== null || selectedWorktreeId === null); const workspaceTabScope = getWorkspaceTabScope(selectedWorktreeId, sessionIdFromParams); const workspaceIdentityResolved = !sessionIdFromParams || @@ -267,6 +273,24 @@ export default function CloudChatPage({ canOpenWorktreeChanges(sessionId, isReadOnly) && fetchedSessionData?.organizationId === (organizationId ?? null); const changesDrawerOpen = canOpenChanges && changesDrawerSessionId === sessionId; + const fileScope = JSON.stringify([currentUserId, organizationId, sessionIdFromParams, sessionId]); + const [resolvedFileScope, setResolvedFileScope] = useState(fileScope); + const filesVisible = canOpenChanges && resolvedFileScope === fileScope; + const activeWorkspaceTabId = + !filesVisible && workspaceTabs.activeTabId.startsWith('file:') + ? CHAT_TAB_ID + : workspaceTabs.activeTabId; + const chatTabValue = + selectedWorktreeId && sessionIdFromParams ? `chat:${sessionIdFromParams}` : CHAT_TAB_ID; + const chatTabActive = + activeWorkspaceTabId === CHAT_TAB_ID && + (sessionIdFromParams !== null || selectedWorktreeId === null); + const terminalTabActive = terminalIdFromTabId(activeWorkspaceTabId) !== null; + + if (resolvedFileScope !== fileScope) { + setResolvedFileScope(fileScope); + setWorkspaceTabs(clearFileTabs); + } if ( resolvedWorkspaceScope.currentUserId !== currentUserId || @@ -630,6 +654,30 @@ export default function CloudChatPage({ setWorkspaceTabs(state => selectWorkspaceTab(state, tabId)); }, []); + const handleWorkspaceValueChange = (value: string) => { + const chat = openWorktreeChats.find(chat => value === `chat:${chat.sessionId}`); + if (chat) { + setWorkspaceTabs(state => selectWorkspaceTab(state, CHAT_TAB_ID)); + if (chat.sessionId !== sessionIdFromParams) openSession(chat.sessionId); + } else { + setWorkspaceTabs(state => selectWorkspaceTab(state, value)); + } + }; + + const handleSelectWorktreeFile = useCallback( + (path: string) => { + if (!canOpenChanges) return; + changesDrawerFocusTargetRef.current = 'workspace-tab'; + setWorkspaceTabs(state => openFileTab(state, path)); + setChangesDrawerSessionId(null); + }, + [canOpenChanges] + ); + + const handleCloseFileTab = useCallback((path: string) => { + setWorkspaceTabs(state => closeFileTab(state, path)); + }, []); + const handleCloseChat = useCallback( (closingSessionId: string) => { if (closingSessionId === sessionIdFromParams) { @@ -777,7 +825,8 @@ export default function CloudChatPage({ }, []); const handleChangesDrawerCloseAutoFocus = useCallback((event: Event) => { - const focusTarget = changesDrawerFocusTargetRef.current; + const target = changesDrawerFocusTargetRef.current; + const focusTarget = target === 'workspace-tab' ? activeWorkspaceTabRef.current : target; changesDrawerFocusTargetRef.current = null; if (!focusTarget?.isConnected) return; event.preventDefault(); @@ -1053,7 +1102,11 @@ export default function CloudChatPage({ )} {showChatInterface ? ( - <> + {showLoadingIndicator &&
}
@@ -1061,7 +1114,10 @@ export default function CloudChatPage({
{(canOpenTerminal || selectedWorktreeId) && ( chat.sessionId)} closedChatSessionIds={closedWorktreeChats.map(chat => chat.sessionId)} @@ -1103,105 +1159,104 @@ export default function CloudChatPage({ } className="flex min-h-0 flex-1 flex-col" > -
- {!sessionIdFromParams && - selectedWorktreeId && - workspaceTabs.activeTabId === CHAT_TAB_ID && ( -
-
- )} - <> -