From b811ffb8f1c1ffc3507b6ff5e12110a7780f28c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 29 Apr 2026 17:28:53 +0800 Subject: [PATCH 1/8] feat: make session workspace changes inspectable Expose a workspace inspector for desktop sessions so users can browse the active project, preview files, inspect session-derived changes, and review diffs even when the work directory is not a git repository. Constraint: The workspace view must work for temporary and non-git project directories. Rejected: Rely only on git status | non-git sessions would lose the changed-files surface. Confidence: medium Scope-risk: moderate Directive: Keep undo semantics separate from workspace browsing; checkpoint-backed rewind should remain the source of truth for rollback. Tested: bun test src/server/__tests__/sessions.test.ts src/server/__tests__/workspace-service.test.ts Tested: cd desktop && bun run test -- src/components/workspace/WorkspacePanel.test.tsx src/stores/workspacePanelStore.test.ts Tested: cd desktop && bun run lint Tested: git diff --check Not-tested: Full desktop packaged app build after this commit --- .gitignore | 3 +- desktop/src/api/sessions.ts | 90 + desktop/src/components/chat/ChatInput.tsx | 64 +- desktop/src/components/chat/MessageList.tsx | 7 +- .../src/components/controls/ModelSelector.tsx | 10 +- .../controls/PermissionModeSelector.tsx | 18 +- desktop/src/components/layout/TabBar.test.tsx | 118 +- desktop/src/components/layout/TabBar.tsx | 71 +- .../components/shared/ProjectContextChip.tsx | 13 +- .../workspace/WorkspacePanel.test.tsx | 615 +++++++ .../components/workspace/WorkspacePanel.tsx | 1057 ++++++++++++ desktop/src/i18n/locales/en.ts | 31 + desktop/src/i18n/locales/zh.ts | 31 + desktop/src/pages/ActiveSession.test.tsx | 194 ++- desktop/src/pages/ActiveSession.tsx | 353 ++-- .../src/stores/workspacePanelStore.test.ts | 520 ++++++ desktop/src/stores/workspacePanelStore.ts | 653 ++++++++ src/server/__tests__/sessions.test.ts | 349 ++++ .../__tests__/workspace-service.test.ts | 444 +++++ src/server/api/sessions.ts | 101 ++ src/server/services/workspaceService.ts | 1483 +++++++++++++++++ 21 files changed, 6064 insertions(+), 161 deletions(-) create mode 100644 desktop/src/components/workspace/WorkspacePanel.test.tsx create mode 100644 desktop/src/components/workspace/WorkspacePanel.tsx create mode 100644 desktop/src/stores/workspacePanelStore.test.ts create mode 100644 desktop/src/stores/workspacePanelStore.ts create mode 100644 src/server/__tests__/workspace-service.test.ts create mode 100644 src/server/services/workspaceService.ts diff --git a/.gitignore b/.gitignore index fadf0b09..d6b8e451 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ desktop/brand-assets/ # Superpowers plans & specs (local only) docs/superpowers/ +.superpowers/ # Temp server/web logs .tmp-*.log @@ -64,4 +65,4 @@ docs/.vitepress/cache runtime/__pycache__ # IDE -.idea \ No newline at end of file +.idea diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index b82a913b..e1211e48 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -139,6 +139,80 @@ export type SessionInspectionResponse = { errors?: Record } +export type WorkspaceFileStatus = + | 'modified' + | 'added' + | 'deleted' + | 'renamed' + | 'untracked' + | 'copied' + | 'type_changed' + | 'unknown' + +export type WorkspaceChangedFile = { + path: string + oldPath?: string + status: WorkspaceFileStatus + additions: number + deletions: number +} + +export type WorkspaceStatusResult = { + state: 'ok' | 'not_git_repo' | 'missing_workdir' | 'error' + workDir: string + repoName: string | null + branch: string | null + isGitRepo: boolean + changedFiles: WorkspaceChangedFile[] + error?: string +} + +export type WorkspaceReadFileResult = { + state: 'ok' | 'binary' | 'too_large' | 'missing' | 'error' + path: string + previewType?: 'text' | 'image' + content?: string + dataUrl?: string + mimeType?: string + language: string + size: number + error?: string +} + +export type WorkspaceTreeEntry = { + name: string + path: string + isDirectory: boolean +} + +export type WorkspaceTreeResult = { + state: 'ok' | 'missing' | 'error' + path: string + entries: WorkspaceTreeEntry[] + error?: string +} + +export type WorkspaceDiffResult = { + state: 'ok' | 'missing' | 'not_git_repo' | 'error' + path: string + diff?: string + error?: string +} + +function buildWorkspacePath( + sessionId: string, + resource: 'status' | 'tree' | 'file' | 'diff', + workspacePath?: string, +) { + const query = new URLSearchParams() + if (typeof workspacePath === 'string' && workspacePath.length > 0) { + query.set('path', workspacePath) + } + + const qs = query.toString() + return `/api/sessions/${sessionId}/workspace/${resource}${qs ? `?${qs}` : ''}` +} + export const sessionsApi = { list(params?: { project?: string; limit?: number; offset?: number }) { const query = new URLSearchParams() @@ -187,6 +261,22 @@ export const sessionsApi = { }) }, + getWorkspaceStatus(sessionId: string) { + return api.get(buildWorkspacePath(sessionId, 'status')) + }, + + getWorkspaceTree(sessionId: string, workspacePath = '') { + return api.get(buildWorkspacePath(sessionId, 'tree', workspacePath)) + }, + + getWorkspaceFile(sessionId: string, workspacePath: string) { + return api.get(buildWorkspacePath(sessionId, 'file', workspacePath)) + }, + + getWorkspaceDiff(sessionId: string, workspacePath: string) { + return api.get(buildWorkspacePath(sessionId, 'diff', workspacePath)) + }, + rewind(sessionId: string, body: { targetUserMessageId?: string userMessageIndex?: number diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index d41734ed..5b9a3afe 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -36,9 +36,10 @@ type Attachment = { type ChatInputProps = { variant?: 'default' | 'hero' + compact?: boolean } -export function ChatInput({ variant = 'default' }: ChatInputProps) { +export function ChatInput({ variant = 'default', compact = false }: ChatInputProps) { const t = useTranslation() const [input, setInput] = useState('') const [attachments, setAttachments] = useState([]) @@ -72,7 +73,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { const isActive = chatState !== 'idle' const isWorkspaceMissing = activeSession?.workDirExists === false const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || (!isMemberSession && attachments.length > 0)) - const isHeroComposer = variant === 'hero' && !isMemberSession + const isHeroComposer = variant === 'hero' && !isMemberSession && !compact const resolvedWorkDir = activeSession?.workDir || gitInfo?.workDir || undefined useEffect(() => { @@ -517,12 +518,30 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { const slashCommandsLabel = isHeroComposer ? t('empty.slashCommands') : t('chat.slashCommands') return ( -
-
+
+
event.preventDefault()} onDrop={handleDrop} > @@ -637,14 +656,18 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { placeholder={composerPlaceholder} disabled={isWorkspaceMissing} rows={1} - className="w-full resize-none bg-transparent py-2 pb-12 text-sm leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] disabled:opacity-50" + className={`w-full resize-none bg-transparent text-sm leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] disabled:opacity-50 ${ + compact ? 'py-1.5 pb-11' : 'py-2 pb-12' + }`} /> )}
-
+ : `absolute bottom-0 left-0 right-0 flex items-center justify-between border-t border-[var(--color-border-separator)] ${ + compact ? 'gap-2 px-2.5 py-2' : 'px-3 py-3' + }`}> +
{!isMemberSession && ( <>
@@ -679,20 +702,30 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { )}
- + )}
-
+
{!isMemberSession && activeTabId && ( - + )}
@@ -710,12 +743,13 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { {!isMemberSession && ( -
+
{hasMessages ? ( ) : ( s.activeTabId) const resolvedSessionId = sessionId ?? activeTabId const sessionState = useChatStore((s) => @@ -291,9 +292,9 @@ export function MessageList({ sessionId }: MessageListProps = {}) {
-
+
{renderItems.map((item) => { if (item.kind === 'tool_group') { return ( diff --git a/desktop/src/components/controls/ModelSelector.tsx b/desktop/src/components/controls/ModelSelector.tsx index 730ba1e7..0605575c 100644 --- a/desktop/src/components/controls/ModelSelector.tsx +++ b/desktop/src/components/controls/ModelSelector.tsx @@ -21,6 +21,7 @@ type Props = { onChange?: (modelId: string) => void runtimeKey?: string disabled?: boolean + compact?: boolean } function officialChoices(availableModels: ModelInfo[], isDefault: boolean, officialName: string): ProviderChoice { @@ -105,6 +106,7 @@ export function ModelSelector({ onChange, runtimeKey, disabled = false, + compact = false, }: Props = {}) { const t = useTranslation() const { @@ -229,13 +231,15 @@ export function ModelSelector({ {open && ( diff --git a/desktop/src/components/layout/TabBar.test.tsx b/desktop/src/components/layout/TabBar.test.tsx index e472e4f6..edbe4ed8 100644 --- a/desktop/src/components/layout/TabBar.test.tsx +++ b/desktop/src/components/layout/TabBar.test.tsx @@ -23,6 +23,9 @@ vi.mock('../../i18n', () => ({ 'tabs.closeConfirmMessage': 'Still running', 'tabs.closeConfirmKeep': 'Keep Running', 'tabs.closeConfirmStop': 'Stop & Close', + 'tabs.openTerminal': 'Open Terminal', + 'tabs.showWorkspace': 'Show Workspace', + 'tabs.hideWorkspace': 'Hide Workspace', 'common.cancel': 'Cancel', } @@ -64,11 +67,13 @@ describe('TabBar', () => { afterEach(async () => { const { useTabStore } = await import('../../stores/tabStore') const { useChatStore } = await import('../../stores/chatStore') + const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore') useTabStore.setState({ tabs: [], activeTabId: null }) useChatStore.setState({ sessions: {}, } as Partial>) + useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true) delete (window as typeof window & { __TAURI__?: unknown }).__TAURI__ }) @@ -356,11 +361,122 @@ describe('TabBar', () => { render() }) - expect(screen.getByText('terminal')).toBeInTheDocument() + expect(screen.getByLabelText('Open Terminal')).toBeInTheDocument() fireEvent.click(screen.getByLabelText('Close Terminal 1')) expect(disconnectSession).not.toHaveBeenCalled() expect(useTabStore.getState().tabs).toEqual([]) }) + + it('opens a terminal tab from the toolbar', async () => { + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + const { useChatStore } = await import('../../stores/chatStore') + + useTabStore.setState({ + tabs: [ + { sessionId: 'tab-1', title: 'First Session', type: 'session', status: 'idle' }, + ], + activeTabId: 'tab-1', + }) + useChatStore.setState({ + sessions: {}, + disconnectSession: vi.fn(), + } as Partial>) + + await act(async () => { + render() + }) + + fireEvent.click(screen.getByRole('button', { name: 'Open Terminal' })) + + const terminalTabs = useTabStore.getState().tabs.filter((tab) => tab.type === 'terminal') + expect(terminalTabs).toHaveLength(1) + expect(useTabStore.getState().activeTabId).toBe(terminalTabs[0]?.sessionId) + }) + + it('toggles the workspace panel for the active session from the toolbar', async () => { + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + const { useChatStore } = await import('../../stores/chatStore') + const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore') + + useTabStore.setState({ + tabs: [ + { sessionId: 'tab-1', title: 'First Session', type: 'session', status: 'idle' }, + ], + activeTabId: 'tab-1', + }) + useChatStore.setState({ + sessions: {}, + disconnectSession: vi.fn(), + } as Partial>) + + await act(async () => { + render() + }) + + fireEvent.click(screen.getByRole('button', { name: 'Show Workspace' })) + expect(useWorkspacePanelStore.getState().isPanelOpen('tab-1')).toBe(true) + + fireEvent.click(screen.getByRole('button', { name: 'Hide Workspace' })) + expect(useWorkspacePanelStore.getState().isPanelOpen('tab-1')).toBe(false) + }) + + it('hides the workspace toolbar button for non-session tabs', async () => { + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + const { useChatStore } = await import('../../stores/chatStore') + + useTabStore.setState({ + tabs: [ + { sessionId: '__terminal__1', title: 'Terminal 1', type: 'terminal', status: 'idle' }, + { sessionId: '__settings__', title: 'Settings', type: 'settings', status: 'idle' }, + ], + activeTabId: '__terminal__1', + }) + useChatStore.setState({ + sessions: {}, + disconnectSession: vi.fn(), + } as Partial>) + + const { rerender } = render() + + expect(screen.queryByRole('button', { name: 'Show Workspace' })).not.toBeInTheDocument() + + await act(async () => { + useTabStore.getState().setActiveTab('__settings__') + }) + rerender() + + expect(screen.queryByRole('button', { name: 'Show Workspace' })).not.toBeInTheDocument() + }) + + it('clears workspace panel state when closing a session tab', async () => { + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + const { useChatStore } = await import('../../stores/chatStore') + const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore') + + useTabStore.setState({ + tabs: [ + { sessionId: 'tab-1', title: 'First Session', type: 'session', status: 'idle' }, + ], + activeTabId: 'tab-1', + }) + useChatStore.setState({ + sessions: {}, + disconnectSession: vi.fn(), + } as Partial>) + useWorkspacePanelStore.getState().openPanel('tab-1') + + await act(async () => { + render() + }) + + fireEvent.click(screen.getByLabelText('Close First Session')) + + expect(useWorkspacePanelStore.getState().panelBySession['tab-1']).toBeUndefined() + }) }) diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index 7da193ae..a44de357 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -1,6 +1,7 @@ import { forwardRef, useRef, useState, useEffect, useCallback } from 'react' import { useTabStore, type Tab } from '../../stores/tabStore' import { useChatStore } from '../../stores/chatStore' +import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' import { useTranslation } from '../../i18n' import { WindowControls, showWindowControls } from './WindowControls' @@ -14,6 +15,11 @@ export function TabBar() { const setActiveTab = useTabStore((s) => s.setActiveTab) const closeTab = useTabStore((s) => s.closeTab) const disconnectSession = useChatStore((s) => s.disconnectSession) + const activeTab = tabs.find((tab) => tab.sessionId === activeTabId) ?? null + const isActiveSessionTab = activeTab?.type === 'session' + const isWorkspacePanelOpen = useWorkspacePanelStore((state) => + activeTabId && isActiveSessionTab ? state.isPanelOpen(activeTabId) : false, + ) const moveTab = useTabStore((s) => s.moveTab) const scrollRef = useRef(null) @@ -74,12 +80,19 @@ export function TabBar() { el.scrollBy({ left: direction === 'left' ? -TAB_WIDTH : TAB_WIDTH, behavior: 'smooth' }) } + const closeTabWithCleanup = useCallback((tab: Tab) => { + if (tab.type === 'session') { + useWorkspacePanelStore.getState().clearSession(tab.sessionId) + } + closeTab(tab.sessionId) + }, [closeTab]) + const handleClose = (sessionId: string) => { // Special tabs can always be closed directly const tab = tabs.find((t) => t.sessionId === sessionId) if (!tab) return if (tab.type !== 'session') { - closeTab(sessionId) + closeTabWithCleanup(tab) return } @@ -92,7 +105,7 @@ export function TabBar() { } disconnectSession(sessionId) - closeTab(sessionId) + closeTabWithCleanup(tab) } const handleContextMenu = (e: React.MouseEvent, sessionId: string) => { @@ -105,7 +118,7 @@ export function TabBar() { const otherTabs = tabs.filter((t) => t.sessionId !== sessionId) for (const tab of otherTabs) { if (tab.type === 'session') disconnectSession(tab.sessionId) - closeTab(tab.sessionId) + closeTabWithCleanup(tab) } } @@ -115,7 +128,7 @@ export function TabBar() { const leftTabs = tabs.slice(0, idx) for (const tab of leftTabs) { if (tab.type === 'session') disconnectSession(tab.sessionId) - closeTab(tab.sessionId) + closeTabWithCleanup(tab) } } @@ -125,7 +138,7 @@ export function TabBar() { const rightTabs = tabs.slice(idx + 1) for (const tab of rightTabs) { if (tab.type === 'session') disconnectSession(tab.sessionId) - closeTab(tab.sessionId) + closeTabWithCleanup(tab) } } @@ -133,7 +146,7 @@ export function TabBar() { setContextMenu(null) for (const tab of tabs) { if (tab.type === 'session') disconnectSession(tab.sessionId) - closeTab(tab.sessionId) + closeTabWithCleanup(tab) } } @@ -265,6 +278,21 @@ export function TabBar() { ))}
+
+ useTabStore.getState().openTerminalTab()} + /> + {isActiveSessionTab && activeTabId && ( + useWorkspacePanelStore.getState().togglePanel(activeTabId)} + /> + )} +
+ {isTauri && (
+ ) +} diff --git a/desktop/src/components/shared/ProjectContextChip.tsx b/desktop/src/components/shared/ProjectContextChip.tsx index f6a7fb49..2e798d81 100644 --- a/desktop/src/components/shared/ProjectContextChip.tsx +++ b/desktop/src/components/shared/ProjectContextChip.tsx @@ -2,21 +2,26 @@ type Props = { workDir?: string | null repoName?: string | null branch?: string | null + compact?: boolean } -export function ProjectContextChip({ workDir, repoName, branch }: Props) { +export function ProjectContextChip({ workDir, repoName, branch, compact = false }: Props) { const label = branch ? (repoName || workDir?.split('/').pop() || '') : (workDir?.split('/').pop() || repoName || '') if (!label) return null return ( -
+
{branch ? ( - + ) : ( - folder + folder )} {label} {branch ? ( diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx new file mode 100644 index 00000000..500ecb0f --- /dev/null +++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx @@ -0,0 +1,615 @@ +// @vitest-environment jsdom + +// @ts-expect-error jsdom is installed in this workspace without local type declarations +import { JSDOM } from 'jsdom' +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +if (typeof document === 'undefined') { + const dom = new JSDOM('', { + url: 'http://localhost/', + }) + const { window } = dom + + Object.assign(globalThis, { + window, + document: window.document, + navigator: window.navigator, + localStorage: window.localStorage, + HTMLElement: window.HTMLElement, + HTMLButtonElement: window.HTMLButtonElement, + MutationObserver: window.MutationObserver, + Node: window.Node, + Event: window.Event, + MouseEvent: window.MouseEvent, + KeyboardEvent: window.KeyboardEvent, + getComputedStyle: window.getComputedStyle.bind(window), + IS_REACT_ACT_ENVIRONMENT: true, + }) +} + +type WorkspaceApiMocks = { + getWorkspaceStatusMock: ReturnType + getWorkspaceTreeMock: ReturnType + getWorkspaceFileMock: ReturnType + getWorkspaceDiffMock: ReturnType +} + +var mocks: WorkspaceApiMocks | undefined + +function getMocks() { + if (!mocks) { + throw new Error('Workspace API mocks were not initialized') + } + return mocks +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +async function setWorkspaceState( + updater: + | ReturnType + | Parameters[0], +) { + await act(() => { + useWorkspacePanelStore.setState(updater as Parameters[0], true) + }) +} + +async function setSettingsState( + updater: + | ReturnType + | Parameters[0], +) { + await act(() => { + useSettingsStore.setState(updater as Parameters[0], true) + }) +} + +async function renderPanel(sessionId: string) { + let view!: ReturnType + await act(() => { + view = render() + }) + return view +} + +async function clickElement(element: Element) { + await act(() => { + fireEvent.click(element) + }) +} + +vi.mock('../../api/sessions', () => ({ + sessionsApi: (() => { + if (!mocks) { + mocks = { + getWorkspaceStatusMock: vi.fn(), + getWorkspaceTreeMock: vi.fn(), + getWorkspaceFileMock: vi.fn(), + getWorkspaceDiffMock: vi.fn(), + } + } + + return { + getWorkspaceStatus: mocks.getWorkspaceStatusMock, + getWorkspaceTree: mocks.getWorkspaceTreeMock, + getWorkspaceFile: mocks.getWorkspaceFileMock, + getWorkspaceDiff: mocks.getWorkspaceDiffMock, + } + })(), +})) + +import { useSettingsStore } from '../../stores/settingsStore' +import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' +import { WorkspacePanel } from './WorkspacePanel' + +describe('WorkspacePanel', () => { + const workspaceInitialState = useWorkspacePanelStore.getInitialState() + const settingsInitialState = useSettingsStore.getInitialState() + + beforeEach(async () => { + vi.clearAllMocks() + await setWorkspaceState(workspaceInitialState) + await setSettingsState({ ...settingsInitialState, locale: 'en' }) + }) + + afterEach(async () => { + cleanup() + await setWorkspaceState(workspaceInitialState) + await setSettingsState(settingsInitialState) + vi.restoreAllMocks() + }) + + it('stays hidden when the panel is closed', async () => { + const view = await renderPanel('session-hidden') + + expect(view.queryByTestId('workspace-panel')).toBeNull() + }) + + it('loads changed status on open and opens a diff preview from the changed view', async () => { + const statusRequest = deferred<{ + state: 'ok' + workDir: string + repoName: string + branch: string + isGitRepo: true + changedFiles: Array<{ + path: string + status: 'modified' + additions: number + deletions: number + }> + }>() + const diffRequest = deferred<{ + state: 'ok' + path: string + diff: string + }>() + + getMocks().getWorkspaceStatusMock.mockReturnValue(statusRequest.promise) + getMocks().getWorkspaceDiffMock.mockReturnValue(diffRequest.promise) + + await act(() => { + useWorkspacePanelStore.getState().openPanel('session-changed') + }) + + const view = await renderPanel('session-changed') + + expect(view.getByTestId('workspace-panel').style.maxWidth).toBe('calc(100% - 348px)') + + await waitFor(() => { + expect(getMocks().getWorkspaceStatusMock).toHaveBeenCalledWith('session-changed') + }) + + await act(async () => { + statusRequest.resolve({ + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [ + { + path: 'src/app.ts', + status: 'modified', + additions: 4, + deletions: 1, + }, + ], + }) + await statusRequest.promise + }) + + expect(view.getByPlaceholderText('Filter files...')).toBeTruthy() + + await clickElement(await view.findByText('src/app.ts')) + + await waitFor(() => { + expect(getMocks().getWorkspaceDiffMock).toHaveBeenCalledWith('session-changed', 'src/app.ts') + }) + + await act(async () => { + diffRequest.resolve({ + state: 'ok', + path: 'src/app.ts', + diff: '@@ -1 +1 @@\n-console.log("old")\n+console.log("new")', + }) + await diffRequest.promise + }) + + await waitFor(() => { + expect(view.getByTestId('workspace-code').textContent).toContain('console.log("new")') + }) + expect(view.getAllByText('Diff').length).toBeGreaterThan(0) + }) + + it('renders transcript-derived changed files for non-git sessions', async () => { + getMocks().getWorkspaceStatusMock.mockResolvedValue({ + state: 'ok', + workDir: '/tmp/non-git-session', + repoName: 'non-git-session', + branch: null, + isGitRepo: false, + changedFiles: [ + { + path: 'src/app.ts', + status: 'modified', + additions: 1, + deletions: 1, + }, + ], + }) + getMocks().getWorkspaceDiffMock.mockResolvedValue({ + state: 'ok', + path: 'src/app.ts', + diff: 'diff --session a/src/app.ts b/src/app.ts\n-export const answer = 1\n+export const answer = 2', + }) + + await act(() => { + useWorkspacePanelStore.getState().openPanel('session-non-git') + }) + + const view = await renderPanel('session-non-git') + + await waitFor(() => { + expect(view.getByText('src/app.ts')).toBeTruthy() + }) + expect(view.queryByText('No matching files')).toBeNull() + + await clickElement(view.getByText('src/app.ts')) + + await waitFor(() => { + expect(getMocks().getWorkspaceDiffMock).toHaveBeenCalledWith('session-non-git', 'src/app.ts') + }) + await waitFor(() => { + expect(view.getByTestId('workspace-code').textContent).toContain('export const answer = 2') + }) + }) + + it('lazy loads the root tree, expands directories, and opens file previews from the all-files view', async () => { + const statusRequest = deferred<{ + state: 'ok' + workDir: string + repoName: string + branch: string + isGitRepo: true + changedFiles: [] + }>() + const rootTreeRequest = deferred<{ + state: 'ok' + path: '' + entries: Array<{ name: string; path: string; isDirectory: boolean }> + }>() + const childTreeRequest = deferred<{ + state: 'ok' + path: 'src' + entries: Array<{ name: string; path: string; isDirectory: boolean }> + }>() + const fileRequest = deferred<{ + state: 'ok' + path: string + content: string + language: string + size: number + }>() + + getMocks().getWorkspaceStatusMock.mockReturnValue(statusRequest.promise) + getMocks().getWorkspaceTreeMock + .mockReturnValueOnce(rootTreeRequest.promise) + .mockReturnValueOnce(childTreeRequest.promise) + getMocks().getWorkspaceFileMock.mockReturnValue(fileRequest.promise) + + await act(() => { + useWorkspacePanelStore.getState().openPanel('session-tree') + }) + + const view = await renderPanel('session-tree') + + expect(view.getByRole('button', { name: 'Changed files' })).toBeTruthy() + + await clickElement(view.getByRole('button', { name: 'Changed files' })) + await clickElement(view.getByRole('menuitem', { name: 'All files' })) + + await waitFor(() => { + expect(getMocks().getWorkspaceTreeMock).toHaveBeenCalledWith('session-tree', '') + }) + + await act(async () => { + statusRequest.resolve({ + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }) + rootTreeRequest.resolve({ + state: 'ok', + path: '', + entries: [ + { name: 'src', path: 'src', isDirectory: true }, + { name: 'README.md', path: 'README.md', isDirectory: false }, + ], + }) + await Promise.all([statusRequest.promise, rootTreeRequest.promise]) + }) + + const folderLabel = await view.findByText('src') + const folderButton = folderLabel.closest('button') + if (!folderButton) { + throw new Error('Expected src label to be rendered inside a folder button') + } + expect(folderButton.getAttribute('aria-expanded')).toBe('false') + + await clickElement(folderButton) + + await waitFor(() => { + expect(getMocks().getWorkspaceTreeMock).toHaveBeenCalledWith('session-tree', 'src') + }) + await act(async () => { + childTreeRequest.resolve({ + state: 'ok', + path: 'src', + entries: [{ name: 'index.ts', path: 'src/index.ts', isDirectory: false }], + }) + await childTreeRequest.promise + }) + await waitFor(() => { + expect(folderButton.getAttribute('aria-expanded')).toBe('true') + }) + + await clickElement(await view.findByText('index.ts')) + + await waitFor(() => { + expect(getMocks().getWorkspaceFileMock).toHaveBeenCalledWith('session-tree', 'src/index.ts') + }) + await act(async () => { + fileRequest.resolve({ + state: 'ok', + path: 'src/index.ts', + content: 'export const ready = true', + language: 'typescript', + size: 25, + }) + await fileRequest.promise + }) + + await waitFor(() => { + expect(view.getByTestId('workspace-code').textContent).toContain('export const ready = true') + }) + expect(view.getAllByText('File').length).toBeGreaterThan(0) + }) + + it('renders multiple preview tabs and closes only the exact requested tab', async () => { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-tabs': { + isOpen: true, + activeView: 'changed', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-tabs': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + previewTabsBySession: { + ...state.previewTabsBySession, + 'session-tabs': [ + { + id: 'file:src/a.ts', + path: 'src/a.ts', + kind: 'file', + title: 'a.ts', + language: 'typescript', + content: 'export const a = 1', + state: 'ok', + size: 18, + }, + { + id: 'diff:src/a.ts', + path: 'src/a.ts', + kind: 'diff', + title: 'a.ts', + diff: '@@ -1 +1 @@', + state: 'ok', + }, + { + id: 'file:src/b.ts', + path: 'src/b.ts', + kind: 'file', + title: 'b.ts', + language: 'typescript', + content: 'export const b = 1', + state: 'ok', + size: 18, + }, + ], + }, + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + 'session-tabs': 'diff:src/a.ts', + }, + })) + + const view = await renderPanel('session-tabs') + + expect(view.getByRole('tablist', { name: 'Preview tabs' })).toBeTruthy() + expect(view.getAllByRole('tab', { name: /a\.ts/ })).toHaveLength(2) + expect(view.getAllByText('a.ts').length).toBeGreaterThanOrEqual(2) + expect(view.getAllByText('b.ts').length).toBeGreaterThanOrEqual(1) + + await clickElement(view.getByLabelText('Close tab a.ts Diff')) + + expect(view.queryByLabelText('Close tab a.ts Diff')).toBeNull() + expect(view.getByLabelText('Close tab a.ts File')).toBeTruthy() + expect(view.getAllByText('b.ts').length).toBeGreaterThanOrEqual(1) + }) + + it('caps rendered preview lines to keep large diffs responsive', async () => { + const longDiff = Array.from({ length: 650 }, (_, index) => `+line ${index + 1}`).join('\n') + + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-large-preview': { + isOpen: true, + activeView: 'changed', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-large-preview': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + previewTabsBySession: { + ...state.previewTabsBySession, + 'session-large-preview': [{ + id: 'diff:large.ts', + path: 'large.ts', + kind: 'diff', + title: 'large.ts', + diff: longDiff, + state: 'ok', + }], + }, + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + 'session-large-preview': 'diff:large.ts', + }, + })) + + const view = await renderPanel('session-large-preview') + const highlightedCode = view.getByTestId('workspace-code').textContent ?? '' + + expect(highlightedCode).toContain('+line 1') + expect(highlightedCode).toContain('+line 420') + expect(highlightedCode).not.toContain('+line 421') + expect(view.getByText('Showing first 420 lines. Open in your editor for the full file.')).toBeTruthy() + }) + + it('renders image previews from workspace files', async () => { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-image-preview': { + isOpen: true, + activeView: 'all', + }, + }, + treeBySessionPath: { + ...state.treeBySessionPath, + 'session-image-preview': { + '': { + state: 'ok', + path: '', + entries: [{ name: 'logo.png', path: 'logo.png', isDirectory: false }], + }, + }, + }, + })) + + getMocks().getWorkspaceFileMock.mockResolvedValue({ + state: 'ok', + path: 'logo.png', + previewType: 'image', + dataUrl: 'data:image/png;base64,iVBORw0KGgo=', + mimeType: 'image/png', + language: 'image', + size: 8, + }) + + const view = await renderPanel('session-image-preview') + + await clickElement(await view.findByText('logo.png')) + + const image = await view.findByRole('img', { name: 'logo.png' }) + expect(image.getAttribute('src')).toBe('data:image/png;base64,iVBORw0KGgo=') + }) + + it('uses the localized view menu label', async () => { + await setSettingsState({ ...settingsInitialState, locale: 'zh' }) + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-zh': { + isOpen: true, + activeView: 'changed', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-zh': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + })) + + const view = await renderPanel('session-zh') + + expect(view.getByRole('button', { name: '已更改文件' })).toBeTruthy() + }) + + it('shows explicit empty and error states in the changed view', async () => { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-empty': { + isOpen: true, + activeView: 'changed', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-empty': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + })) + + const view = await renderPanel('session-empty') + + expect(view.getByText('No changes')).toBeTruthy() + + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-error': { + isOpen: true, + activeView: 'changed', + }, + }, + errors: { + ...state.errors, + statusBySession: { + ...state.errors.statusBySession, + 'session-error': 'status failed', + }, + }, + })) + + await act(() => { + view.rerender() + }) + + expect(view.getByText('status failed')).toBeTruthy() + }) +}) diff --git a/desktop/src/components/workspace/WorkspacePanel.tsx b/desktop/src/components/workspace/WorkspacePanel.tsx new file mode 100644 index 00000000..a7007105 --- /dev/null +++ b/desktop/src/components/workspace/WorkspacePanel.tsx @@ -0,0 +1,1057 @@ +import { useEffect, useMemo, useState } from 'react' +import { Highlight, type PrismTheme } from 'prism-react-renderer' +import type { + WorkspaceChangedFile, + WorkspaceFileStatus, + WorkspaceTreeEntry, + WorkspaceTreeResult, +} from '../../api/sessions' +import { useTranslation } from '../../i18n' +import { useShallow } from 'zustand/react/shallow' +import { + useWorkspacePanelStore, + type WorkspacePreviewKind, + type WorkspacePreviewTab, +} from '../../stores/workspacePanelStore' + +type WorkspacePanelProps = { + sessionId: string +} + +type TreeNodeProps = { + sessionId: string + entry: WorkspaceTreeEntry + depth: number + expandedPaths: Set + treeByPath: Record + treeLoadingByPath: Record + treeErrorsByPath: Record + filterQuery: string + onToggle: (path: string) => void + onOpenFile: (path: string) => void + activePath: string | null +} + +const FILE_STATUS_META: Record = { + modified: { + label: 'M', + className: 'border-[var(--color-warning)]/35 bg-[var(--color-warning)]/12 text-[var(--color-warning)]', + }, + added: { + label: 'A', + className: 'border-[var(--color-success)]/35 bg-[var(--color-success)]/12 text-[var(--color-success)]', + }, + deleted: { + label: 'D', + className: 'border-[var(--color-error)]/35 bg-[var(--color-error)]/12 text-[var(--color-error)]', + }, + renamed: { + label: 'R', + className: 'border-[var(--color-brand)]/35 bg-[var(--color-brand)]/12 text-[var(--color-brand)]', + }, + untracked: { + label: 'U', + className: 'border-[var(--color-tertiary)]/35 bg-[var(--color-tertiary)]/12 text-[var(--color-tertiary)]', + }, + copied: { + label: 'C', + className: 'border-[var(--color-secondary)]/35 bg-[var(--color-secondary)]/12 text-[var(--color-secondary)]', + }, + type_changed: { + label: 'T', + className: 'border-[var(--color-outline)]/45 bg-[var(--color-outline)]/10 text-[var(--color-text-secondary)]', + }, + unknown: { + label: '?', + className: 'border-[var(--color-outline)]/45 bg-[var(--color-outline)]/10 text-[var(--color-text-secondary)]', + }, +} + +const EMPTY_TREE_BY_PATH: Record = {} +const EMPTY_PREVIEW_TABS: WorkspacePreviewTab[] = [] +const EMPTY_EXPANDED_PATHS: string[] = [] +const PREVIEW_LINE_LIMIT = 420 + +const workspacePrismTheme: PrismTheme = { + plain: { + color: 'var(--color-code-fg)', + backgroundColor: 'transparent', + }, + styles: [ + { types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--color-code-comment)', fontStyle: 'italic' } }, + { types: ['string', 'attr-value', 'template-string'], style: { color: 'var(--color-code-string)' } }, + { types: ['keyword', 'selector', 'important', 'atrule'], style: { color: 'var(--color-code-keyword)' } }, + { types: ['function'], style: { color: 'var(--color-code-function)' } }, + { types: ['tag'], style: { color: 'var(--color-code-keyword)' } }, + { types: ['number', 'boolean'], style: { color: 'var(--color-code-number)' } }, + { types: ['operator'], style: { color: 'var(--color-code-fg)' } }, + { types: ['punctuation'], style: { color: 'var(--color-code-punctuation)' } }, + { types: ['variable', 'parameter'], style: { color: 'var(--color-code-fg)' } }, + { types: ['property', 'attr-name'], style: { color: 'var(--color-code-property)' } }, + { types: ['builtin', 'class-name', 'constant', 'symbol'], style: { color: 'var(--color-code-type)' } }, + { types: ['inserted'], style: { color: 'var(--color-code-inserted)' } }, + { types: ['deleted'], style: { color: 'var(--color-code-deleted)' } }, + ], +} + +const FILE_BADGE_META: Record = { + ts: { label: 'TS', className: 'bg-[#dff0ff] text-[#2b86c5]' }, + tsx: { label: 'TSX', className: 'bg-[#dff0ff] text-[#2b86c5]' }, + js: { label: 'JS', className: 'bg-[#fff3bf] text-[#8a6500]' }, + jsx: { label: 'JSX', className: 'bg-[#fff3bf] text-[#8a6500]' }, + json: { label: '{}', className: 'bg-[#eee9ff] text-[#6f4fb8]' }, + md: { label: 'MD', className: 'bg-[#e9eef3] text-[#5e6872]' }, + css: { label: 'CSS', className: 'bg-[#e4f2ff] text-[#246da6]' }, + html: { label: 'H', className: 'bg-[#ffe7dc] text-[#b9552d]' }, + png: { label: 'IMG', className: 'bg-[#e4f7ed] text-[#287747]' }, + jpg: { label: 'IMG', className: 'bg-[#e4f7ed] text-[#287747]' }, + jpeg: { label: 'IMG', className: 'bg-[#e4f7ed] text-[#287747]' }, + gif: { label: 'IMG', className: 'bg-[#e4f7ed] text-[#287747]' }, + svg: { label: 'SVG', className: 'bg-[#e4f7ed] text-[#287747]' }, +} + +function makeTreeStateKey(sessionId: string, path: string) { + return `${sessionId}::${path}` +} + +function makePreviewStateKey(sessionId: string, tabId: string) { + return `${sessionId}::${tabId}` +} + +function getSessionScopedRecord( + record: Record, + sessionId: string, +) { + const prefix = `${sessionId}::` + return Object.fromEntries( + Object.entries(record).filter(([key]) => key.startsWith(prefix)), + ) as Record +} + +function getPreviewKindLabel( + t: ReturnType, + kind: WorkspacePreviewKind, +) { + return kind === 'diff' ? t('workspace.previewKind.diff') : t('workspace.previewKind.file') +} + +function getFileExtension(name: string) { + const cleanName = name.split('/').pop() ?? name + const lastDot = cleanName.lastIndexOf('.') + if (lastDot <= 0 || lastDot === cleanName.length - 1) return '' + return cleanName.slice(lastDot + 1).toLowerCase() +} + +function getFileBadgeMeta(name: string) { + const extension = getFileExtension(name) + return FILE_BADGE_META[extension] ?? { + label: extension ? extension.slice(0, 3).toUpperCase() : 'TXT', + className: 'bg-[#eef0f2] text-[#747b83]', + } +} + +function normalizePrismLanguage(language: string) { + const lower = language.toLowerCase() + const map: Record = { + text: 'text', + typescript: 'typescript', + ts: 'typescript', + tsx: 'tsx', + javascript: 'javascript', + js: 'javascript', + jsx: 'jsx', + markdown: 'markdown', + md: 'markdown', + html: 'markup', + xml: 'markup', + shell: 'bash', + sh: 'bash', + zsh: 'bash', + diff: 'diff', + } + return map[lower] ?? lower +} + +function getLanguageFromPath(path: string) { + return normalizePrismLanguage(getFileExtension(path) || 'text') +} + +function FileTypeBadge({ name, subtle = false }: { name: string; subtle?: boolean }) { + const meta = getFileBadgeMeta(name) + return ( + + ) +} + +function InlineHighlightedCode({ + value, + language, +}: { + value: string + language: string +}) { + return ( + + {({ tokens, getTokenProps }) => ( + <> + {(tokens[0] ?? []).map((token, tokenIndex) => { + const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex }) + return + })} + + )} + + ) +} + +function getInlineStateMessage( + t: ReturnType, + state: WorkspacePreviewTab['state'] | WorkspaceTreeResult['state'] | 'not_git_repo' | undefined, + fallbackError?: string | null, +) { + switch (state) { + case 'loading': + return t('workspace.previewState.loading') + case 'binary': + return t('workspace.previewState.binary') + case 'too_large': + return t('workspace.previewState.tooLarge') + case 'missing': + return t('workspace.previewState.missing') + case 'not_git_repo': + return t('workspace.notGitRepo') + case 'error': + return fallbackError || t('workspace.loadError') + default: + return fallbackError || t('workspace.loadError') + } +} + +function normalizeFilterQuery(query: string) { + return query.trim().toLowerCase() +} + +function changedFileMatchesFilter(file: WorkspaceChangedFile, query: string) { + if (!query) return true + return ( + file.path.toLowerCase().includes(query) + || file.oldPath?.toLowerCase().includes(query) + || file.status.toLowerCase().includes(query) + ) +} + +function treeEntryMatchesFilter( + entry: WorkspaceTreeEntry, + query: string, + treeByPath: Record, +): boolean { + if (!query) return true + if (entry.name.toLowerCase().includes(query) || entry.path.toLowerCase().includes(query)) { + return true + } + + if (!entry.isDirectory) return false + const childTree = treeByPath[entry.path] + if (childTree?.state !== 'ok') return false + return childTree.entries.some((child) => treeEntryMatchesFilter(child, query, treeByPath)) +} + +function PanelMessage({ + icon, + message, + tone = 'muted', + compact = false, +}: { + icon: string + message: string + tone?: 'muted' | 'error' + compact?: boolean +}) { + const toneClass = + tone === 'error' + ? 'text-[var(--color-error)]' + : 'text-[var(--color-text-tertiary)]' + + return ( +
+ + {icon} + + {message} +
+ ) +} + +function ToolbarIconButton({ + icon, + label, + onClick, +}: { + icon: string + label: string + onClick: () => void +}) { + return ( + + ) +} + +function WorkspaceFilterInput({ + value, + onChange, +}: { + value: string + onChange: (value: string) => void +}) { + const t = useTranslation() + + return ( +
+ +
+ ) +} + +function FileStatusBadge({ status }: { status: WorkspaceFileStatus }) { + const meta = FILE_STATUS_META[status] + return ( + + {meta.label} + + ) +} + +function CodeSurface({ value, language }: { value: string; language: string }) { + const t = useTranslation() + const lines = value.split('\n') + const visibleLines = lines.slice(0, PREVIEW_LINE_LIMIT) + const visibleCode = visibleLines.join('\n') + const hiddenLineCount = Math.max(0, lines.length - visibleLines.length) + + return ( +
+
+ + {({ tokens, getLineProps, getTokenProps }) => ( +
+              {tokens.map((line, index) => {
+                const { key: lineKey, ...lineProps } = getLineProps({ line, key: index })
+                return (
+                  
+ + {index + 1} + + + {line.length === 1 && line[0]?.empty ? ' ' : line.map((token, tokenIndex) => { + const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex }) + return + })} + +
+ ) + })} +
+ )} +
+ {hiddenLineCount > 0 && ( +
+ {t('workspace.previewLineLimit', { count: PREVIEW_LINE_LIMIT })} +
+ )} +
+
+ ) +} + +function DiffSurface({ value, path }: { value: string; path: string }) { + const t = useTranslation() + const lines = value.split('\n') + const visibleLines = lines.slice(0, PREVIEW_LINE_LIMIT) + const hiddenLineCount = Math.max(0, lines.length - visibleLines.length) + const language = getLanguageFromPath(path) + + return ( +
+
+
+          {visibleLines.map((line, index) => {
+            const isFileHeader = line.startsWith('diff --') || line.startsWith('--- ') || line.startsWith('+++ ')
+            const isHunk = line.startsWith('@@')
+            const isAdded = line.startsWith('+') && !line.startsWith('+++')
+            const isRemoved = line.startsWith('-') && !line.startsWith('---')
+            const isCodeLine = isAdded || isRemoved || line.startsWith(' ')
+            const code = isCodeLine ? line.slice(1) : line
+            const prefix = isCodeLine ? line[0] : ' '
+
+            return (
+              
+ + {index + 1} + + + {prefix} + + + {isCodeLine ? ( + code ? : ' ' + ) : ( + code || ' ' + )} + +
+ ) + })} +
+ {hiddenLineCount > 0 && ( +
+ {t('workspace.previewLineLimit', { count: PREVIEW_LINE_LIMIT })} +
+ )} +
+
+ ) +} + +function ImagePreview({ tab }: { tab: WorkspacePreviewTab }) { + const t = useTranslation() + + if (!tab.dataUrl) { + return ( + + ) + } + + return ( +
+
+ {tab.path} +
+
+ ) +} + +function ChangedFileRow({ + file, + onClick, +}: { + file: WorkspaceChangedFile + onClick: () => void +}) { + return ( + + ) +} + +function TreeNode({ + sessionId, + entry, + depth, + expandedPaths, + treeByPath, + treeLoadingByPath, + treeErrorsByPath, + filterQuery, + onToggle, + onOpenFile, + activePath, +}: TreeNodeProps) { + const t = useTranslation() + const childTree = treeByPath[entry.path] + const childLoading = treeLoadingByPath[makeTreeStateKey(sessionId, entry.path)] ?? false + const childError = treeErrorsByPath[makeTreeStateKey(sessionId, entry.path)] ?? null + const isExpanded = expandedPaths.has(entry.path) + const isVisuallyExpanded = isExpanded || filterQuery.length > 0 + const indent = 14 + depth * 20 + + if (!entry.isDirectory) { + const isActive = entry.path === activePath + return ( + + ) + } + + return ( +
+ + + {isVisuallyExpanded && ( +
+ {depth < 4 && ( +
+ )} +
+ ) +} + +export function WorkspacePanel({ sessionId }: WorkspacePanelProps) { + const t = useTranslation() + const [filterQuery, setFilterQuery] = useState('') + const [isViewMenuOpen, setIsViewMenuOpen] = useState(false) + const width = useWorkspacePanelStore((state) => state.width) + const isOpen = useWorkspacePanelStore((state) => state.isPanelOpen(sessionId)) + const activeView = useWorkspacePanelStore((state) => state.getActiveView(sessionId)) + const status = useWorkspacePanelStore((state) => state.statusBySession[sessionId]) + const treeByPath = useWorkspacePanelStore((state) => state.treeBySessionPath[sessionId] ?? EMPTY_TREE_BY_PATH) + const previewTabs = useWorkspacePanelStore((state) => state.previewTabsBySession[sessionId] ?? EMPTY_PREVIEW_TABS) + const activePreviewTabId = useWorkspacePanelStore((state) => state.activePreviewTabIdBySession[sessionId] ?? null) + const expandedPaths = useWorkspacePanelStore((state) => state.expandedPathsBySession[sessionId] ?? EMPTY_EXPANDED_PATHS) + const statusLoading = useWorkspacePanelStore((state) => state.loading.statusBySession[sessionId] ?? false) + const treeLoadingByPath = useWorkspacePanelStore( + useShallow((state) => getSessionScopedRecord(state.loading.treeBySessionPath, sessionId)), + ) + const statusError = useWorkspacePanelStore((state) => state.errors.statusBySession[sessionId] ?? null) + const treeErrorsByPath = useWorkspacePanelStore( + useShallow((state) => getSessionScopedRecord(state.errors.treeBySessionPath, sessionId)), + ) + const setActiveView = useWorkspacePanelStore((state) => state.setActiveView) + const loadStatus = useWorkspacePanelStore((state) => state.loadStatus) + const loadTree = useWorkspacePanelStore((state) => state.loadTree) + const toggleTreeNode = useWorkspacePanelStore((state) => state.toggleTreeNode) + const openPreview = useWorkspacePanelStore((state) => state.openPreview) + const closePreview = useWorkspacePanelStore((state) => state.closePreview) + const closePanel = useWorkspacePanelStore((state) => state.closePanel) + + const rootTree = treeByPath[''] + const rootTreeKey = makeTreeStateKey(sessionId, '') + const rootTreeLoading = treeLoadingByPath[rootTreeKey] ?? false + const rootTreeError = treeErrorsByPath[rootTreeKey] ?? null + const normalizedFilterQuery = normalizeFilterQuery(filterQuery) + const expandedPathSet = new Set(expandedPaths) + const activePreviewTab = + previewTabs.find((tab) => tab.id === activePreviewTabId) ?? previewTabs[previewTabs.length - 1] ?? null + const activeTreePath = activePreviewTab?.kind === 'file' ? activePreviewTab.path : null + const filteredChangedFiles = useMemo( + () => (status?.changedFiles ?? []).filter((file) => changedFileMatchesFilter(file, normalizedFilterQuery)), + [normalizedFilterQuery, status?.changedFiles], + ) + const filteredRootEntries = useMemo( + () => rootTree?.state === 'ok' + ? rootTree.entries.filter((entry) => treeEntryMatchesFilter(entry, normalizedFilterQuery, treeByPath)) + : [], + [normalizedFilterQuery, rootTree, treeByPath], + ) + const activePreviewRequestKey = activePreviewTab + ? makePreviewStateKey(sessionId, activePreviewTab.id) + : null + const activePreviewLoading = useWorkspacePanelStore((state) => + activePreviewRequestKey ? state.loading.previewByTabId[activePreviewRequestKey] ?? false : false, + ) + const activePreviewError = useWorkspacePanelStore((state) => + activePreviewRequestKey ? state.errors.previewByTabId[activePreviewRequestKey] ?? null : null, + ) + + useEffect(() => { + if (!isOpen || activeView !== 'changed' || status || statusLoading || statusError) return + void loadStatus(sessionId) + }, [activeView, isOpen, loadStatus, sessionId, status, statusError, statusLoading]) + + useEffect(() => { + if (!isOpen || activeView !== 'all' || rootTree || rootTreeLoading || rootTreeError) return + void loadTree(sessionId, '') + }, [activeView, isOpen, loadTree, rootTree, rootTreeError, rootTreeLoading, sessionId]) + + if (!isOpen) return null + + const handleRefresh = () => { + void loadStatus(sessionId) + if (activeView === 'all') { + void loadTree(sessionId, '') + } + } + + const handleOpenDiff = (path: string) => { + void openPreview(sessionId, path, 'diff') + } + + const handleOpenFile = (path: string) => { + void openPreview(sessionId, path, 'file') + } + + const handleSetActiveView = (view: 'changed' | 'all') => { + setActiveView(sessionId, view) + setIsViewMenuOpen(false) + } + + const renderChangedView = () => { + if (statusLoading && !status) { + return + } + + if (status?.state === 'missing_workdir') { + return + } + + if (status?.state === 'not_git_repo') { + return + } + + if (statusError || status?.state === 'error') { + return ( + + ) + } + + if (!status) { + return + } + + if (status.changedFiles.length === 0) { + return + } + + if (filteredChangedFiles.length === 0) { + return + } + + return ( +
+ {filteredChangedFiles.map((file) => ( + handleOpenDiff(file.path)} + /> + ))} +
+ ) + } + + const renderAllFilesView = () => { + if (rootTreeLoading && !rootTree) { + return + } + + if (rootTreeError) { + return + } + + if (rootTree?.state === 'missing') { + return + } + + if (rootTree?.state === 'error') { + return + } + + if (!rootTree) { + return + } + + if (rootTree.entries.length === 0) { + return + } + + if (filteredRootEntries.length === 0) { + return + } + + return ( +
+ {filteredRootEntries.map((entry) => ( + { + void toggleTreeNode(sessionId, path) + }} + onOpenFile={handleOpenFile} + activePath={activeTreePath} + /> + ))} +
+ ) + } + + const renderPreviewContent = () => { + if (!activePreviewTab) { + return ( +
+ {t('workspace.previewEmpty')} +
+ ) + } + + const kindLabel = getPreviewKindLabel(t, activePreviewTab.kind) + const state = activePreviewTab.state ?? 'loading' + + return ( + <> +
+ {status?.repoName || 'workspace'} + {activePreviewTab.path.split('/').map((segment, index, segments) => ( + + + + {segment} + + + ))} + + {kindLabel} + +
+ + {activePreviewLoading || state === 'loading' ? ( + + ) : state === 'ok' && activePreviewTab.previewType === 'image' ? ( + + ) : state === 'ok' && activePreviewTab.kind === 'diff' ? ( + + ) : state === 'ok' ? ( + + ) : ( + + )} + + ) + } + + const renderPreviewTabs = () => ( +
+ {previewTabs.length === 0 ? ( +
+ docs + {t('workspace.preview')} +
+ ) : ( + previewTabs.map((tab) => { + const kindLabel = getPreviewKindLabel(t, tab.kind) + const isActive = tab.id === activePreviewTab?.id + + return ( +
+ + +
+ ) + }) + )} +
+ ) + + return ( + + ) +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index ca59f1e5..f1814d22 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -41,6 +41,34 @@ export const en = { 'titlebar.terminal': 'Terminal', 'titlebar.history': 'History', + // ─── Workspace Panel ─────────────────────────────── + 'workspace.changedFiles': 'Changed files', + 'workspace.allFiles': 'All files', + 'workspace.viewTabs': 'Workspace views', + 'workspace.previewTabs': 'Preview tabs', + 'workspace.filterPlaceholder': 'Filter files...', + 'workspace.clearFilter': 'Clear file filter', + 'workspace.refresh': 'Refresh workspace', + 'workspace.closePanel': 'Close workspace panel', + 'workspace.resizePanel': 'Resize workspace panel', + 'workspace.closeTab': 'Close tab', + 'workspace.preview': 'Preview', + 'workspace.previewEmpty': 'Select a file to preview.', + 'workspace.notGitRepo': 'Not a git repository.', + 'workspace.missingWorkdir': 'Working directory is missing.', + 'workspace.loadError': 'Failed to load workspace data.', + 'workspace.noChanges': 'No changes', + 'workspace.noFiles': 'No files', + 'workspace.noMatchingFiles': 'No matching files', + 'workspace.previewKind.diff': 'Diff', + 'workspace.previewKind.file': 'File', + 'workspace.previewState.loading': 'Loading preview...', + 'workspace.previewState.binary': 'Binary file preview is unavailable.', + 'workspace.previewState.tooLarge': 'File is too large to preview.', + 'workspace.previewState.missing': 'File not found.', + 'workspace.imagePreviewUnavailable': 'Image preview is unavailable.', + 'workspace.previewLineLimit': 'Showing first {count} lines. Open in your editor for the full file.', + // ─── Status Bar ────────────────────────────────────── 'status.connected': 'Connected', 'status.connecting': 'Connecting...', @@ -962,6 +990,9 @@ export const en = { 'tabs.closeConfirmMessage': 'This session is still running. What would you like to do?', 'tabs.closeConfirmKeep': 'Keep Running', 'tabs.closeConfirmStop': 'Stop & Close', + 'tabs.openTerminal': 'Open Terminal', + 'tabs.showWorkspace': 'Show Workspace', + 'tabs.hideWorkspace': 'Hide Workspace', } as const export type TranslationKey = keyof typeof en diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 8350dfbb..9e4a1ce1 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -43,6 +43,34 @@ export const zh: Record = { 'titlebar.terminal': '终端', 'titlebar.history': '历史', + // ─── Workspace Panel ─────────────────────────────── + 'workspace.changedFiles': '已更改文件', + 'workspace.allFiles': '所有文件', + 'workspace.viewTabs': '工作区视图', + 'workspace.previewTabs': '预览标签', + 'workspace.filterPlaceholder': '筛选文件...', + 'workspace.clearFilter': '清除文件筛选', + 'workspace.refresh': '刷新工作区', + 'workspace.closePanel': '关闭工作区面板', + 'workspace.resizePanel': '调整工作区宽度', + 'workspace.closeTab': '关闭标签', + 'workspace.preview': '预览', + 'workspace.previewEmpty': '选择一个文件进行预览。', + 'workspace.notGitRepo': '当前目录不是 Git 仓库。', + 'workspace.missingWorkdir': '工作目录不存在。', + 'workspace.loadError': '工作区数据加载失败。', + 'workspace.noChanges': '没有变更', + 'workspace.noFiles': '没有文件', + 'workspace.noMatchingFiles': '没有匹配的文件', + 'workspace.previewKind.diff': 'Diff', + 'workspace.previewKind.file': '文件', + 'workspace.previewState.loading': '正在加载预览...', + 'workspace.previewState.binary': '二进制文件暂不支持预览。', + 'workspace.previewState.tooLarge': '文件过大,无法预览。', + 'workspace.previewState.missing': '文件不存在。', + 'workspace.imagePreviewUnavailable': '图片无法预览。', + 'workspace.previewLineLimit': '仅显示前 {count} 行。完整内容请在编辑器中打开。', + // ─── Status Bar ────────────────────────────────────── 'status.connected': '已连接', 'status.connecting': '连接中...', @@ -964,4 +992,7 @@ export const zh: Record = { 'tabs.closeConfirmMessage': '此会话仍在运行,你想要怎么做?', 'tabs.closeConfirmKeep': '保持运行', 'tabs.closeConfirmStop': '停止并关闭', + 'tabs.openTerminal': '打开终端', + 'tabs.showWorkspace': '显示工作区', + 'tabs.hideWorkspace': '隐藏工作区', } diff --git a/desktop/src/pages/ActiveSession.test.tsx b/desktop/src/pages/ActiveSession.test.tsx index e5b99d4d..18878324 100644 --- a/desktop/src/pages/ActiveSession.test.tsx +++ b/desktop/src/pages/ActiveSession.test.tsx @@ -1,14 +1,18 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { render } from '@testing-library/react' +import { fireEvent, render, screen, within } from '@testing-library/react' import '@testing-library/jest-dom' import { act } from 'react' vi.mock('../components/chat/MessageList', () => ({ - MessageList: () =>
, + MessageList: ({ compact }: { compact?: boolean }) => ( +
+ ), })) vi.mock('../components/chat/ChatInput', () => ({ - ChatInput: () =>
, + ChatInput: ({ compact, variant }: { compact?: boolean; variant?: string }) => ( +
+ ), })) vi.mock('../components/teams/TeamStatusBar', () => ({ @@ -19,12 +23,20 @@ vi.mock('../components/chat/SessionTaskBar', () => ({ SessionTaskBar: () =>
, })) +vi.mock('../components/workspace/WorkspacePanel', () => ({ + WorkspacePanel: ({ sessionId }: { sessionId: string }) => ( +
workspace:{sessionId}
+ ), +})) + import { ActiveSession } from './ActiveSession' import { useChatStore } from '../stores/chatStore' import { useCLITaskStore } from '../stores/cliTaskStore' import { useSessionStore } from '../stores/sessionStore' import { useTabStore } from '../stores/tabStore' import { useTeamStore } from '../stores/teamStore' +import { useWorkspacePanelStore } from '../stores/workspacePanelStore' +import { WORKSPACE_PANEL_DEFAULT_WIDTH } from '../stores/workspacePanelStore' afterEach(() => { vi.useRealTimers() @@ -32,6 +44,7 @@ afterEach(() => { useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) useChatStore.setState({ sessions: {} }) useTeamStore.setState({ teams: [], activeTeam: null, memberColors: new Map(), error: null }) + useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true) }) describe('ActiveSession task polling', () => { @@ -178,4 +191,179 @@ describe('ActiveSession task polling', () => { unmount() useCLITaskStore.setState(originalCliTaskState) }) + + it('renders the workspace panel to the right of chat and supports resizing', () => { + const sessionId = 'workspace-session' + + useSessionStore.setState({ + sessions: [{ + id: sessionId, + title: 'Workspace Session', + createdAt: '2026-04-10T00:00:00.000Z', + modifiedAt: '2026-04-10T00:00:00.000Z', + messageCount: 1, + projectPath: '', + workDir: '/tmp/project', + workDirExists: true, + }], + activeSessionId: sessionId, + isLoading: false, + error: null, + }) + useTabStore.setState({ + tabs: [{ sessionId, title: 'Workspace Session', type: 'session', status: 'idle' }], + activeTabId: sessionId, + }) + useChatStore.setState({ + sessions: { + [sessionId]: { + messages: [{ id: 'msg-1', type: 'assistant_text', content: 'hello', timestamp: 1 }], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + useWorkspacePanelStore.getState().openPanel(sessionId) + + render() + + const contentRow = screen.getByTestId('active-session-content-row') + const chatColumn = screen.getByTestId('active-session-chat-column') + const resizeHandle = screen.getByTestId('workspace-resize-handle') + + expect(within(contentRow).getByTestId('message-list')).toBeInTheDocument() + expect(within(contentRow).getByTestId('message-list')).toHaveAttribute('data-compact', 'true') + expect(within(contentRow).getByTestId('workspace-panel')).toHaveTextContent(`workspace:${sessionId}`) + expect(within(chatColumn).getByTestId('chat-input')).toBeInTheDocument() + expect(within(chatColumn).getByTestId('chat-input')).toHaveAttribute('data-compact', 'true') + expect(chatColumn).toHaveClass('shrink-0') + expect(contentRow.children[0]).toBe(chatColumn) + expect(contentRow.children[1]).toBe(resizeHandle) + expect(contentRow.children[2]).toBe(screen.getByTestId('workspace-panel')) + + act(() => { + fireEvent.keyDown(resizeHandle, { key: 'ArrowLeft' }) + }) + + expect(useWorkspacePanelStore.getState().width).toBe(WORKSPACE_PANEL_DEFAULT_WIDTH + 32) + }) + + it('does not render the workspace panel when closed or for member sessions', () => { + const regularSessionId = 'regular-session' + + useSessionStore.setState({ + sessions: [{ + id: regularSessionId, + title: 'Regular Session', + createdAt: '2026-04-10T00:00:00.000Z', + modifiedAt: '2026-04-10T00:00:00.000Z', + messageCount: 0, + projectPath: '', + workDir: '/tmp/project', + workDirExists: true, + }], + activeSessionId: regularSessionId, + isLoading: false, + error: null, + }) + useTabStore.setState({ + tabs: [{ sessionId: regularSessionId, title: 'Regular Session', type: 'session', status: 'idle' }], + activeTabId: regularSessionId, + }) + useChatStore.setState({ + sessions: { + [regularSessionId]: { + messages: [], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + const { rerender } = render() + expect(screen.queryByTestId('workspace-panel')).not.toBeInTheDocument() + + const memberSessionId = 'team-member:security-reviewer@test-team' + useTeamStore.setState({ + teams: [], + activeTeam: { + name: 'test-team', + leadAgentId: 'team-lead@test-team', + leadSessionId: 'leader-session', + members: [ + { + agentId: 'team-lead@test-team', + role: 'team-lead', + status: 'running', + sessionId: 'leader-session', + }, + { + agentId: 'security-reviewer@test-team', + role: 'security-reviewer', + status: 'running', + }, + ], + }, + memberColors: new Map(), + error: null, + }) + useTabStore.setState({ + tabs: [{ sessionId: memberSessionId, title: 'security-reviewer', type: 'session', status: 'idle' }], + activeTabId: memberSessionId, + }) + useChatStore.setState({ + sessions: { + [memberSessionId]: { + messages: [{ id: 'msg-2', type: 'assistant_text', content: 'hello', timestamp: 1 }], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + useWorkspacePanelStore.getState().openPanel(memberSessionId) + + rerender() + + expect(screen.queryByTestId('workspace-panel')).not.toBeInTheDocument() + expect(screen.getByTestId('message-list')).toBeInTheDocument() + }) }) diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index 0cc18ec4..5e44b114 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -1,20 +1,95 @@ -import { useEffect, useMemo } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useTabStore } from '../stores/tabStore' import { useSessionStore } from '../stores/sessionStore' import { useChatStore } from '../stores/chatStore' import { useCLITaskStore } from '../stores/cliTaskStore' import { useTeamStore } from '../stores/teamStore' +import { useWorkspacePanelStore } from '../stores/workspacePanelStore' import { useTranslation } from '../i18n' import { MessageList } from '../components/chat/MessageList' import { ChatInput } from '../components/chat/ChatInput' import { ComputerUsePermissionModal } from '../components/chat/ComputerUsePermissionModal' -import { TeamStatusBar } from '../components/teams/TeamStatusBar' import { SessionTaskBar } from '../components/chat/SessionTaskBar' +import { WorkspacePanel } from '../components/workspace/WorkspacePanel' +import { TeamStatusBar } from '../components/teams/TeamStatusBar' const TASK_POLL_INTERVAL_MS = 1000 +const WORKSPACE_RESIZE_STEP = 32 +const CHAT_COLUMN_COMPACT_CLASS = + 'w-[clamp(340px,23vw,420px)] min-w-[340px] max-w-[420px] shrink-0 border-r border-[var(--color-border)] bg-[var(--color-surface)]' + +function WorkspaceResizeHandle() { + const t = useTranslation() + const width = useWorkspacePanelStore((state) => state.width) + const setWidth = useWorkspacePanelStore((state) => state.setWidth) + const [dragState, setDragState] = useState<{ startX: number; startWidth: number } | null>(null) + const dragStateRef = useRef(dragState) + + useEffect(() => { + dragStateRef.current = dragState + }, [dragState]) + + useEffect(() => { + if (!dragState) return + + const handlePointerMove = (event: PointerEvent) => { + const current = dragStateRef.current + if (!current) return + setWidth(current.startWidth + current.startX - event.clientX) + } + + const handlePointerUp = () => { + setDragState(null) + } + + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + window.addEventListener('pointermove', handlePointerMove) + window.addEventListener('pointerup', handlePointerUp) + window.addEventListener('pointercancel', handlePointerUp) + + return () => { + document.body.style.cursor = '' + document.body.style.userSelect = '' + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('pointerup', handlePointerUp) + window.removeEventListener('pointercancel', handlePointerUp) + } + }, [dragState, setWidth]) + + return ( +
{ + if (event.button !== 0) return + event.preventDefault() + setDragState({ startX: event.clientX, startWidth: width }) + }} + onKeyDown={(event) => { + if (event.key === 'ArrowLeft') { + event.preventDefault() + setWidth(width + WORKSPACE_RESIZE_STEP) + } + if (event.key === 'ArrowRight') { + event.preventDefault() + setWidth(width - WORKSPACE_RESIZE_STEP) + } + }} + className="group relative z-10 flex w-2 shrink-0 cursor-col-resize items-stretch justify-center bg-[var(--color-surface)] outline-none focus-visible:bg-[var(--color-surface-container)]" + > +
+
+ ) +} export function ActiveSession() { const activeTabId = useTabStore((s) => s.activeTabId) + const activeTabType = useTabStore((s) => s.tabs.find((tab) => tab.sessionId === s.activeTabId)?.type ?? null) const sessions = useSessionStore((s) => s.sessions) const connectToSession = useChatStore((s) => s.connectToSession) const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined) @@ -29,6 +104,11 @@ export function ActiveSession() { const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null) const activeTeam = useTeamStore((s) => s.activeTeam) const isMemberSession = !!memberInfo + const showWorkspacePanel = useWorkspacePanelStore((state) => + activeTabId && activeTabType === 'session' && !isMemberSession + ? state.isPanelOpen(activeTabId) + : false, + ) useEffect(() => { if (activeTabId && !isMemberSession) { @@ -81,132 +161,167 @@ export function ActiveSession() { if (!activeTabId) return null return ( -
- {isMemberSession && ( -
-
-
-
- {memberInfo?.status === 'running' && ( - - )} - {memberInfo?.status === 'completed' && ( - check_circle - )} - smart_toy - - {memberInfo?.role} - - {activeTeam && ( - - @ {activeTeam.name} - - )} -
-

- {t('teams.memberSessionHint')} -

-
- -
-
- )} - - {isEmpty ? ( -
-
- {isMemberSession ? ( - <> - smart_toy -

- {memberInfo?.status === 'running' - ? `${memberInfo.role} ${t('teams.working')}` - : t('teams.noMessages')} -

- - ) : ( - <> - Claude Code Haha -

- {t('empty.title')} -

-

- {t('empty.subtitle')} -

- - )} -
-
- ) : ( - <> - {!isMemberSession && ( -
-
-

- {session?.title || t('session.untitled')} -

-
- {isActive && ( - - - {t('session.active')} - - )} - {totalTokens > 0 && ( - <> - · - {totalTokens.toLocaleString()} t - - )} - {lastUpdated && ( - <> - · - {t('session.lastUpdated', { time: lastUpdated })} - - )} - {session?.messageCount !== undefined && session.messageCount > 0 && ( - <> - · - {t('session.messages', { count: session.messageCount })} - - )} -
- {session?.workDirExists === false && ( -
- warning - - {t('session.workspaceUnavailable', { dir: session.workDir || 'directory no longer exists' })} +
+
+
+ {isMemberSession && ( +
+
+
+
+ {memberInfo?.status === 'running' && ( + + )} + {memberInfo?.status === 'completed' && ( + check_circle + )} + smart_toy + + {memberInfo?.role} + {activeTeam && ( + + @ {activeTeam.name} + + )}
- )} +

+ {t('teams.memberSessionHint')} +

+
+
)} - - - )} + {isEmpty ? ( +
+
+ {isMemberSession ? ( + <> + smart_toy +

+ {memberInfo?.status === 'running' + ? `${memberInfo.role} ${t('teams.working')}` + : t('teams.noMessages')} +

+ + ) : ( + <> + Claude Code Haha +

+ {t('empty.title')} +

+

+ {t('empty.subtitle')} +

+ + )} +
+
+ ) : ( + <> + {!isMemberSession && ( +
+
+

+ {session?.title || t('session.untitled')} +

+
+ {isActive && ( + + + {t('session.active')} + + )} + {totalTokens > 0 && ( + <> + · + {totalTokens.toLocaleString()} t + + )} + {lastUpdated && ( + <> + · + {t('session.lastUpdated', { time: lastUpdated })} + + )} + {!showWorkspacePanel && session?.messageCount !== undefined && session.messageCount > 0 && ( + <> + · + {t('session.messages', { count: session.messageCount })} + + )} +
+ {session?.workDirExists === false && ( +
+ warning + + {t('session.workspaceUnavailable', { dir: session.workDir || 'directory no longer exists' })} + +
+ )} +
+
+ )} - {!isMemberSession && } + + + )} - + {!isMemberSession && } - + + + +
+ + {showWorkspacePanel ? ( + <> + + + + ) : null} +
{!isMemberSession && activeTabId ? ( (factory: () => T) => T +} + +if (typeof (vi as HoistedVi).hoisted !== 'function') { + ;(vi as HoistedVi).hoisted = (factory: () => T) => factory() +} + +const mocks = vi.hoisted(() => ({ + getWorkspaceStatusMock: vi.fn(), + getWorkspaceTreeMock: vi.fn(), + getWorkspaceFileMock: vi.fn(), + getWorkspaceDiffMock: vi.fn(), +})) + +vi.mock('../api/sessions', () => ({ + sessionsApi: { + getWorkspaceStatus: mocks.getWorkspaceStatusMock, + getWorkspaceTree: mocks.getWorkspaceTreeMock, + getWorkspaceFile: mocks.getWorkspaceFileMock, + getWorkspaceDiff: mocks.getWorkspaceDiffMock, + }, +})) + +import { sessionsApi } from '../api/sessions' +import { + WORKSPACE_PANEL_DEFAULT_WIDTH, + WORKSPACE_PANEL_MAX_WIDTH, + WORKSPACE_PANEL_MIN_WIDTH, + getWorkspacePreviewTabId, + useWorkspacePanelStore, +} from './workspacePanelStore' + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +describe('workspacePanelStore', () => { + const initialState = useWorkspacePanelStore.getInitialState() + + beforeEach(() => { + vi.clearAllMocks() + useWorkspacePanelStore.setState(initialState, true) + }) + + afterEach(() => { + useWorkspacePanelStore.setState(initialState, true) + vi.restoreAllMocks() + }) + + it('uses hoisted session api mocks', () => { + expect(sessionsApi.getWorkspaceStatus).toBe(mocks.getWorkspaceStatusMock) + expect(sessionsApi.getWorkspaceTree).toBe(mocks.getWorkspaceTreeMock) + expect(sessionsApi.getWorkspaceFile).toBe(mocks.getWorkspaceFileMock) + expect(sessionsApi.getWorkspaceDiff).toBe(mocks.getWorkspaceDiffMock) + }) + + it('keeps panel open state and active view isolated per session', () => { + const store = useWorkspacePanelStore.getState() + + expect(store.isPanelOpen('session-a')).toBe(false) + expect(store.getActiveView('session-a')).toBe('changed') + expect(store.width).toBe(WORKSPACE_PANEL_DEFAULT_WIDTH) + + store.openPanel('session-a') + store.setActiveView('session-a', 'all') + + expect(useWorkspacePanelStore.getState().isPanelOpen('session-a')).toBe(true) + expect(useWorkspacePanelStore.getState().getActiveView('session-a')).toBe('all') + expect(useWorkspacePanelStore.getState().isPanelOpen('session-b')).toBe(false) + expect(useWorkspacePanelStore.getState().getActiveView('session-b')).toBe('changed') + + store.togglePanel('session-b') + expect(useWorkspacePanelStore.getState().isPanelOpen('session-b')).toBe(true) + expect(useWorkspacePanelStore.getState().isPanelOpen('session-a')).toBe(true) + + store.closePanel('session-a') + expect(useWorkspacePanelStore.getState().isPanelOpen('session-a')).toBe(false) + expect(useWorkspacePanelStore.getState().getActiveView('session-a')).toBe('all') + + store.setWidth(120) + expect(useWorkspacePanelStore.getState().width).toBe(WORKSPACE_PANEL_MIN_WIDTH) + store.setWidth(1200) + expect(useWorkspacePanelStore.getState().width).toBe(WORKSPACE_PANEL_MAX_WIDTH) + }) + + it('loads workspace status successfully', async () => { + mocks.getWorkspaceStatusMock.mockResolvedValue({ + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [ + { + path: 'src/a.ts', + status: 'modified', + additions: 3, + deletions: 1, + }, + ], + }) + + await useWorkspacePanelStore.getState().loadStatus('session-1') + + expect(mocks.getWorkspaceStatusMock).toHaveBeenCalledWith('session-1') + expect(useWorkspacePanelStore.getState().statusBySession['session-1']).toMatchObject({ + branch: 'main', + changedFiles: [{ path: 'src/a.ts' }], + }) + expect(useWorkspacePanelStore.getState().loading.statusBySession['session-1']).toBe(false) + expect(useWorkspacePanelStore.getState().errors.statusBySession['session-1']).toBeNull() + }) + + it('captures workspace status request errors', async () => { + mocks.getWorkspaceStatusMock.mockRejectedValue(new Error('status failed')) + + await useWorkspacePanelStore.getState().loadStatus('session-err') + + expect(useWorkspacePanelStore.getState().statusBySession['session-err']).toBeUndefined() + expect(useWorkspacePanelStore.getState().loading.statusBySession['session-err']).toBe(false) + expect(useWorkspacePanelStore.getState().errors.statusBySession['session-err']).toBe('status failed') + }) + + it('lazily loads tree nodes when expanding and reuses cached results', async () => { + mocks.getWorkspaceTreeMock.mockResolvedValue({ + state: 'ok', + path: '', + entries: [ + { name: 'src', path: 'src', isDirectory: true }, + { name: 'README.md', path: 'README.md', isDirectory: false }, + ], + }) + + await useWorkspacePanelStore.getState().toggleTreeNode('session-tree', '') + + expect(mocks.getWorkspaceTreeMock).toHaveBeenCalledTimes(1) + expect(mocks.getWorkspaceTreeMock).toHaveBeenCalledWith('session-tree', '') + expect(useWorkspacePanelStore.getState().expandedPathsBySession['session-tree']).toEqual(['']) + expect(useWorkspacePanelStore.getState().treeBySessionPath['session-tree']?.['']).toMatchObject({ + entries: [{ path: 'src' }, { path: 'README.md' }], + }) + + await useWorkspacePanelStore.getState().toggleTreeNode('session-tree', '') + expect(useWorkspacePanelStore.getState().expandedPathsBySession['session-tree']).toEqual([]) + + await useWorkspacePanelStore.getState().toggleTreeNode('session-tree', '') + expect(mocks.getWorkspaceTreeMock).toHaveBeenCalledTimes(1) + expect(useWorkspacePanelStore.getState().expandedPathsBySession['session-tree']).toEqual(['']) + }) + + it('ignores stale tree responses for the same session path', async () => { + const first = deferred<{ state: 'ok'; path: string; entries: Array<{ name: string; path: string; isDirectory: boolean }> }>() + const second = deferred<{ state: 'ok'; path: string; entries: Array<{ name: string; path: string; isDirectory: boolean }> }>() + + mocks.getWorkspaceTreeMock + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstLoad = useWorkspacePanelStore.getState().loadTree('session-tree-race', 'src') + const secondLoad = useWorkspacePanelStore.getState().loadTree('session-tree-race', 'src') + + second.resolve({ + state: 'ok', + path: 'src', + entries: [{ name: 'new.ts', path: 'src/new.ts', isDirectory: false }], + }) + await secondLoad + + first.resolve({ + state: 'ok', + path: 'src', + entries: [{ name: 'old.ts', path: 'src/old.ts', isDirectory: false }], + }) + await firstLoad + + expect(useWorkspacePanelStore.getState().treeBySessionPath['session-tree-race']?.src?.entries).toEqual([ + { name: 'new.ts', path: 'src/new.ts', isDirectory: false }, + ]) + }) + + it('opens preview tabs, supports multiple kinds, and reuses duplicates without persistence', async () => { + const storage = typeof globalThis.localStorage === 'undefined' ? null : globalThis.localStorage + const setItemSpy = storage ? vi.spyOn(storage, 'setItem') : null + + mocks.getWorkspaceFileMock.mockResolvedValue({ + state: 'ok', + path: 'src/a.ts', + content: 'export const a = 1', + language: 'typescript', + size: 18, + }) + mocks.getWorkspaceDiffMock.mockResolvedValue({ + state: 'ok', + path: 'src/a.ts', + diff: '@@ -1 +1 @@', + }) + + useWorkspacePanelStore.getState().openPanel('session-preview') + await useWorkspacePanelStore.getState().openPreview('session-preview', 'src/a.ts', 'file') + await useWorkspacePanelStore.getState().openPreview('session-preview', 'src/a.ts', 'diff') + await useWorkspacePanelStore.getState().openPreview('session-preview', 'src/a.ts', 'file') + + expect(mocks.getWorkspaceFileMock).toHaveBeenCalledTimes(1) + expect(mocks.getWorkspaceDiffMock).toHaveBeenCalledTimes(1) + + const tabs = useWorkspacePanelStore.getState().previewTabsBySession['session-preview'] + expect(tabs).toBeDefined() + expect(tabs).toHaveLength(2) + expect(tabs![0]).toMatchObject({ + id: 'file:src/a.ts', + kind: 'file', + path: 'src/a.ts', + content: 'export const a = 1', + language: 'typescript', + }) + expect(tabs![1]).toMatchObject({ + id: 'diff:src/a.ts', + kind: 'diff', + path: 'src/a.ts', + diff: '@@ -1 +1 @@', + }) + expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview']).toBe('file:src/a.ts') + if (setItemSpy) { + expect(setItemSpy).not.toHaveBeenCalled() + } else { + expect(storage).toBeNull() + } + }) + + it('closes exact tab id and preserves sibling preview for the same path', async () => { + mocks.getWorkspaceFileMock.mockResolvedValue({ + state: 'ok', + path: 'src/a.ts', + content: 'export const a = 1', + language: 'typescript', + size: 18, + }) + mocks.getWorkspaceDiffMock.mockResolvedValue({ + state: 'ok', + path: 'src/a.ts', + diff: '@@ -1 +1 @@', + }) + + await useWorkspacePanelStore.getState().openPreview('session-close-path', 'src/a.ts', 'file') + await useWorkspacePanelStore.getState().openPreview('session-close-path', 'src/a.ts', 'diff') + + useWorkspacePanelStore.getState().closePreview('session-close-path', 'diff:src/a.ts') + + expect(useWorkspacePanelStore.getState().previewTabsBySession['session-close-path']).toEqual([ + expect.objectContaining({ id: 'file:src/a.ts' }), + ]) + expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-close-path']).toBe('file:src/a.ts') + }) + + it('chooses the next sensible active preview when closing the current tab', async () => { + mocks.getWorkspaceFileMock + .mockResolvedValueOnce({ + state: 'ok', + path: 'src/a.ts', + content: 'a', + language: 'typescript', + size: 1, + }) + .mockResolvedValueOnce({ + state: 'ok', + path: 'src/b.ts', + content: 'b', + language: 'typescript', + size: 1, + }) + .mockResolvedValueOnce({ + state: 'ok', + path: 'src/c.ts', + content: 'c', + language: 'typescript', + size: 1, + }) + + await useWorkspacePanelStore.getState().openPreview('session-close', 'src/a.ts', 'file') + await useWorkspacePanelStore.getState().openPreview('session-close', 'src/b.ts', 'file') + await useWorkspacePanelStore.getState().openPreview('session-close', 'src/c.ts', 'file') + + useWorkspacePanelStore.getState().closePreview('session-close', 'file:src/b.ts') + + expect(useWorkspacePanelStore.getState().previewTabsBySession['session-close']).toMatchObject([ + { id: 'file:src/a.ts' }, + { id: 'file:src/c.ts' }, + ]) + expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-close']).toBe('file:src/c.ts') + + useWorkspacePanelStore.getState().closePreview('session-close', 'file:src/c.ts') + expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-close']).toBe('file:src/a.ts') + }) + + it('ignores stale status responses for the same session', async () => { + const first = deferred<{ + state: 'ok' + workDir: string + repoName: string | null + branch: string | null + isGitRepo: boolean + changedFiles: [] + error?: string + }>() + const second = deferred<{ + state: 'ok' + workDir: string + repoName: string | null + branch: string | null + isGitRepo: boolean + changedFiles: [] + error?: string + }>() + + mocks.getWorkspaceStatusMock + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstLoad = useWorkspacePanelStore.getState().loadStatus('session-race') + const secondLoad = useWorkspacePanelStore.getState().loadStatus('session-race') + + second.resolve({ + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'new', + isGitRepo: true, + changedFiles: [], + }) + await secondLoad + + first.resolve({ + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'old', + isGitRepo: true, + changedFiles: [], + }) + await firstLoad + + expect(useWorkspacePanelStore.getState().statusBySession['session-race']?.branch).toBe('new') + }) + + it('ignores stale preview responses after close and reopen', async () => { + const first = deferred<{ state: 'ok'; path: string; content: string; language: string; size: number }>() + const second = deferred<{ state: 'ok'; path: string; content: string; language: string; size: number }>() + + mocks.getWorkspaceFileMock + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstOpen = useWorkspacePanelStore.getState().openPreview('session-preview-race', 'src/a.ts', 'file') + useWorkspacePanelStore.getState().closePreview('session-preview-race', 'file:src/a.ts') + const secondOpen = useWorkspacePanelStore.getState().openPreview('session-preview-race', 'src/a.ts', 'file') + + second.resolve({ + state: 'ok', + path: 'src/a.ts', + content: 'new', + language: 'typescript', + size: 3, + }) + await secondOpen + + first.resolve({ + state: 'ok', + path: 'src/a.ts', + content: 'old', + language: 'typescript', + size: 3, + }) + await firstOpen + + expect(useWorkspacePanelStore.getState().previewTabsBySession['session-preview-race']).toEqual([ + expect.objectContaining({ id: 'file:src/a.ts', content: 'new' }), + ]) + }) + + it('does not recreate loading or error state when a loading preview is closed before completion', async () => { + const pending = deferred<{ state: 'ok'; path: string; diff: string }>() + const tabId = getWorkspacePreviewTabId('src/a.ts', 'diff') + const previewKey = `session-preview-close::${tabId}` + + mocks.getWorkspaceDiffMock.mockReturnValueOnce(pending.promise) + + const openPromise = useWorkspacePanelStore.getState().openPreview('session-preview-close', 'src/a.ts', 'diff') + useWorkspacePanelStore.getState().closePreview('session-preview-close', tabId) + + pending.resolve({ + state: 'ok', + path: 'src/a.ts', + diff: '@@ -1 +1 @@', + }) + await openPromise + + expect(useWorkspacePanelStore.getState().previewTabsBySession['session-preview-close']).toBeUndefined() + expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview-close']).toBeNull() + expect(useWorkspacePanelStore.getState().loading.previewByTabId[previewKey]).toBeUndefined() + expect(useWorkspacePanelStore.getState().errors.previewByTabId[previewKey]).toBeUndefined() + }) + + it('clears session UI and cached data for clearSession and resetSessionUi', () => { + useWorkspacePanelStore.setState((state) => ({ + ...state, + panelBySession: { + 'session-clear': { isOpen: true, activeView: 'all' }, + 'session-reset': { isOpen: true, activeView: 'all' }, + }, + statusBySession: { + 'session-clear': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + 'session-reset': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + expandedPathsBySession: { + 'session-clear': ['src'], + 'session-reset': ['src'], + }, + treeBySessionPath: { + 'session-clear': { + src: { state: 'ok', path: 'src', entries: [] }, + }, + 'session-reset': { + src: { state: 'ok', path: 'src', entries: [] }, + }, + }, + previewTabsBySession: { + 'session-clear': [ + { id: 'file:src/a.ts', path: 'src/a.ts', kind: 'file', title: 'a.ts' }, + ], + 'session-reset': [ + { id: 'file:src/b.ts', path: 'src/b.ts', kind: 'file', title: 'b.ts' }, + ], + }, + activePreviewTabIdBySession: { + 'session-clear': 'file:src/a.ts', + 'session-reset': 'file:src/b.ts', + }, + loading: { + statusBySession: { + 'session-clear': true, + 'session-reset': true, + }, + treeBySessionPath: { + 'session-clear::src': true, + 'session-reset::src': true, + }, + previewByTabId: { + 'session-clear::file:src/a.ts': true, + 'session-reset::file:src/b.ts': true, + }, + }, + errors: { + statusBySession: { + 'session-clear': 'bad', + 'session-reset': 'bad', + }, + treeBySessionPath: { + 'session-clear::src': 'bad', + 'session-reset::src': 'bad', + }, + previewByTabId: { + 'session-clear::file:src/a.ts': 'bad', + 'session-reset::file:src/b.ts': 'bad', + }, + }, + })) + + useWorkspacePanelStore.getState().clearSession('session-clear') + useWorkspacePanelStore.getState().resetSessionUi('session-reset') + + const state = useWorkspacePanelStore.getState() + expect(state.panelBySession['session-clear']).toBeUndefined() + expect(state.panelBySession['session-reset']).toBeUndefined() + expect(state.statusBySession['session-clear']).toBeUndefined() + expect(state.statusBySession['session-reset']).toBeUndefined() + expect(state.expandedPathsBySession['session-clear']).toBeUndefined() + expect(state.expandedPathsBySession['session-reset']).toBeUndefined() + expect(state.treeBySessionPath['session-clear']).toBeUndefined() + expect(state.treeBySessionPath['session-reset']).toBeUndefined() + expect(state.previewTabsBySession['session-clear']).toBeUndefined() + expect(state.previewTabsBySession['session-reset']).toBeUndefined() + expect(state.activePreviewTabIdBySession['session-clear']).toBeUndefined() + expect(state.activePreviewTabIdBySession['session-reset']).toBeUndefined() + expect(state.loading.statusBySession['session-clear']).toBeUndefined() + expect(state.loading.statusBySession['session-reset']).toBeUndefined() + expect(state.loading.treeBySessionPath['session-clear::src']).toBeUndefined() + expect(state.loading.treeBySessionPath['session-reset::src']).toBeUndefined() + expect(state.loading.previewByTabId['session-clear::file:src/a.ts']).toBeUndefined() + expect(state.loading.previewByTabId['session-reset::file:src/b.ts']).toBeUndefined() + expect(state.errors.statusBySession['session-clear']).toBeUndefined() + expect(state.errors.statusBySession['session-reset']).toBeUndefined() + expect(state.errors.treeBySessionPath['session-clear::src']).toBeUndefined() + expect(state.errors.treeBySessionPath['session-reset::src']).toBeUndefined() + expect(state.errors.previewByTabId['session-clear::file:src/a.ts']).toBeUndefined() + expect(state.errors.previewByTabId['session-reset::file:src/b.ts']).toBeUndefined() + }) +}) diff --git a/desktop/src/stores/workspacePanelStore.ts b/desktop/src/stores/workspacePanelStore.ts new file mode 100644 index 00000000..279ec1d8 --- /dev/null +++ b/desktop/src/stores/workspacePanelStore.ts @@ -0,0 +1,653 @@ +import { create } from 'zustand' +import { + sessionsApi, + type WorkspaceDiffResult, + type WorkspaceReadFileResult, + type WorkspaceStatusResult, + type WorkspaceTreeResult, +} from '../api/sessions' + +export const WORKSPACE_PANEL_DEFAULT_WIDTH = 860 +export const WORKSPACE_PANEL_MIN_WIDTH = 640 +export const WORKSPACE_PANEL_MAX_WIDTH = 1120 + +export type WorkspacePanelView = 'changed' | 'all' +export type WorkspacePreviewKind = 'file' | 'diff' +export type WorkspacePreviewState = + | 'loading' + | WorkspaceReadFileResult['state'] + | WorkspaceDiffResult['state'] + +export type WorkspacePreviewTab = { + id: string + path: string + kind: WorkspacePreviewKind + title: string + language?: string + content?: string + dataUrl?: string + mimeType?: string + previewType?: 'text' | 'image' + diff?: string + state?: WorkspacePreviewState + error?: string + size?: number +} + +export type WorkspacePanelSessionState = { + isOpen: boolean + activeView: WorkspacePanelView +} + +type WorkspacePanelLoadingState = { + statusBySession: Record + treeBySessionPath: Record + previewByTabId: Record +} + +type WorkspacePanelErrorState = { + statusBySession: Record + treeBySessionPath: Record + previewByTabId: Record +} + +type WorkspacePanelStore = { + panelBySession: Record + width: number + statusBySession: Record + expandedPathsBySession: Record + treeBySessionPath: Record | undefined> + previewTabsBySession: Record + activePreviewTabIdBySession: Record + loading: WorkspacePanelLoadingState + errors: WorkspacePanelErrorState + + isPanelOpen: (sessionId: string) => boolean + getActiveView: (sessionId: string) => WorkspacePanelView + openPanel: (sessionId: string) => void + closePanel: (sessionId: string) => void + togglePanel: (sessionId: string) => void + setWidth: (width: number) => void + setActiveView: (sessionId: string, view: WorkspacePanelView) => void + loadStatus: (sessionId: string) => Promise + loadTree: (sessionId: string, path?: string) => Promise + toggleTreeNode: (sessionId: string, path: string) => Promise + openPreview: (sessionId: string, path: string, kind: WorkspacePreviewKind) => Promise + closePreview: (sessionId: string, tabId: string) => void + clearSession: (sessionId: string) => void + resetSessionUi: (sessionId: string) => void +} + +const DEFAULT_PANEL_STATE: WorkspacePanelSessionState = { + isOpen: false, + activeView: 'changed', +} + +const statusRequestIds = new Map() +const treeRequestIds = new Map() +const previewRequestIds = new Map() + +function nextRequestId(store: Map, key: string) { + const requestId = (store.get(key) ?? 0) + 1 + store.set(key, requestId) + return requestId +} + +function invalidateRequest(store: Map, key: string) { + store.set(key, (store.get(key) ?? 0) + 1) +} + +function isLatestRequest(store: Map, key: string, requestId: number) { + return store.get(key) === requestId +} + +export function clampWorkspacePanelWidth(width: number) { + if (!Number.isFinite(width)) return WORKSPACE_PANEL_DEFAULT_WIDTH + const rounded = Math.round(width) + return Math.min(WORKSPACE_PANEL_MAX_WIDTH, Math.max(WORKSPACE_PANEL_MIN_WIDTH, rounded)) +} + +function getSessionPanelState( + panelBySession: Record, + sessionId: string, +) { + return panelBySession[sessionId] ?? DEFAULT_PANEL_STATE +} + +function makeTreeKey(sessionId: string, path: string) { + return `${sessionId}::${path}` +} + +export function getWorkspacePreviewTabId(path: string, kind: WorkspacePreviewKind) { + return `${kind}:${path}` +} + +function makePreviewKey(sessionId: string, tabId: string) { + return `${sessionId}::${tabId}` +} + +function getPathTitle(path: string) { + if (!path) return 'Workspace' + const segments = path.split('/').filter(Boolean) + return segments[segments.length - 1] ?? path +} + +function stripSessionKeys(record: Record, sessionId: string) { + const prefix = `${sessionId}::` + return Object.fromEntries( + Object.entries(record).filter(([key]) => !key.startsWith(prefix)), + ) as Record +} + +function removeRecordKey(record: Record, key: string) { + if (!(key in record)) return record + const { [key]: _removed, ...rest } = record + return rest +} + +function invalidateSessionScopedRequests(store: Map, sessionId: string) { + const prefix = `${sessionId}::` + for (const key of store.keys()) { + if (key.startsWith(prefix)) { + invalidateRequest(store, key) + } + } +} + +function upsertPreviewTab( + tabs: WorkspacePreviewTab[], + tabId: string, + update: WorkspacePreviewTab | ((current: WorkspacePreviewTab) => WorkspacePreviewTab), +) { + const index = tabs.findIndex((tab) => tab.id === tabId) + if (index < 0) return tabs + + const current = tabs[index]! + const next = typeof update === 'function' ? update(current) : update + const nextTabs = [...tabs] + nextTabs[index] = next + return nextTabs +} + +export const useWorkspacePanelStore = create((set, get) => ({ + panelBySession: {}, + width: WORKSPACE_PANEL_DEFAULT_WIDTH, + statusBySession: {}, + expandedPathsBySession: {}, + treeBySessionPath: {}, + previewTabsBySession: {}, + activePreviewTabIdBySession: {}, + loading: { + statusBySession: {}, + treeBySessionPath: {}, + previewByTabId: {}, + }, + errors: { + statusBySession: {}, + treeBySessionPath: {}, + previewByTabId: {}, + }, + + isPanelOpen: (sessionId) => getSessionPanelState(get().panelBySession, sessionId).isOpen, + getActiveView: (sessionId) => getSessionPanelState(get().panelBySession, sessionId).activeView, + + openPanel: (sessionId) => + set((state) => ({ + panelBySession: { + ...state.panelBySession, + [sessionId]: { + ...getSessionPanelState(state.panelBySession, sessionId), + isOpen: true, + }, + }, + })), + + closePanel: (sessionId) => + set((state) => ({ + panelBySession: { + ...state.panelBySession, + [sessionId]: { + ...getSessionPanelState(state.panelBySession, sessionId), + isOpen: false, + }, + }, + })), + + togglePanel: (sessionId) => + set((state) => { + const panel = getSessionPanelState(state.panelBySession, sessionId) + return { + panelBySession: { + ...state.panelBySession, + [sessionId]: { + ...panel, + isOpen: !panel.isOpen, + }, + }, + } + }), + + setWidth: (width) => set({ width: clampWorkspacePanelWidth(width) }), + + setActiveView: (sessionId, view) => + set((state) => ({ + panelBySession: { + ...state.panelBySession, + [sessionId]: { + ...getSessionPanelState(state.panelBySession, sessionId), + activeView: view, + }, + }, + })), + + loadStatus: async (sessionId) => { + const requestId = nextRequestId(statusRequestIds, sessionId) + + set((state) => ({ + loading: { + ...state.loading, + statusBySession: { + ...state.loading.statusBySession, + [sessionId]: true, + }, + }, + errors: { + ...state.errors, + statusBySession: { + ...state.errors.statusBySession, + [sessionId]: null, + }, + }, + })) + + try { + const result = await sessionsApi.getWorkspaceStatus(sessionId) + if (!isLatestRequest(statusRequestIds, sessionId, requestId)) return + + set((state) => ({ + statusBySession: { + ...state.statusBySession, + [sessionId]: result, + }, + loading: { + ...state.loading, + statusBySession: { + ...state.loading.statusBySession, + [sessionId]: false, + }, + }, + errors: { + ...state.errors, + statusBySession: { + ...state.errors.statusBySession, + [sessionId]: result.error ?? null, + }, + }, + })) + } catch (error) { + if (!isLatestRequest(statusRequestIds, sessionId, requestId)) return + + set((state) => ({ + loading: { + ...state.loading, + statusBySession: { + ...state.loading.statusBySession, + [sessionId]: false, + }, + }, + errors: { + ...state.errors, + statusBySession: { + ...state.errors.statusBySession, + [sessionId]: error instanceof Error ? error.message : 'Failed to load workspace status', + }, + }, + })) + } + }, + + loadTree: async (sessionId, path = '') => { + const treeKey = makeTreeKey(sessionId, path) + const requestId = nextRequestId(treeRequestIds, treeKey) + + set((state) => ({ + loading: { + ...state.loading, + treeBySessionPath: { + ...state.loading.treeBySessionPath, + [treeKey]: true, + }, + }, + errors: { + ...state.errors, + treeBySessionPath: { + ...state.errors.treeBySessionPath, + [treeKey]: null, + }, + }, + })) + + try { + const result = await sessionsApi.getWorkspaceTree(sessionId, path) + if (!isLatestRequest(treeRequestIds, treeKey, requestId)) return + + set((state) => ({ + treeBySessionPath: { + ...state.treeBySessionPath, + [sessionId]: { + ...state.treeBySessionPath[sessionId], + [path]: result, + }, + }, + loading: { + ...state.loading, + treeBySessionPath: { + ...state.loading.treeBySessionPath, + [treeKey]: false, + }, + }, + errors: { + ...state.errors, + treeBySessionPath: { + ...state.errors.treeBySessionPath, + [treeKey]: result.error ?? null, + }, + }, + })) + } catch (error) { + if (!isLatestRequest(treeRequestIds, treeKey, requestId)) return + + set((state) => ({ + loading: { + ...state.loading, + treeBySessionPath: { + ...state.loading.treeBySessionPath, + [treeKey]: false, + }, + }, + errors: { + ...state.errors, + treeBySessionPath: { + ...state.errors.treeBySessionPath, + [treeKey]: error instanceof Error ? error.message : 'Failed to load workspace tree', + }, + }, + })) + } + }, + + toggleTreeNode: async (sessionId, path) => { + let shouldLoad = false + + set((state) => { + const expanded = new Set(state.expandedPathsBySession[sessionId] ?? []) + if (expanded.has(path)) { + expanded.delete(path) + } else { + expanded.add(path) + if (!state.treeBySessionPath[sessionId]?.[path]) { + shouldLoad = true + } + } + + return { + expandedPathsBySession: { + ...state.expandedPathsBySession, + [sessionId]: [...expanded], + }, + } + }) + + if (shouldLoad) { + await get().loadTree(sessionId, path) + } + }, + + openPreview: async (sessionId, path, kind) => { + const tabId = getWorkspacePreviewTabId(path, kind) + const requestKey = makePreviewKey(sessionId, tabId) + const existing = get().previewTabsBySession[sessionId]?.find((tab) => tab.id === tabId) + + if (existing) { + set((state) => ({ + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + [sessionId]: tabId, + }, + })) + return + } + + const requestId = nextRequestId(previewRequestIds, requestKey) + const baseTab: WorkspacePreviewTab = { + id: tabId, + path, + kind, + title: getPathTitle(path), + state: 'loading', + } + + set((state) => ({ + previewTabsBySession: { + ...state.previewTabsBySession, + [sessionId]: [...(state.previewTabsBySession[sessionId] ?? []), baseTab], + }, + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + [sessionId]: tabId, + }, + loading: { + ...state.loading, + previewByTabId: { + ...state.loading.previewByTabId, + [requestKey]: true, + }, + }, + errors: { + ...state.errors, + previewByTabId: { + ...state.errors.previewByTabId, + [requestKey]: null, + }, + }, + })) + + try { + if (kind === 'diff') { + const result = await sessionsApi.getWorkspaceDiff(sessionId, path) + if (!isLatestRequest(previewRequestIds, requestKey, requestId)) return + if (!get().previewTabsBySession[sessionId]?.some((tab) => tab.id === tabId)) return + + set((state) => { + const tabs = state.previewTabsBySession[sessionId] ?? [] + return { + previewTabsBySession: { + ...state.previewTabsBySession, + [sessionId]: upsertPreviewTab(tabs, tabId, (current) => ({ + ...current, + diff: result.diff ?? '', + content: undefined, + language: undefined, + size: undefined, + state: result.state, + error: result.error, + })), + }, + loading: { + ...state.loading, + previewByTabId: { + ...state.loading.previewByTabId, + [requestKey]: false, + }, + }, + errors: { + ...state.errors, + previewByTabId: { + ...state.errors.previewByTabId, + [requestKey]: result.error ?? null, + }, + }, + } + }) + return + } + + const result = await sessionsApi.getWorkspaceFile(sessionId, path) + if (!isLatestRequest(previewRequestIds, requestKey, requestId)) return + if (!get().previewTabsBySession[sessionId]?.some((tab) => tab.id === tabId)) return + + set((state) => { + const tabs = state.previewTabsBySession[sessionId] ?? [] + return { + previewTabsBySession: { + ...state.previewTabsBySession, + [sessionId]: upsertPreviewTab(tabs, tabId, (current) => ({ + ...current, + content: result.content, + dataUrl: result.dataUrl, + mimeType: result.mimeType, + previewType: result.previewType ?? 'text', + diff: undefined, + language: result.language, + size: result.size, + state: result.state, + error: result.error, + })), + }, + loading: { + ...state.loading, + previewByTabId: { + ...state.loading.previewByTabId, + [requestKey]: false, + }, + }, + errors: { + ...state.errors, + previewByTabId: { + ...state.errors.previewByTabId, + [requestKey]: result.error ?? null, + }, + }, + } + }) + } catch (error) { + if (!isLatestRequest(previewRequestIds, requestKey, requestId)) return + if (!get().previewTabsBySession[sessionId]?.some((tab) => tab.id === tabId)) return + + set((state) => { + const tabs = state.previewTabsBySession[sessionId] ?? [] + const message = error instanceof Error ? error.message : 'Failed to load workspace preview' + + return { + previewTabsBySession: { + ...state.previewTabsBySession, + [sessionId]: upsertPreviewTab(tabs, tabId, (current) => ({ + ...current, + state: 'error', + error: message, + })), + }, + loading: { + ...state.loading, + previewByTabId: { + ...state.loading.previewByTabId, + [requestKey]: false, + }, + }, + errors: { + ...state.errors, + previewByTabId: { + ...state.errors.previewByTabId, + [requestKey]: message, + }, + }, + } + }) + } + }, + + closePreview: (sessionId, tabId) => { + const requestKey = makePreviewKey(sessionId, tabId) + invalidateRequest(previewRequestIds, requestKey) + + set((state) => { + const tabs = state.previewTabsBySession[sessionId] ?? [] + const index = tabs.findIndex((tab) => tab.id === tabId) + if (index < 0) { + return { + loading: { + ...state.loading, + previewByTabId: removeRecordKey(state.loading.previewByTabId, requestKey), + }, + errors: { + ...state.errors, + previewByTabId: removeRecordKey(state.errors.previewByTabId, requestKey), + }, + } + } + + const closingTab = tabs[index]! + const nextTabs = tabs.filter((tab) => tab.id !== closingTab.id) + const activeTabId = state.activePreviewTabIdBySession[sessionId] ?? null + + let nextActiveTabId = activeTabId + if (activeTabId === closingTab.id) { + if (nextTabs.length === 0) { + nextActiveTabId = null + } else if (index >= nextTabs.length) { + nextActiveTabId = nextTabs[nextTabs.length - 1]!.id + } else { + nextActiveTabId = nextTabs[index]!.id + } + } + + return { + previewTabsBySession: { + ...state.previewTabsBySession, + [sessionId]: nextTabs.length > 0 ? nextTabs : undefined, + }, + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + [sessionId]: nextActiveTabId, + }, + loading: { + ...state.loading, + previewByTabId: removeRecordKey(state.loading.previewByTabId, requestKey), + }, + errors: { + ...state.errors, + previewByTabId: removeRecordKey(state.errors.previewByTabId, requestKey), + }, + } + }) + }, + + clearSession: (sessionId) => { + invalidateRequest(statusRequestIds, sessionId) + invalidateSessionScopedRequests(treeRequestIds, sessionId) + invalidateSessionScopedRequests(previewRequestIds, sessionId) + + set((state) => ({ + panelBySession: removeRecordKey(state.panelBySession, sessionId), + statusBySession: removeRecordKey(state.statusBySession, sessionId), + expandedPathsBySession: removeRecordKey(state.expandedPathsBySession, sessionId), + treeBySessionPath: removeRecordKey(state.treeBySessionPath, sessionId), + previewTabsBySession: removeRecordKey(state.previewTabsBySession, sessionId), + activePreviewTabIdBySession: removeRecordKey(state.activePreviewTabIdBySession, sessionId), + loading: { + statusBySession: removeRecordKey(state.loading.statusBySession, sessionId), + treeBySessionPath: stripSessionKeys(state.loading.treeBySessionPath, sessionId), + previewByTabId: stripSessionKeys(state.loading.previewByTabId, sessionId), + }, + errors: { + statusBySession: removeRecordKey(state.errors.statusBySession, sessionId), + treeBySessionPath: stripSessionKeys(state.errors.treeBySessionPath, sessionId), + previewByTabId: stripSessionKeys(state.errors.previewByTabId, sessionId), + }, + })) + }, + + resetSessionUi: (sessionId) => { + get().clearSession(sessionId) + }, +})) diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index 7738fcb1..6789913a 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test' import * as fs from 'node:fs/promises' +import { execFileSync } from 'node:child_process' import * as path from 'node:path' import * as os from 'node:os' import { SessionService } from '../services/sessionService.js' @@ -75,6 +76,34 @@ async function writeSkill( ) } +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + }) +} + +async function createWorkspaceApiGitRepo(baseDir: string): Promise { + const workDir = path.join( + baseDir, + `workspace-api-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ) + + await fs.mkdir(path.join(workDir, 'src'), { recursive: true }) + git(workDir, 'init') + git(workDir, 'config', 'user.email', 'sessions-api@example.com') + git(workDir, 'config', 'user.name', 'Sessions API') + + await fs.writeFile(path.join(workDir, 'tracked.txt'), 'before\n') + await fs.writeFile(path.join(workDir, 'src', 'app.ts'), 'export const answer = 42\n') + git(workDir, 'add', 'tracked.txt', 'src/app.ts') + git(workDir, 'commit', '-m', 'initial') + + await fs.writeFile(path.join(workDir, 'tracked.txt'), 'before\nafter\n') + + return workDir +} + // Sample entries matching real CLI format function makeSnapshotEntry(): Record { return { @@ -136,6 +165,31 @@ function makeAssistantEntry(content: string, parentUuid?: string): Record }>, + parentUuid?: string, +): Record { + return { + parentUuid: parentUuid || null, + isSidechain: false, + type: 'assistant', + message: { + model: 'claude-opus-4-7', + id: `msg_${crypto.randomUUID().slice(0, 20)}`, + type: 'message', + role: 'assistant', + content: toolUses.map((toolUse) => ({ + type: 'tool_use', + id: toolUse.id, + name: toolUse.name, + input: toolUse.input, + })), + }, + uuid: crypto.randomUUID(), + timestamp: '2026-01-01T00:02:00.000Z', + } +} + function makeMetaUserEntry(): Record { return { parentUuid: null, @@ -930,6 +984,301 @@ describe('Sessions API', () => { ) }) + it('GET /api/sessions/:id/workspace/status|tree|file|diff should return workspace data', async () => { + const workDir = await createWorkspaceApiGitRepo(tmpDir) + const { sessionId } = await service.createSession(workDir) + + const statusRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/status`) + expect(statusRes.status).toBe(200) + const statusBody = await statusRes.json() as { + state: string + workDir: string + changedFiles: Array<{ path: string; status: string }> + isGitRepo: boolean + } + expect(statusBody.state).toBe('ok') + expect(statusBody.workDir).toBe(workDir) + expect(statusBody.isGitRepo).toBe(true) + expect(statusBody.changedFiles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: 'tracked.txt', status: 'modified' }), + ]), + ) + + const treeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/tree`) + expect(treeRes.status).toBe(200) + const treeBody = await treeRes.json() as { + state: string + path: string + entries: Array<{ name: string; path: string; isDirectory: boolean }> + } + expect(treeBody).toMatchObject({ + state: 'ok', + path: '', + }) + expect(treeBody.entries).toEqual([ + { name: 'src', path: 'src', isDirectory: true }, + { name: 'tracked.txt', path: 'tracked.txt', isDirectory: false }, + ]) + + const fileRes = await fetch( + `${baseUrl}/api/sessions/${sessionId}/workspace/file?path=${encodeURIComponent('src/app.ts')}`, + ) + expect(fileRes.status).toBe(200) + const fileBody = await fileRes.json() as { + state: string + path: string + content?: string + language: string + size: number + } + expect(fileBody).toMatchObject({ + state: 'ok', + path: 'src/app.ts', + language: 'typescript', + size: 25, + content: 'export const answer = 42\n', + }) + + const diffRes = await fetch( + `${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('tracked.txt')}`, + ) + expect(diffRes.status).toBe(200) + const diffBody = await diffRes.json() as { + state: string + path: string + diff?: string + } + expect(diffBody.state).toBe('ok') + expect(diffBody.path).toBe('tracked.txt') + expect(diffBody.diff).toContain('tracked.txt') + }) + + it('GET /api/sessions/:id/workspace/* should surface transcript changes for a non-git tmp session', async () => { + const sessionId = 'bbbbbbbb-cccc-dddd-eeee-ffffffffffff' + const workDir = await fs.mkdtemp(path.join(tmpDir, 'workspace-api-non-git-')) + const srcDir = path.join(workDir, 'src') + const notesDir = path.join(workDir, 'notes') + const assetsDir = path.join(workDir, 'assets') + + await fs.mkdir(srcDir, { recursive: true }) + await fs.mkdir(notesDir, { recursive: true }) + await fs.mkdir(assetsDir, { recursive: true }) + await fs.writeFile(path.join(workDir, 'README.md'), '# Temporary project\n') + await fs.writeFile(path.join(srcDir, 'app.ts'), 'export const answer = 2\n') + await fs.writeFile(path.join(notesDir, 'todo.md'), '- ship workspace panel\n') + await fs.writeFile( + path.join(assetsDir, 'pixel.png'), + Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', + 'base64', + ), + ) + + await writeSessionFile(sanitizePath(workDir), sessionId, [ + makeSnapshotEntry(), + makeSessionMetaEntry(workDir), + makeUserEntry('Update this temporary project'), + makeAssistantToolUseEntry([ + { + id: 'toolu-edit-app', + name: 'Edit', + input: { + file_path: path.join(workDir, 'src', 'app.ts'), + old_string: 'export const answer = 1\n', + new_string: 'export const answer = 2\n', + }, + }, + { + id: 'toolu-write-todo', + name: 'Write', + input: { + file_path: path.join(workDir, 'notes', 'todo.md'), + content: '- ship workspace panel\n', + }, + }, + ]), + ]) + + const statusRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/status`) + expect(statusRes.status).toBe(200) + const statusBody = await statusRes.json() as { + state: string + workDir: string + repoName: string | null + branch: string | null + isGitRepo: boolean + changedFiles: Array<{ + path: string + status: string + additions: number + deletions: number + }> + } + expect(statusBody).toMatchObject({ + state: 'ok', + workDir, + repoName: path.basename(workDir), + branch: null, + isGitRepo: false, + }) + expect(statusBody.changedFiles).toEqual([ + expect.objectContaining({ + path: 'notes/todo.md', + status: 'added', + additions: 1, + deletions: 0, + }), + expect.objectContaining({ + path: 'src/app.ts', + status: 'modified', + additions: 1, + deletions: 1, + }), + ]) + + const treeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/tree`) + expect(treeRes.status).toBe(200) + const treeBody = await treeRes.json() as { + state: string + path: string + entries: Array<{ name: string; path: string; isDirectory: boolean }> + } + expect(treeBody).toMatchObject({ state: 'ok', path: '' }) + expect(treeBody.entries).toEqual([ + { name: 'assets', path: 'assets', isDirectory: true }, + { name: 'notes', path: 'notes', isDirectory: true }, + { name: 'src', path: 'src', isDirectory: true }, + { name: 'README.md', path: 'README.md', isDirectory: false }, + ]) + + const srcTreeRes = await fetch( + `${baseUrl}/api/sessions/${sessionId}/workspace/tree?path=${encodeURIComponent('src')}`, + ) + expect(srcTreeRes.status).toBe(200) + expect(await srcTreeRes.json()).toMatchObject({ + state: 'ok', + path: 'src', + entries: [{ name: 'app.ts', path: 'src/app.ts', isDirectory: false }], + }) + + const fileRes = await fetch( + `${baseUrl}/api/sessions/${sessionId}/workspace/file?path=${encodeURIComponent('src/app.ts')}`, + ) + expect(fileRes.status).toBe(200) + expect(await fileRes.json()).toMatchObject({ + state: 'ok', + path: 'src/app.ts', + previewType: 'text', + language: 'typescript', + content: 'export const answer = 2\n', + }) + + const imageRes = await fetch( + `${baseUrl}/api/sessions/${sessionId}/workspace/file?path=${encodeURIComponent('assets/pixel.png')}`, + ) + expect(imageRes.status).toBe(200) + const imageBody = await imageRes.json() as { + state: string + path: string + previewType: string + mimeType: string + dataUrl: string + } + expect(imageBody).toMatchObject({ + state: 'ok', + path: 'assets/pixel.png', + previewType: 'image', + mimeType: 'image/png', + }) + expect(imageBody.dataUrl).toStartWith('data:image/png;base64,') + + const appDiffRes = await fetch( + `${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('src/app.ts')}`, + ) + expect(appDiffRes.status).toBe(200) + const appDiffBody = await appDiffRes.json() as { state: string; path: string; diff?: string } + expect(appDiffBody).toMatchObject({ state: 'ok', path: 'src/app.ts' }) + expect(appDiffBody.diff).toContain('diff --session a/src/app.ts b/src/app.ts') + expect(appDiffBody.diff).toContain('-export const answer = 1') + expect(appDiffBody.diff).toContain('+export const answer = 2') + + const todoDiffRes = await fetch( + `${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('notes/todo.md')}`, + ) + expect(todoDiffRes.status).toBe(200) + const todoDiffBody = await todoDiffRes.json() as { state: string; path: string; diff?: string } + expect(todoDiffBody).toMatchObject({ state: 'ok', path: 'notes/todo.md' }) + expect(todoDiffBody.diff).toContain('--- /dev/null') + expect(todoDiffBody.diff).toContain('+++ b/notes/todo.md') + expect(todoDiffBody.diff).toContain('+- ship workspace panel') + }) + + it('GET /api/sessions/:id/workspace/file and diff should require a path query', async () => { + const workDir = await createWorkspaceApiGitRepo(tmpDir) + const { sessionId } = await service.createSession(workDir) + + for (const route of ['file', 'diff']) { + const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/${route}`) + expect(res.status).toBe(400) + expect(await res.json()).toMatchObject({ + error: 'BAD_REQUEST', + }) + } + }) + + it('GET /api/sessions/:id/workspace/file and tree should reject traversal with 403', async () => { + const workDir = await createWorkspaceApiGitRepo(tmpDir) + const { sessionId } = await service.createSession(workDir) + + for (const route of ['file', 'tree']) { + const res = await fetch( + `${baseUrl}/api/sessions/${sessionId}/workspace/${route}?path=${encodeURIComponent('../outside.txt')}`, + ) + expect(res.status).toBe(403) + expect(await res.json()).toMatchObject({ + error: 'FORBIDDEN', + }) + } + }) + + it('GET /api/sessions/:id/workspace/diff should reject traversal with 403', async () => { + const workDir = await createWorkspaceApiGitRepo(tmpDir) + const { sessionId } = await service.createSession(workDir) + + const res = await fetch( + `${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('../outside.txt')}`, + ) + expect(res.status).toBe(403) + expect(await res.json()).toMatchObject({ + error: 'FORBIDDEN', + }) + }) + + it('GET /api/sessions/:id/workspace/status should 404 for unknown sessions', async () => { + const res = await fetch( + `${baseUrl}/api/sessions/00000000-0000-0000-0000-000000000000/workspace/status`, + ) + expect(res.status).toBe(404) + expect(await res.json()).toMatchObject({ + error: 'NOT_FOUND', + }) + }) + + it('non-GET workspace routes should return 405', async () => { + const workDir = await createWorkspaceApiGitRepo(tmpDir) + const { sessionId } = await service.createSession(workDir) + + const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/status`, { + method: 'POST', + }) + + expect(res.status).toBe(405) + expect(await res.json()).toMatchObject({ + error: 'METHOD_NOT_ALLOWED', + }) + }) + it('POST /api/sessions/:id/rewind should preview and trim the active conversation chain', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const firstUserId = crypto.randomUUID() diff --git a/src/server/__tests__/workspace-service.test.ts b/src/server/__tests__/workspace-service.test.ts new file mode 100644 index 00000000..5846b8d1 --- /dev/null +++ b/src/server/__tests__/workspace-service.test.ts @@ -0,0 +1,444 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import * as fs from 'node:fs/promises' +import { execFileSync } from 'node:child_process' +import * as os from 'node:os' +import * as path from 'node:path' +import { WorkspaceService } from '../services/workspaceService.js' + +const cleanupDirs = new Set() +const ONE_MIB = 1024 * 1024 + +function trackDir(dir: string): string { + cleanupDirs.add(dir) + return dir +} + +async function makeTempDir(prefix: string): Promise { + return trackDir(await fs.mkdtemp(path.join(os.tmpdir(), prefix))) +} + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + }) +} + +async function createGitWorkspace(): Promise { + const repoDir = await makeTempDir('workspace-service-git-') + + git(repoDir, 'init') + git(repoDir, 'config', 'user.email', 'workspace-service@example.com') + git(repoDir, 'config', 'user.name', 'Workspace Service') + + await fs.writeFile(path.join(repoDir, 'tracked.txt'), 'before\n') + await fs.writeFile(path.join(repoDir, 'deleted.txt'), 'delete me\n') + await fs.writeFile(path.join(repoDir, 'clean.txt'), 'clean\n') + git(repoDir, 'add', 'tracked.txt', 'deleted.txt', 'clean.txt') + git(repoDir, 'commit', '-m', 'initial') + + await fs.writeFile(path.join(repoDir, 'tracked.txt'), 'before\nafter\n') + await fs.writeFile(path.join(repoDir, 'new.txt'), 'new file\n') + git(repoDir, 'add', 'new.txt') + await fs.unlink(path.join(repoDir, 'deleted.txt')) + await fs.writeFile(path.join(repoDir, 'untracked.txt'), 'still untracked\n') + + return repoDir +} + +async function createNestedGitWorkspace(): Promise<{ + repoDir: string + workDir: string +}> { + const repoDir = await makeTempDir('workspace-service-nested-git-') + const workDir = path.join(repoDir, 'subdir') + + git(repoDir, 'init') + git(repoDir, 'config', 'user.email', 'workspace-service@example.com') + git(repoDir, 'config', 'user.name', 'Workspace Service') + + await fs.mkdir(workDir) + await fs.writeFile(path.join(repoDir, 'root.txt'), 'root original\n') + await fs.writeFile(path.join(workDir, 'sub.txt'), 'sub original\n') + git(repoDir, 'add', 'root.txt', 'subdir/sub.txt') + git(repoDir, 'commit', '-m', 'initial') + + await fs.writeFile(path.join(repoDir, 'root.txt'), 'root original\nroot changed\n') + await fs.writeFile(path.join(workDir, 'sub.txt'), 'sub original\nsub changed\n') + + return { repoDir, workDir } +} + +afterEach(async () => { + for (const dir of cleanupDirs) { + await fs.rm(dir, { recursive: true, force: true }) + } + cleanupDirs.clear() +}) + +describe('WorkspaceService', () => { + it('returns git status for modified, added, deleted, and untracked files', async () => { + const repoDir = await createGitWorkspace() + const service = new WorkspaceService(async (sessionId) => sessionId === 'session-1' ? repoDir : null) + + const result = await service.getStatus('session-1') + + expect(result.state).toBe('ok') + expect(result.workDir).toBe(repoDir) + expect(result.isGitRepo).toBe(true) + expect(result.repoName).toBe(path.basename(repoDir)) + expect(result.branch).toBeTruthy() + + const files = new Map(result.changedFiles.map((file) => [file.path, file])) + expect(Array.from(files.keys()).sort()).toEqual([ + 'deleted.txt', + 'new.txt', + 'tracked.txt', + 'untracked.txt', + ]) + expect(files.get('tracked.txt')?.status).toBe('modified') + expect(files.get('tracked.txt')?.additions).toBeGreaterThan(0) + expect(files.get('new.txt')?.status).toBe('added') + expect(files.get('new.txt')?.additions).toBeGreaterThan(0) + expect(files.get('deleted.txt')?.status).toBe('deleted') + expect(files.get('deleted.txt')?.deletions).toBeGreaterThan(0) + expect(files.get('untracked.txt')).toMatchObject({ + status: 'untracked', + additions: 1, + deletions: 0, + }) + }) + + it('scopes git status and diff paths to a nested workDir inside a repo', async () => { + const { repoDir, workDir } = await createNestedGitWorkspace() + const service = new WorkspaceService(async (sessionId) => sessionId === 'session-1' ? workDir : null) + + const status = await service.getStatus('session-1') + + expect(status.state).toBe('ok') + expect(status.workDir).toBe(workDir) + expect(status.repoName).toBe(path.basename(repoDir)) + expect(status.changedFiles).toHaveLength(1) + expect(status.changedFiles[0]).toMatchObject({ + path: 'sub.txt', + status: 'modified', + }) + expect(status.changedFiles[0]?.additions).toBeGreaterThan(0) + expect(status.changedFiles[0]?.deletions).toBeGreaterThanOrEqual(0) + expect(status.changedFiles.some((file) => file.path === 'root.txt')).toBe(false) + + const diff = await service.getDiff('session-1', 'sub.txt') + expect(diff.state).toBe('ok') + expect(diff.diff).toContain('subdir/sub.txt') + expect(diff.diff?.length).toBeGreaterThan(0) + }) + + it('returns explicit non-git and missing-workdir states', async () => { + const nonGitDir = await makeTempDir('workspace-service-non-git-') + const missingDir = path.join(await makeTempDir('workspace-service-missing-parent-'), 'missing') + const service = new WorkspaceService(async (sessionId) => { + if (sessionId === 'non-git') return nonGitDir + if (sessionId === 'missing') return missingDir + return null + }) + + await expect(service.getStatus('unknown')).rejects.toThrow('Session not found: unknown') + + await expect(service.getStatus('non-git')).resolves.toMatchObject({ + state: 'ok', + workDir: nonGitDir, + repoName: path.basename(nonGitDir), + isGitRepo: false, + changedFiles: [], + }) + + await expect(service.getStatus('missing')).resolves.toMatchObject({ + state: 'missing_workdir', + workDir: missingDir, + isGitRepo: false, + changedFiles: [], + }) + }) + + it('reports session tool edits without requiring a git repository', async () => { + const nonGitDir = await makeTempDir('workspace-service-session-changes-') + await fs.mkdir(path.join(nonGitDir, 'src')) + await fs.writeFile(path.join(nonGitDir, 'src/App.jsx'), 'export default function App() { return
New
}\n') + + const service = new WorkspaceService( + async () => nonGitDir, + async () => [{ + id: 'assistant-1', + type: 'tool_use', + timestamp: new Date().toISOString(), + content: [{ + type: 'tool_use', + name: 'Edit', + input: { + file_path: 'src/App.jsx', + old_string: 'export default function App() { return
Old
}\n', + new_string: 'export default function App() { return
New
}\n', + }, + }], + }], + ) + + const status = await service.getStatus('session-1') + + expect(status).toMatchObject({ + state: 'ok', + workDir: nonGitDir, + isGitRepo: false, + changedFiles: [{ + path: 'src/App.jsx', + status: 'modified', + additions: 1, + deletions: 1, + }], + }) + + const diff = await service.getDiff('session-1', 'src/App.jsx') + expect(diff.state).toBe('ok') + expect(diff.diff).toContain('diff --session a/src/App.jsx b/src/App.jsx') + expect(diff.diff).toContain('-export default function App() { return
Old
}') + expect(diff.diff).toContain('+export default function App() { return
New
}') + }) + + it('rejects traversal attempts for file, diff, and tree access', async () => { + const repoDir = await createGitWorkspace() + const service = new WorkspaceService(async () => repoDir) + + await expect(service.readFile('session-1', '../outside.txt')).rejects.toThrow(/outside workspace/) + await expect(service.getDiff('session-1', '../outside.txt')).resolves.toMatchObject({ + state: 'error', + path: '../outside.txt', + }) + await expect(service.readTree('session-1', '../outside')).rejects.toThrow(/outside workspace/) + }) + + it('rejects symlink targets that escape the workspace root', async () => { + const workDir = await makeTempDir('workspace-service-symlink-') + const outsideDir = await makeTempDir('workspace-service-symlink-outside-') + const outsideFile = path.join(outsideDir, 'secret.txt') + await fs.writeFile(outsideFile, 'top secret\n') + await fs.symlink(outsideFile, path.join(workDir, 'escape.txt')) + + const service = new WorkspaceService(async () => workDir) + + await expect(service.readFile('session-1', 'escape.txt')).rejects.toThrow(/outside workspace/) + }) + + it('returns error for an untracked symlink that escapes the workspace root', async () => { + const repoDir = await makeTempDir('workspace-service-symlink-git-') + const outsideDir = await makeTempDir('workspace-service-symlink-git-outside-') + const outsideFile = path.join(outsideDir, 'secret.txt') + + git(repoDir, 'init') + git(repoDir, 'config', 'user.email', 'workspace-service@example.com') + git(repoDir, 'config', 'user.name', 'Workspace Service') + await fs.writeFile(path.join(repoDir, 'tracked.txt'), 'tracked\n') + git(repoDir, 'add', 'tracked.txt') + git(repoDir, 'commit', '-m', 'initial') + + await fs.writeFile(outsideFile, 'top secret\n') + await fs.symlink(outsideFile, path.join(repoDir, 'escape.txt')) + + const service = new WorkspaceService(async () => repoDir) + + const status = await service.getStatus('session-1') + expect(status.state).toBe('error') + expect(status.error).toMatch(/outside workspace/) + + await expect(service.getDiff('session-1', 'escape.txt')).resolves.toMatchObject({ + state: 'error', + path: 'escape.txt', + }) + const diffOutcome = await service.getDiff('session-1', 'escape.txt') + expect(diffOutcome.error).toMatch(/outside workspace/) + }) + + it('returns explicit readFile states for text, binary, too-large, and missing targets', async () => { + const workDir = await makeTempDir('workspace-service-files-') + const service = new WorkspaceService(async () => workDir) + + await fs.writeFile(path.join(workDir, 'note.ts'), 'export const answer = 42\n') + await fs.writeFile(path.join(workDir, 'binary.bin'), Buffer.from([0, 1, 2, 3])) + await fs.writeFile(path.join(workDir, 'image.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00])) + await fs.writeFile(path.join(workDir, 'large.txt'), Buffer.alloc(ONE_MIB + 1, 'a')) + await fs.mkdir(path.join(workDir, 'folder')) + + await expect(service.readFile('session-1', 'note.ts')).resolves.toMatchObject({ + state: 'ok', + language: 'typescript', + size: 25, + content: 'export const answer = 42\n', + }) + await expect(service.readFile('session-1', 'binary.bin')).resolves.toMatchObject({ + state: 'binary', + language: 'binary', + size: 4, + }) + await expect(service.readFile('session-1', 'image.png')).resolves.toMatchObject({ + state: 'ok', + previewType: 'image', + language: 'image', + mimeType: 'image/png', + dataUrl: 'data:image/png;base64,iVBORwA=', + size: 5, + }) + await expect(service.readFile('session-1', 'large.txt')).resolves.toMatchObject({ + state: 'too_large', + language: 'text', + size: ONE_MIB + 1, + }) + await expect(service.readFile('session-1', 'missing.txt')).resolves.toMatchObject({ + state: 'missing', + }) + await expect(service.readFile('session-1', 'folder')).resolves.toMatchObject({ + state: 'missing', + }) + }) + + it('lists a single directory level with dotfiles excluded and directories first', async () => { + const workDir = await makeTempDir('workspace-service-tree-') + const service = new WorkspaceService(async () => workDir) + + await fs.mkdir(path.join(workDir, 'b-dir')) + await fs.mkdir(path.join(workDir, 'a-dir')) + await fs.mkdir(path.join(workDir, 'a-dir', 'inner')) + await fs.writeFile(path.join(workDir, 'a-dir', 'note.txt'), 'nested\n') + await fs.writeFile(path.join(workDir, 'z-file.txt'), 'root file\n') + await fs.writeFile(path.join(workDir, '.hidden.txt'), 'ignore\n') + + await expect(service.readTree('session-1')).resolves.toMatchObject({ + state: 'ok', + path: '', + entries: [ + { name: 'a-dir', path: 'a-dir', isDirectory: true }, + { name: 'b-dir', path: 'b-dir', isDirectory: true }, + { name: 'z-file.txt', path: 'z-file.txt', isDirectory: false }, + ], + }) + + await expect(service.readTree('session-1', 'a-dir')).resolves.toMatchObject({ + state: 'ok', + path: 'a-dir', + entries: [ + { name: 'inner', path: 'a-dir/inner', isDirectory: true }, + { name: 'note.txt', path: 'a-dir/note.txt', isDirectory: false }, + ], + }) + }) + + it('returns diffs for modified, added, deleted, and untracked files', async () => { + const repoDir = await createGitWorkspace() + const service = new WorkspaceService(async (sessionId) => sessionId === 'session-1' ? repoDir : null) + + const modified = await service.getDiff('session-1', 'tracked.txt') + expect(modified.state).toBe('ok') + expect(modified.diff).toContain('tracked.txt') + expect(modified.diff.length).toBeGreaterThan(0) + + const added = await service.getDiff('session-1', 'new.txt') + expect(added.state).toBe('ok') + expect(added.diff).toContain('new.txt') + expect(added.diff.length).toBeGreaterThan(0) + + const deleted = await service.getDiff('session-1', 'deleted.txt') + expect(deleted.state).toBe('ok') + expect(deleted.diff).toContain('deleted.txt') + expect(deleted.diff.length).toBeGreaterThan(0) + + const untracked = await service.getDiff('session-1', 'untracked.txt') + expect(untracked.state).toBe('ok') + expect(untracked.diff).toContain('untracked.txt') + expect(untracked.diff.length).toBeGreaterThan(0) + + await expect(service.getDiff('session-1', 'clean.txt')).resolves.toMatchObject({ + state: 'missing', + path: 'clean.txt', + }) + + const nonGitDir = await makeTempDir('workspace-service-diff-non-git-') + const nonGitService = new WorkspaceService(async () => nonGitDir) + await expect(nonGitService.getDiff('session-1', 'whatever.txt')).resolves.toMatchObject({ + state: 'not_git_repo', + path: 'whatever.txt', + }) + }) + + it('returns explicit error state when git status fails instead of ok-empty', async () => { + const repoDir = await createGitWorkspace() + const service = new WorkspaceService(async () => repoDir) as WorkspaceService & { + runGit: (workDir: string, args: string[]) => Promise<{ + stdout: string + stderr: string + code: number + }> + } + + service.runGit = async (workDir, args) => { + if (args[0] === 'rev-parse' && args[1] === '--show-toplevel') { + return { stdout: `${workDir}\n`, stderr: '', code: 0 } + } + if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') { + return { stdout: 'main\n', stderr: '', code: 0 } + } + if (args[0] === 'status') { + return { stdout: '', stderr: 'fatal: synthetic git failure', code: 1 } + } + return { stdout: '', stderr: 'unexpected call', code: 1 } + } + + await expect(service.getStatus('session-1')).resolves.toMatchObject({ + state: 'error', + isGitRepo: true, + }) + + const result = await service.getStatus('session-1') + expect(result.state).toBe('error') + expect(result.changedFiles).toEqual([]) + expect(result.error).toContain('Failed to read git status') + expect(result.error).toContain('synthetic git failure') + }) + + it('reads tracked diff stats in one bulk git call', async () => { + const repoDir = await makeTempDir('workspace-service-bulk-stats-') + await fs.writeFile(path.join(repoDir, 'a.txt'), 'a\n') + await fs.writeFile(path.join(repoDir, 'b.txt'), 'b\n') + const diffStatCalls: string[][] = [] + const service = new WorkspaceService(async () => repoDir) as WorkspaceService & { + runGit: (workDir: string, args: string[]) => Promise<{ + stdout: string + stderr: string + code: number + }> + } + + service.runGit = async (_workDir, args) => { + if (args[0] === 'rev-parse' && args[1] === '--show-toplevel') { + return { stdout: `${repoDir}\n`, stderr: '', code: 0 } + } + if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') { + return { stdout: 'main\n', stderr: '', code: 0 } + } + if (args[0] === 'status') { + return { stdout: ' M a.txt\0 M b.txt\0', stderr: '', code: 0 } + } + if (args[0] === 'diff' && args.includes('--numstat')) { + diffStatCalls.push(args) + return { stdout: '1\t0\ta.txt\n2\t3\tb.txt\n', stderr: '', code: 0 } + } + return { stdout: '', stderr: `unexpected git call: ${args.join(' ')}`, code: 1 } + } + + const result = await service.getStatus('session-1') + + expect(result.state).toBe('ok') + expect(diffStatCalls).toHaveLength(1) + expect(result.changedFiles).toEqual([ + { path: 'a.txt', oldPath: undefined, status: 'modified', additions: 1, deletions: 0 }, + { path: 'b.txt', oldPath: undefined, status: 'modified', additions: 2, deletions: 3 }, + ]) + }) +}) diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index 155289b4..c7766c78 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -18,12 +18,18 @@ import { ApiError, errorResponse } from '../middleware/errorHandler.js' import { getSlashCommands } from '../ws/handler.js' import { getCommandName } from '../../commands.js' import { getSkillDirCommands } from '../../skills/loadSkillsDir.js' +import { WorkspaceService } from '../services/workspaceService.js' import { executeSessionRewind, previewSessionRewind, type RewindTargetSelector, } from '../services/sessionRewindService.js' +const workspaceService = new WorkspaceService(async (sessionId) => ( + conversationService.getSessionWorkDir(sessionId) || + await sessionService.getSessionWorkDir(sessionId) +), async (sessionId) => sessionService.getSessionMessages(sessionId)) + export async function handleSessionsApi( req: Request, url: URL, @@ -109,6 +115,16 @@ export async function handleSessionsApi( return await getSessionInspection(sessionId, url) } + if (subResource === 'workspace') { + if (req.method !== 'GET') { + return Response.json( + { error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` }, + { status: 405 } + ) + } + return await handleSessionWorkspaceRoute(sessionId, url, segments[4]) + } + // Route to conversations handler if sub-resource is 'chat' if (subResource === 'chat') { // This is handled by the conversations API, but in case the router @@ -174,6 +190,36 @@ async function getSessionMessages(sessionId: string): Promise { return Response.json({ messages }) } +async function handleSessionWorkspaceRoute( + sessionId: string, + url: URL, + workspaceResource?: string, +): Promise { + await requireSessionWorkspace(sessionId) + + switch (workspaceResource) { + case 'status': + return Response.json(await workspaceService.getStatus(sessionId)) + case 'tree': + return await runWorkspaceRequest(() => workspaceService.readTree( + sessionId, + url.searchParams.get('path') || '', + )) + case 'file': + return await runWorkspaceRequest(() => workspaceService.readFile( + sessionId, + requireWorkspacePath(url, 'file'), + )) + case 'diff': + return await runWorkspaceDiffRequest(() => workspaceService.getDiff( + sessionId, + requireWorkspacePath(url, 'diff'), + )) + default: + throw ApiError.notFound(`Unknown workspace resource: ${workspaceResource || 'workspace'}`) + } +} + async function createSession(req: Request): Promise { let body: { workDir?: string } try { @@ -190,6 +236,61 @@ async function createSession(req: Request): Promise { return Response.json(result, { status: 201 }) } +async function requireSessionWorkspace(sessionId: string): Promise { + const workDir = + conversationService.getSessionWorkDir(sessionId) || + await sessionService.getSessionWorkDir(sessionId) + + if (!workDir) { + throw ApiError.notFound(`Session not found: ${sessionId}`) + } + + return workDir +} + +function requireWorkspacePath(url: URL, route: 'file' | 'diff'): string { + const filePath = url.searchParams.get('path') + if (!filePath) { + throw ApiError.badRequest(`path query parameter is required for workspace ${route}`) + } + return filePath +} + +async function runWorkspaceRequest(operation: () => Promise): Promise { + try { + return Response.json(await operation()) + } catch (error) { + if (isOutsideWorkspaceError(error)) { + throw new ApiError(403, error.message, 'FORBIDDEN') + } + if (isSessionNotFoundError(error)) { + throw ApiError.notFound(error.message) + } + throw error + } +} + +async function runWorkspaceDiffRequest( + operation: () => Promise, +): Promise { + const result = await runWorkspaceRequest(operation) + const body = await result.clone().json() as T + + if (body.state === 'error' && typeof body.error === 'string' && body.error.includes('outside workspace')) { + throw new ApiError(403, body.error, 'FORBIDDEN') + } + + return result +} + +function isOutsideWorkspaceError(error: unknown): error is Error { + return error instanceof Error && error.message.includes('outside workspace') +} + +function isSessionNotFoundError(error: unknown): error is Error { + return error instanceof Error && error.message.startsWith('Session not found:') +} + async function deleteSession(sessionId: string): Promise { await sessionService.deleteSession(sessionId) return Response.json({ ok: true }) diff --git a/src/server/services/workspaceService.ts b/src/server/services/workspaceService.ts new file mode 100644 index 00000000..7d954d6f --- /dev/null +++ b/src/server/services/workspaceService.ts @@ -0,0 +1,1483 @@ +import * as fs from 'node:fs/promises' +import { execFile as execFileCallback } from 'node:child_process' +import * as path from 'node:path' +import { promisify } from 'node:util' +import type { MessageEntry } from './sessionService.js' + +const MAX_PREVIEW_BYTES = 1024 * 1024 +const MAX_UNTRACKED_STAT_BYTES = 256 * 1024 +const GIT_TIMEOUT_MS = 5_000 +const MAX_GIT_BUFFER_BYTES = 2_000_000 +const execFile = promisify(execFileCallback) + +const LANGUAGE_MAP: Record = { + cjs: 'javascript', + css: 'css', + go: 'go', + html: 'html', + java: 'java', + js: 'javascript', + json: 'json', + jsx: 'jsx', + md: 'markdown', + mjs: 'javascript', + py: 'python', + rs: 'rust', + sh: 'bash', + sql: 'sql', + ts: 'typescript', + tsx: 'tsx', + txt: 'text', + xml: 'xml', + yaml: 'yaml', + yml: 'yaml', +} + +const IMAGE_MIME_BY_EXTENSION: Record = { + apng: 'image/apng', + avif: 'image/avif', + gif: 'image/gif', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + png: 'image/png', + svg: 'image/svg+xml', + webp: 'image/webp', +} + +export type WorkspaceFileStatus = + | 'modified' + | 'added' + | 'deleted' + | 'renamed' + | 'untracked' + | 'copied' + | 'type_changed' + | 'unknown' + +export type WorkspaceChangedFile = { + path: string + oldPath?: string + status: WorkspaceFileStatus + additions: number + deletions: number +} + +export type WorkspaceStatusResult = { + state: 'ok' | 'not_git_repo' | 'missing_workdir' | 'error' + workDir: string + repoName: string | null + branch: string | null + isGitRepo: boolean + changedFiles: WorkspaceChangedFile[] + error?: string +} + +export type WorkspaceReadFileResult = { + state: 'ok' | 'binary' | 'too_large' | 'missing' | 'error' + path: string + previewType?: 'text' | 'image' + content?: string + dataUrl?: string + mimeType?: string + language: string + size: number + error?: string +} + +export type WorkspaceTreeEntry = { + name: string + path: string + isDirectory: boolean +} + +export type WorkspaceTreeResult = { + state: 'ok' | 'missing' | 'error' + path: string + entries: WorkspaceTreeEntry[] + error?: string +} + +export type WorkspaceDiffResult = { + state: 'ok' | 'missing' | 'not_git_repo' | 'error' + path: string + diff?: string + error?: string +} + +type StatusEntry = { + path: string + oldPath?: string + code: string + status: WorkspaceFileStatus +} + +type ScopedStatusEntry = { + repoPath: string + repoOldPath?: string + path: string + oldPath?: string + status: WorkspaceFileStatus + absolutePath: string + canonicalWorkspaceRoot: string +} + +type GitRepoInfo = + | { + kind: 'not_git_repo' + } + | { + kind: 'ok' + repoRoot: string + branch: string | null + } + | { + kind: 'error' + message: string + } + +type WorkspacePathResolution = { + requestedPath: string + relativePath: string + absolutePath: string + workspaceRoot: string + canonicalWorkspaceRoot: string + canonicalTargetPath: string +} + +type WorkspaceStatResult = + | { + kind: 'ok' + stat: Awaited> + } + | { + kind: 'missing' + } + | { + kind: 'error' + message: string + } + +type GitCommandResult = { + stdout: string + stderr: string + code: number +} + +type DiffStatsResult = + | { + kind: 'ok' + additions: number + deletions: number + } + | { + kind: 'error' + message: string + } + +type DiffStatsByRepoPathResult = + | { + kind: 'ok' + statsByRepoPath: Map + } + | { + kind: 'error' + message: string + } + +type UntrackedDiffResult = + | { + kind: 'ok' + diff: string + } + | { + kind: 'missing' + } + | { + kind: 'error' + message: string + } + +type SessionFileChange = WorkspaceChangedFile & { + diff?: string +} + +export function parseStatus(code: string): WorkspaceFileStatus { + const x = code[0] ?? ' ' + const y = code[1] ?? ' ' + + if (code === '??') return 'untracked' + if (x === 'R' || y === 'R') return 'renamed' + if (x === 'C' || y === 'C') return 'copied' + if (x === 'T' || y === 'T') return 'type_changed' + if (x === 'D' || y === 'D') return 'deleted' + if (x === 'A' || y === 'A') return 'added' + if (x === 'M' || y === 'M') return 'modified' + return 'unknown' +} + +export class WorkspaceService { + constructor( + private readonly resolveSessionWorkDir: ( + sessionId: string, + ) => Promise, + private readonly resolveSessionMessages: ( + sessionId: string, + ) => Promise = async () => [], + ) {} + + async getStatus(sessionId: string): Promise { + const workDir = await this.requireWorkDir(sessionId) + const workspaceInfo = await this.getWorkspaceRoot(workDir) + if (workspaceInfo.kind === 'missing') { + return { + state: 'missing_workdir', + workDir, + repoName: null, + branch: null, + isGitRepo: false, + changedFiles: [], + } + } + if (workspaceInfo.kind === 'error') { + return { + state: 'error', + workDir, + repoName: null, + branch: null, + isGitRepo: false, + changedFiles: [], + error: workspaceInfo.message, + } + } + + const repoInfo = await this.getGitRepoInfo(workDir) + const sessionChanges = await this.getSessionFileChanges( + sessionId, + workspaceInfo.workspaceRoot, + ) + + if (sessionChanges.length > 0) { + sessionChanges.sort((a, b) => a.path.localeCompare(b.path)) + return { + state: 'ok', + workDir, + repoName: repoInfo.kind === 'ok' + ? path.basename(repoInfo.repoRoot) + : path.basename(workspaceInfo.workspaceRoot), + branch: repoInfo.kind === 'ok' ? repoInfo.branch : null, + isGitRepo: repoInfo.kind === 'ok', + changedFiles: sessionChanges.map(({ diff: _diff, ...change }) => change), + } + } + + if (repoInfo.kind === 'not_git_repo') { + return { + state: 'ok', + workDir, + repoName: path.basename(workspaceInfo.workspaceRoot), + branch: null, + isGitRepo: false, + changedFiles: [], + } + } + if (repoInfo.kind === 'error') { + return { + state: 'error', + workDir, + repoName: null, + branch: null, + isGitRepo: false, + changedFiles: [], + error: repoInfo.message, + } + } + + const statusEntries = await this.getStatusEntries(repoInfo.repoRoot) + if (statusEntries.kind === 'error') { + return { + state: 'error', + workDir, + repoName: path.basename(repoInfo.repoRoot), + branch: repoInfo.branch, + isGitRepo: true, + changedFiles: [], + error: statusEntries.message, + } + } + const scopedEntries = this.scopeStatusEntries( + statusEntries.entries, + repoInfo.repoRoot, + workspaceInfo.canonicalWorkspaceRoot, + ) + const trackedStats = await this.getTrackedDiffStats(repoInfo.repoRoot, scopedEntries) + if (trackedStats.kind === 'error') { + return { + state: 'error', + workDir, + repoName: path.basename(repoInfo.repoRoot), + branch: repoInfo.branch, + isGitRepo: true, + changedFiles: [], + error: trackedStats.message, + } + } + + const changedFiles = await Promise.all( + scopedEntries.map(async (entry) => { + const stats = entry.status === 'untracked' + ? await this.getDiffStats(repoInfo.repoRoot, entry) + : { + kind: 'ok' as const, + ...(trackedStats.statsByRepoPath.get(entry.repoPath) ?? { + additions: 0, + deletions: 0, + }), + } + + if (stats.kind === 'error') { + throw new Error(stats.message) + } + + return { + path: entry.path, + oldPath: entry.oldPath, + status: entry.status, + additions: stats.additions, + deletions: stats.deletions, + } satisfies WorkspaceChangedFile + }), + ).catch((error) => error as Error) + + if (changedFiles instanceof Error) { + return { + state: 'error', + workDir, + repoName: path.basename(repoInfo.repoRoot), + branch: repoInfo.branch, + isGitRepo: true, + changedFiles: [], + error: changedFiles.message, + } + } + + changedFiles.sort((a, b) => a.path.localeCompare(b.path)) + + return { + state: 'ok', + workDir, + repoName: path.basename(repoInfo.repoRoot), + branch: repoInfo.branch, + isGitRepo: true, + changedFiles, + } + } + + async readFile( + sessionId: string, + filePath: string, + ): Promise { + const resolvedPath = await this.resolveWorkspacePath(sessionId, filePath) + + const stat = await this.safeStat(resolvedPath.absolutePath) + if (stat.kind === 'error') { + return { + state: 'error', + path: resolvedPath.relativePath, + language: this.detectLanguage(resolvedPath.absolutePath), + size: 0, + error: stat.message, + } + } + if (stat.kind === 'missing' || !stat.stat.isFile()) { + return { + state: 'missing', + path: resolvedPath.relativePath, + language: this.detectLanguage(resolvedPath.absolutePath), + size: 0, + } + } + + const language = this.detectLanguage(resolvedPath.absolutePath) + if (stat.stat.size > MAX_PREVIEW_BYTES) { + return { + state: 'too_large', + path: resolvedPath.relativePath, + language, + size: stat.stat.size, + } + } + + let content: Buffer + try { + content = await fs.readFile(resolvedPath.absolutePath) + } catch (error) { + return { + state: 'error', + path: resolvedPath.relativePath, + language, + size: stat.stat.size, + error: this.formatFsError( + 'Failed to read workspace file', + resolvedPath.absolutePath, + error, + ), + } + } + const imageMimeType = this.detectImageMimeType(resolvedPath.absolutePath) + if (imageMimeType) { + return { + state: 'ok', + path: resolvedPath.relativePath, + previewType: 'image', + dataUrl: `data:${imageMimeType};base64,${content.toString('base64')}`, + mimeType: imageMimeType, + language: 'image', + size: stat.stat.size, + } + } + + if (content.includes(0)) { + return { + state: 'binary', + path: resolvedPath.relativePath, + language: 'binary', + size: stat.stat.size, + } + } + + return { + state: 'ok', + path: resolvedPath.relativePath, + previewType: 'text', + content: content.toString('utf8'), + language, + size: stat.stat.size, + } + } + + async readTree( + sessionId: string, + treePath = '', + ): Promise { + const resolvedPath = await this.resolveWorkspacePath(sessionId, treePath) + + const stat = await this.safeStat(resolvedPath.absolutePath) + if (stat.kind === 'error') { + return { + state: 'error', + path: resolvedPath.relativePath, + entries: [], + error: stat.message, + } + } + if (stat.kind === 'missing' || !stat.stat.isDirectory()) { + return { state: 'missing', path: resolvedPath.relativePath, entries: [] } + } + + let entries: import('node:fs').Dirent[] + try { + entries = await fs.readdir(resolvedPath.absolutePath, { withFileTypes: true }) + } catch (error) { + return { + state: 'error', + path: resolvedPath.relativePath, + entries: [], + error: this.formatFsError( + 'Failed to read workspace directory', + resolvedPath.absolutePath, + error, + ), + } + } + const visibleEntries = entries + .filter((entry) => !entry.name.startsWith('.')) + .sort((a, b) => { + if (a.isDirectory() !== b.isDirectory()) { + return a.isDirectory() ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) + .map((entry) => ({ + name: entry.name, + path: this.normalizeRelativePath( + path.relative( + resolvedPath.workspaceRoot, + path.join(resolvedPath.absolutePath, entry.name), + ), + ), + isDirectory: entry.isDirectory(), + })) + + return { + state: 'ok', + path: resolvedPath.relativePath, + entries: visibleEntries, + } + } + + async getDiff( + sessionId: string, + filePath: string, + ): Promise { + let resolvedPath: WorkspacePathResolution + try { + resolvedPath = await this.resolveWorkspacePath(sessionId, filePath) + } catch (error) { + return { + state: 'error', + path: this.normalizeRequestedPath(filePath), + error: error instanceof Error ? error.message : String(error), + } + } + + const sessionDiff = await this.getSessionDiff(sessionId, resolvedPath.relativePath) + if (sessionDiff) { + return { state: 'ok', path: resolvedPath.relativePath, diff: sessionDiff } + } + + const repoInfo = await this.getGitRepoInfo(resolvedPath.workspaceRoot) + if (repoInfo.kind === 'not_git_repo') { + return { state: 'not_git_repo', path: resolvedPath.relativePath } + } + if (repoInfo.kind === 'error') { + return { + state: 'error', + path: resolvedPath.relativePath, + error: repoInfo.message, + } + } + + const statusEntries = await this.getStatusEntries(repoInfo.repoRoot) + if (statusEntries.kind === 'error') { + return { + state: 'error', + path: resolvedPath.relativePath, + error: statusEntries.message, + } + } + const scopedEntries = this.scopeStatusEntries( + statusEntries.entries, + repoInfo.repoRoot, + resolvedPath.canonicalWorkspaceRoot, + ) + const repoRelativePath = this.toRepoRelativePath( + repoInfo.repoRoot, + resolvedPath.canonicalTargetPath, + ) + + const statusEntry = scopedEntries.find( + (entry) => + entry.repoPath === repoRelativePath || + entry.repoOldPath === repoRelativePath, + ) + + if (!statusEntry) { + return { state: 'missing', path: resolvedPath.relativePath } + } + + if (statusEntry.status === 'untracked') { + const diff = await this.buildUntrackedDiff( + resolvedPath.canonicalTargetPath, + resolvedPath.relativePath, + ) + if (diff.kind === 'missing') { + return { state: 'missing', path: resolvedPath.relativePath } + } + if (diff.kind === 'error') { + return { + state: 'error', + path: resolvedPath.relativePath, + error: diff.message, + } + } + return { state: 'ok', path: resolvedPath.relativePath, diff: diff.diff } + } + + const targetPath = statusEntry.repoPath + const diff = await this.runGitDiff(repoInfo.repoRoot, targetPath) + if (diff.kind === 'error') { + return { + state: 'error', + path: resolvedPath.relativePath, + error: diff.message, + } + } + if (!diff.diff.trim()) { + return { state: 'missing', path: resolvedPath.relativePath } + } + + return { state: 'ok', path: resolvedPath.relativePath, diff: diff.diff } + } + + private async getSessionDiff( + sessionId: string, + relativePath: string, + ): Promise { + const workDir = await this.requireWorkDir(sessionId) + const changes = await this.getSessionFileChanges(sessionId, workDir) + const change = changes.find((entry) => entry.path === relativePath) + if (!change) return null + if (change.diff?.trim()) return change.diff + + const file = await this.readFile(sessionId, relativePath) + if (file.state !== 'ok' || file.previewType === 'image' || typeof file.content !== 'string') { + return null + } + return this.buildSyntheticDiff('/dev/null', relativePath, '', file.content) + } + + private async getSessionFileChanges( + sessionId: string, + workspaceRoot: string, + ): Promise { + let messages: MessageEntry[] + try { + messages = await this.resolveSessionMessages(sessionId) + } catch { + return [] + } + + const changes = new Map() + + for (const message of messages) { + if (message.type !== 'tool_use' || !Array.isArray(message.content)) continue + + for (const block of message.content) { + if (!block || typeof block !== 'object') continue + const record = block as Record + if (record.type !== 'tool_use' || typeof record.name !== 'string') continue + const input = record.input + if (!input || typeof input !== 'object') continue + + for (const change of this.extractSessionChangesFromTool( + record.name, + input as Record, + workspaceRoot, + )) { + const existing = changes.get(change.path) + if (!existing) { + changes.set(change.path, change) + continue + } + + changes.set(change.path, { + ...existing, + status: existing.status === 'added' ? existing.status : change.status, + additions: existing.additions + change.additions, + deletions: existing.deletions + change.deletions, + diff: [existing.diff, change.diff].filter(Boolean).join('\n'), + }) + } + } + } + + return [...changes.values()] + } + + private extractSessionChangesFromTool( + toolName: string, + input: Record, + workspaceRoot: string, + ): SessionFileChange[] { + const normalizedToolName = toolName.toLowerCase() + if (normalizedToolName === 'write') { + const filePath = this.resolveSessionToolPath(input.file_path ?? input.path, workspaceRoot) + if (!filePath) return [] + const content = typeof input.content === 'string' ? input.content : '' + return [{ + path: filePath, + status: 'added', + additions: this.countChangedLines(content), + deletions: 0, + diff: this.buildSyntheticDiff('/dev/null', filePath, '', content), + }] + } + + if (normalizedToolName === 'edit') { + const filePath = this.resolveSessionToolPath(input.file_path ?? input.path, workspaceRoot) + if (!filePath) return [] + return [this.buildEditSessionChange(filePath, input)] + } + + if (normalizedToolName === 'multiedit') { + const filePath = this.resolveSessionToolPath(input.file_path ?? input.path, workspaceRoot) + if (!filePath || !Array.isArray(input.edits)) return [] + return input.edits + .filter((edit): edit is Record => !!edit && typeof edit === 'object') + .map((edit) => this.buildEditSessionChange(filePath, edit)) + } + + if (normalizedToolName === 'notebookedit') { + const filePath = this.resolveSessionToolPath( + input.notebook_path ?? input.file_path ?? input.path, + workspaceRoot, + ) + if (!filePath) return [] + const oldString = typeof input.old_source === 'string' ? input.old_source : '' + const newString = typeof input.new_source === 'string' ? input.new_source : '' + return [{ + path: filePath, + status: oldString ? 'modified' : 'added', + additions: this.countChangedLines(newString), + deletions: this.countChangedLines(oldString), + diff: this.buildSyntheticDiff(filePath, filePath, oldString, newString), + }] + } + + if (normalizedToolName === 'apply_patch') { + return this.extractApplyPatchSessionChanges(input.patch, workspaceRoot) + } + + return [] + } + + private buildEditSessionChange( + filePath: string, + input: Record, + ): SessionFileChange { + const oldString = typeof input.old_string === 'string' ? input.old_string : '' + const newString = typeof input.new_string === 'string' ? input.new_string : '' + return { + path: filePath, + status: oldString ? 'modified' : 'added', + additions: this.countChangedLines(newString), + deletions: this.countChangedLines(oldString), + diff: this.buildSyntheticDiff(filePath, filePath, oldString, newString), + } + } + + private extractApplyPatchSessionChanges( + patch: unknown, + workspaceRoot: string, + ): SessionFileChange[] { + if (typeof patch !== 'string') return [] + const changes: SessionFileChange[] = [] + + for (const line of patch.split('\n')) { + const match = line.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/) + if (!match?.[1]) continue + const filePath = this.resolveSessionToolPath(match[1], workspaceRoot) + if (!filePath) continue + const status: WorkspaceFileStatus = line.includes('Add File') + ? 'added' + : line.includes('Delete File') + ? 'deleted' + : 'modified' + changes.push({ + path: filePath, + status, + additions: 0, + deletions: 0, + }) + } + + return changes + } + + private resolveSessionToolPath( + filePath: unknown, + workspaceRoot: string, + ): string | null { + if (typeof filePath !== 'string' || !filePath.trim()) return null + const absolutePath = path.isAbsolute(filePath) + ? path.resolve(filePath) + : path.resolve(workspaceRoot, filePath) + if (!this.isWithinRoot(absolutePath, workspaceRoot)) return null + return this.normalizeRelativePath(path.relative(workspaceRoot, absolutePath)) + } + + private countChangedLines(value: string): number { + if (!value) return 0 + return value.endsWith('\n') + ? value.split('\n').length - 1 + : value.split('\n').length + } + + private buildSyntheticDiff( + oldPath: string, + newPath: string, + oldContent: string, + newContent: string, + ): string { + const oldLines = oldContent ? oldContent.split('\n') : [] + const newLines = newContent ? newContent.split('\n') : [] + if (oldLines.at(-1) === '') oldLines.pop() + if (newLines.at(-1) === '') newLines.pop() + + return [ + `diff --session a/${oldPath} b/${newPath}`, + `--- ${oldPath === '/dev/null' ? '/dev/null' : `a/${oldPath}`}`, + `+++ b/${newPath}`, + `@@ -1,${oldLines.length} +1,${newLines.length} @@`, + ...oldLines.map((line) => `-${line}`), + ...newLines.map((line) => `+${line}`), + ].join('\n') + } + + private async requireWorkDir(sessionId: string): Promise { + const workDir = await this.resolveSessionWorkDir(sessionId) + if (!workDir) { + throw new Error(`Session not found: ${sessionId}`) + } + return path.resolve(workDir) + } + + private async getWorkspaceRoot( + workDir: string, + ): Promise< + | { kind: 'ok'; workspaceRoot: string; canonicalWorkspaceRoot: string } + | { kind: 'missing' } + | { kind: 'error'; message: string } + > { + const stat = await this.safeStat(workDir) + if (stat.kind === 'missing') { + return { kind: 'missing' } + } + if (stat.kind === 'error') { + return { kind: 'error', message: stat.message } + } + + try { + return { + kind: 'ok', + workspaceRoot: workDir, + canonicalWorkspaceRoot: await fs.realpath(workDir), + } + } catch (error) { + return { + kind: 'error', + message: this.formatFsError( + 'Failed to canonicalize workspace root', + workDir, + error, + ), + } + } + } + + private async resolveWorkspacePath( + sessionId: string, + requestedPath: string, + ): Promise { + const workDir = await this.requireWorkDir(sessionId) + const workspaceRoot = await this.getWorkspaceRoot(workDir) + if (workspaceRoot.kind === 'missing') { + throw new Error(`Workspace root is missing: ${workDir}`) + } + if (workspaceRoot.kind === 'error') { + throw new Error(workspaceRoot.message) + } + + const absolutePath = path.resolve(workDir, requestedPath || '.') + if (!this.isWithinRoot(absolutePath, workDir)) { + throw new Error(`Path is outside workspace: ${requestedPath}`) + } + + const canonicalTargetPath = await this.resolveCanonicalTargetPath( + workspaceRoot.canonicalWorkspaceRoot, + absolutePath, + requestedPath, + ) + + return { + absolutePath, + requestedPath, + workspaceRoot: workspaceRoot.workspaceRoot, + canonicalWorkspaceRoot: workspaceRoot.canonicalWorkspaceRoot, + canonicalTargetPath, + relativePath: this.normalizeRelativePath( + path.relative(workspaceRoot.workspaceRoot, absolutePath), + ), + } + } + + private async validateCanonicalWorkspacePath( + canonicalWorkspaceRoot: string, + absolutePath: string, + requestedPath: string, + ): Promise< + | { kind: 'ok'; canonicalTargetPath: string } + | { kind: 'error'; message: string } + > { + try { + return { + kind: 'ok', + canonicalTargetPath: await this.resolveCanonicalTargetPath( + canonicalWorkspaceRoot, + absolutePath, + requestedPath, + ), + } + } catch (error) { + return { + kind: 'error', + message: error instanceof Error ? error.message : String(error), + } + } + } + + private async resolveCanonicalTargetPath( + canonicalWorkspaceRoot: string, + absolutePath: string, + requestedPath: string, + ): Promise { + let probePath = absolutePath + const missingSuffix: string[] = [] + + for (;;) { + try { + const canonicalBase = await fs.realpath(probePath) + const canonicalTarget = path.resolve(canonicalBase, ...missingSuffix) + if (!this.isWithinRoot(canonicalTarget, canonicalWorkspaceRoot)) { + throw new Error(`Path is outside workspace: ${requestedPath}`) + } + return canonicalTarget + } catch (error) { + const err = error as NodeJS.ErrnoException + if (err.code === 'ENOENT' || err.code === 'ENOTDIR') { + if (probePath === canonicalWorkspaceRoot) { + const candidate = path.resolve(canonicalWorkspaceRoot, ...missingSuffix) + if (!this.isWithinRoot(candidate, canonicalWorkspaceRoot)) { + throw new Error(`Path is outside workspace: ${requestedPath}`) + } + return candidate + } + + missingSuffix.unshift(path.basename(probePath)) + const parentPath = path.dirname(probePath) + if (parentPath === probePath) { + throw err + } + probePath = parentPath + continue + } + + throw new Error( + this.formatFsError( + 'Failed to canonicalize workspace path', + absolutePath, + error, + ), + ) + } + } + } + + private isWithinRoot(targetPath: string, rootPath: string): boolean { + return targetPath === rootPath || targetPath.startsWith(`${rootPath}${path.sep}`) + } + + private normalizeRelativePath(filePath: string): string { + if (!filePath || filePath === '.') return '' + return filePath.split(path.sep).join('/') + } + + private normalizeRequestedPath(filePath: string): string { + if (!filePath) return '' + return filePath.split(path.sep).join('/') + } + + private scopeStatusEntries( + entries: StatusEntry[], + repoRoot: string, + workDir: string, + ): ScopedStatusEntry[] { + const workDirFromRepo = this.normalizeRelativePath(path.relative(repoRoot, workDir)) + + return entries.flatMap((entry) => { + const scopedPath = this.rebaseRepoPathToWorkspacePath(entry.path, workDirFromRepo) + if (scopedPath === null) { + return [] + } + + const scopedOldPath = entry.oldPath + ? this.rebaseRepoPathToWorkspacePath(entry.oldPath, workDirFromRepo) + : undefined + + return [{ + repoPath: entry.path, + repoOldPath: entry.oldPath, + path: scopedPath, + oldPath: scopedOldPath ?? undefined, + status: entry.status, + absolutePath: path.resolve(repoRoot, entry.path), + canonicalWorkspaceRoot: workDir, + }] + }) + } + + private rebaseRepoPathToWorkspacePath( + repoPath: string, + workDirFromRepo: string, + ): string | null { + const normalizedRepoPath = this.normalizeRelativePath(repoPath) + if (!workDirFromRepo) { + return normalizedRepoPath + } + + const rebasedPath = path.posix.relative(workDirFromRepo, normalizedRepoPath) + if ( + !rebasedPath || + rebasedPath === '.' || + rebasedPath === '..' || + rebasedPath.startsWith('../') + ) { + return null + } + + return rebasedPath + } + + private toRepoRelativePath( + repoRoot: string, + canonicalTargetPath: string, + ): string { + return this.normalizeRelativePath(path.relative(repoRoot, canonicalTargetPath)) + } + + private detectLanguage(filePath: string): string { + const ext = path.extname(filePath).slice(1).toLowerCase() + return LANGUAGE_MAP[ext] || 'text' + } + + private detectImageMimeType(filePath: string): string | null { + const ext = path.extname(filePath).slice(1).toLowerCase() + return IMAGE_MIME_BY_EXTENSION[ext] ?? null + } + + private async safeStat(targetPath: string): Promise { + try { + return { + kind: 'ok', + stat: await fs.stat(targetPath), + } + } catch (error) { + const err = error as NodeJS.ErrnoException + if (err.code === 'ENOENT' || err.code === 'ENOTDIR') { + return { kind: 'missing' } + } + return { + kind: 'error', + message: this.formatFsError('Failed to stat workspace path', targetPath, error), + } + } + } + + private async getGitRepoInfo(workDir: string): Promise { + const rootResult = await this.runGit(workDir, ['rev-parse', '--show-toplevel']) + + if (rootResult.code !== 0) { + const stderr = rootResult.stderr.trim() + if (stderr.includes('not a git repository')) { + return { kind: 'not_git_repo' } + } + return { + kind: 'error', + message: this.formatGitError( + 'Failed to inspect git repository', + ['rev-parse', '--show-toplevel'], + workDir, + rootResult, + ), + } + } + + let repoRoot: string + try { + repoRoot = await fs.realpath(path.resolve(rootResult.stdout.trim())) + } catch (error) { + return { + kind: 'error', + message: this.formatFsError( + 'Failed to canonicalize git repository root', + path.resolve(rootResult.stdout.trim()), + error, + ), + } + } + const branchResult = await this.runGit(workDir, [ + 'rev-parse', + '--abbrev-ref', + 'HEAD', + ]) + + return { + kind: 'ok', + repoRoot, + branch: + branchResult.code === 0 + ? branchResult.stdout.trim() || null + : null, + } + } + + private async getStatusEntries( + workDir: string, + ): Promise<{ kind: 'ok'; entries: StatusEntry[] } | { kind: 'error'; message: string }> { + const result = await this.runGit(workDir, [ + 'status', + '--porcelain=v1', + '-z', + '--untracked-files=all', + ]) + + if (result.code !== 0) { + return { + kind: 'error', + message: this.formatGitError( + 'Failed to read git status', + ['status', '--porcelain=v1', '-z', '--untracked-files=all'], + workDir, + result, + ), + } + } + + const parts = result.stdout.split('\0') + const entries: StatusEntry[] = [] + + for (let i = 0; i < parts.length; i++) { + const record = parts[i] + if (!record) continue + + const code = record.slice(0, 2) + const currentPath = this.normalizeRelativePath(record.slice(3)) + const status = parseStatus(code) + + if (status === 'renamed' || status === 'copied') { + const oldPath = this.normalizeRelativePath(parts[++i] || '') + entries.push({ + path: currentPath, + oldPath, + code, + status, + }) + continue + } + + entries.push({ + path: currentPath, + code, + status, + }) + } + + return { kind: 'ok', entries } + } + + private async getDiffStats( + workDir: string, + entry: ScopedStatusEntry, + ): Promise { + if (entry.status === 'untracked') { + const validatedPath = await this.validateCanonicalWorkspacePath( + entry.canonicalWorkspaceRoot, + entry.absolutePath, + entry.path, + ) + if (validatedPath.kind === 'error') { + return { kind: 'error', message: validatedPath.message } + } + + return this.getUntrackedStats(validatedPath.canonicalTargetPath) + } + + const result = await this.runGit(workDir, [ + 'diff', + '--numstat', + '--find-renames', + '--find-copies', + 'HEAD', + '--', + entry.repoPath, + ]) + + if (result.code !== 0) { + return { + kind: 'error', + message: this.formatGitError( + 'Failed to read git diff stats', + [ + 'diff', + '--numstat', + '--find-renames', + '--find-copies', + 'HEAD', + '--', + entry.repoPath, + ], + workDir, + result, + ), + } + } + + const line = result.stdout.trim().split('\n').find(Boolean) + if (!line) { + return { + kind: 'ok', + additions: 0, + deletions: 0, + } + } + + const [additions, deletions] = line.split('\t') + return { + kind: 'ok', + additions: additions === '-' ? 0 : parseInt(additions || '0', 10) || 0, + deletions: deletions === '-' ? 0 : parseInt(deletions || '0', 10) || 0, + } + } + + private async getTrackedDiffStats( + workDir: string, + entries: ScopedStatusEntry[], + ): Promise { + if (!entries.some((entry) => entry.status !== 'untracked')) { + return { kind: 'ok', statsByRepoPath: new Map() } + } + + const result = await this.runGit(workDir, [ + 'diff', + '--numstat', + '--find-renames', + '--find-copies', + 'HEAD', + '--', + ]) + + if (result.code !== 0) { + return { + kind: 'error', + message: this.formatGitError( + 'Failed to read git diff stats', + [ + 'diff', + '--numstat', + '--find-renames', + '--find-copies', + 'HEAD', + '--', + ], + workDir, + result, + ), + } + } + + const statsByRepoPath = new Map() + for (const line of result.stdout.trim().split('\n')) { + if (!line) continue + const [additions, deletions, repoPath] = line.split('\t') + if (!repoPath) continue + statsByRepoPath.set(this.normalizeRelativePath(repoPath), { + additions: additions === '-' ? 0 : parseInt(additions || '0', 10) || 0, + deletions: deletions === '-' ? 0 : parseInt(deletions || '0', 10) || 0, + }) + } + + return { kind: 'ok', statsByRepoPath } + } + + private async getUntrackedStats( + absolutePath: string, + ): Promise { + try { + const stat = await fs.stat(absolutePath) + if (!stat.isFile()) { + return { kind: 'ok', additions: 0, deletions: 0 } + } + if (stat.size > MAX_UNTRACKED_STAT_BYTES) { + return { kind: 'ok', additions: 0, deletions: 0 } + } + + const content = await fs.readFile(absolutePath, 'utf8') + return { + kind: 'ok', + additions: this.countTextLines(content), + deletions: 0, + } + } catch (error) { + return { + kind: 'error', + message: this.formatFsError( + 'Failed to read untracked workspace file', + absolutePath, + error, + ), + } + } + } + + private countTextLines(content: string): number { + if (!content) return 0 + const lines = content.split(/\r\n|\r|\n/) + if (lines[lines.length - 1] === '') { + lines.pop() + } + return lines.length + } + + private async runGitDiff( + workDir: string, + relativePath: string, + ): Promise<{ kind: 'ok'; diff: string } | { kind: 'error'; message: string }> { + const result = await this.runGit(workDir, [ + 'diff', + '--no-ext-diff', + '--binary', + '--find-renames', + '--find-copies', + 'HEAD', + '--', + relativePath, + ]) + + if (result.code !== 0) { + return { + kind: 'error', + message: this.formatGitError( + 'Failed to read git diff', + [ + 'diff', + '--no-ext-diff', + '--binary', + '--find-renames', + '--find-copies', + 'HEAD', + '--', + relativePath, + ], + workDir, + result, + ), + } + } + + return { kind: 'ok', diff: result.stdout } + } + + private async runGit( + workDir: string, + args: string[], + ): Promise { + try { + const result = await execFile('git', args, { + cwd: workDir, + timeout: GIT_TIMEOUT_MS, + maxBuffer: MAX_GIT_BUFFER_BYTES, + encoding: 'utf8', + }) + + return { + stdout: result.stdout, + stderr: result.stderr, + code: 0, + } + } catch (error) { + const err = error as NodeJS.ErrnoException & { + stdout?: string | Buffer + stderr?: string | Buffer + code?: number | string + } + + return { + stdout: + typeof err.stdout === 'string' + ? err.stdout + : Buffer.isBuffer(err.stdout) + ? err.stdout.toString('utf8') + : '', + stderr: + typeof err.stderr === 'string' + ? err.stderr + : Buffer.isBuffer(err.stderr) + ? err.stderr.toString('utf8') + : '', + code: typeof err.code === 'number' ? err.code : 1, + } + } + } + + private async buildUntrackedDiff( + absolutePath: string, + relativePath: string, + ): Promise { + const stat = await this.safeStat(absolutePath) + if (stat.kind === 'error') { + return { kind: 'error', message: stat.message } + } + if (stat.kind === 'missing' || !stat.stat.isFile()) { + return { kind: 'missing' } + } + + let buffer: Buffer + try { + buffer = await fs.readFile(absolutePath) + } catch (error) { + return { + kind: 'error', + message: this.formatFsError( + 'Failed to read untracked workspace file', + absolutePath, + error, + ), + } + } + if (buffer.includes(0)) { + return { + kind: 'ok', + diff: [ + `diff --git a/${relativePath} b/${relativePath}`, + 'new file mode 100644', + `Binary files /dev/null and b/${relativePath} differ`, + '', + ].join('\n'), + } + } + + const content = buffer.toString('utf8') + const lines = content.split(/\r\n|\r|\n/) + if (lines[lines.length - 1] === '') { + lines.pop() + } + + const hunkLines = lines.map((line) => `+${line}`) + if (hunkLines.length === 0) { + hunkLines.push('+') + } + + return { + kind: 'ok', + diff: [ + `diff --git a/${relativePath} b/${relativePath}`, + 'new file mode 100644', + '--- /dev/null', + `+++ b/${relativePath}`, + `@@ -0,0 +1,${hunkLines.length} @@`, + ...hunkLines, + '', + ].join('\n'), + } + } + + private formatFsError( + prefix: string, + targetPath: string, + error: unknown, + ): string { + const err = error as NodeJS.ErrnoException + const code = err.code ? `${err.code}: ` : '' + return `${prefix} (${targetPath}): ${code}${err.message || 'unknown error'}` + } + + private formatGitError( + prefix: string, + args: string[], + workDir: string, + result: GitCommandResult, + ): string { + const stderr = result.stderr.trim() || result.stdout.trim() || 'unknown git failure' + return `${prefix} (git ${args.join(' ')} in ${workDir}): ${stderr}` + } +} From 3a6f45d17384820a26aa6855d834f3670dee37ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 29 Apr 2026 19:29:54 +0800 Subject: [PATCH 2/8] feat: make current turn changes reviewable and undoable Workspace inspection was visible only as a side panel, while file restore still lived behind the older per-message rewind affordance. This adds a chat-flow change card for the completed turn, keeps undo routed through checkpoint rewind, and tightens the workspace split layout so narrow and fullscreen windows keep both chat and file preview usable. Constraint: Current session file changes must work for non-Git directories when transcript tool edits are available Constraint: Workspace preview must remain right-docked without horizontal overflow across narrow and wide desktop viewports Rejected: Keep only hover rewind | users could not clearly see what the current turn changed before undoing it Rejected: Use Git as the only changed-file source | many desktop sessions run in temporary or non-Git directories Confidence: high Scope-risk: moderate Directive: Keep checkpoint rewind as the source of truth for undo semantics; workspace diffs are a preview surface, not the restore authority Tested: bun test src/server/__tests__/sessions.test.ts src/server/__tests__/workspace-service.test.ts Tested: cd desktop && bun run test -- src/components/chat/MessageList.test.tsx src/components/workspace/WorkspacePanel.test.tsx src/stores/workspacePanelStore.test.ts src/pages/ActiveSession.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Tested: bun /tmp/cc-haha-layout-e2e/run-layout-e2e.mjs Not-tested: Native Tauri packaged app window chrome behavior --- .../components/chat/CurrentTurnChangeCard.tsx | 199 ++++++++++++++++ .../src/components/chat/MessageList.test.tsx | 221 ++++++++++++++++++ desktop/src/components/chat/MessageList.tsx | 196 ++++++++++++++++ .../workspace/WorkspacePanel.test.tsx | 9 +- .../components/workspace/WorkspacePanel.tsx | 11 +- desktop/src/i18n/locales/en.ts | 10 + desktop/src/i18n/locales/zh.ts | 10 + desktop/src/pages/ActiveSession.test.tsx | 3 +- desktop/src/pages/ActiveSession.tsx | 6 +- src/server/__tests__/sessions.test.ts | 77 ++++++ src/server/services/sessionRewindService.ts | 44 +++- 11 files changed, 766 insertions(+), 20 deletions(-) create mode 100644 desktop/src/components/chat/CurrentTurnChangeCard.tsx diff --git a/desktop/src/components/chat/CurrentTurnChangeCard.tsx b/desktop/src/components/chat/CurrentTurnChangeCard.tsx new file mode 100644 index 00000000..60326a83 --- /dev/null +++ b/desktop/src/components/chat/CurrentTurnChangeCard.tsx @@ -0,0 +1,199 @@ +import { useCallback, useMemo, useState } from 'react' +import { sessionsApi, type SessionRewindResponse } from '../../api/sessions' +import { useTranslation } from '../../i18n' + +type DiffPreviewState = { + loading: boolean + diff?: string + error?: string +} + +type CurrentTurnChangeCardProps = { + sessionId: string + preview: SessionRewindResponse + workDir: string | null + error: string | null + isUndoing: boolean + onUndo: () => void +} + +export function CurrentTurnChangeCard({ + sessionId, + preview, + workDir, + error, + isUndoing, + onUndo, +}: CurrentTurnChangeCardProps) { + const t = useTranslation() + const [expandedPath, setExpandedPath] = useState(null) + const [diffByPath, setDiffByPath] = useState>({}) + + const files = useMemo( + () => preview.code.filesChanged.map((filePath) => relativizeWorkspacePath(filePath, workDir)), + [preview.code.filesChanged, workDir], + ) + + const toggleDiff = useCallback((filePath: string) => { + const nextExpandedPath = expandedPath === filePath ? null : filePath + setExpandedPath(nextExpandedPath) + if (!nextExpandedPath || diffByPath[filePath]?.diff || diffByPath[filePath]?.loading) { + return + } + + setDiffByPath((current) => ({ + ...current, + [filePath]: { loading: true }, + })) + + void sessionsApi + .getWorkspaceDiff(sessionId, filePath) + .then((result) => { + setDiffByPath((current) => ({ + ...current, + [filePath]: { + loading: false, + diff: result.state === 'ok' ? result.diff || '' : undefined, + error: result.state === 'ok' + ? undefined + : result.error || t('chat.turnChangesDiffUnavailable'), + }, + })) + }) + .catch((diffError) => { + setDiffByPath((current) => ({ + ...current, + [filePath]: { + loading: false, + error: diffError instanceof Error + ? diffError.message + : String(diffError), + }, + })) + }) + }, [diffByPath, expandedPath, sessionId, t]) + + return ( +
+
+
+
+ + {t('chat.turnChangesTitle', { count: files.length })} + + + +{preview.code.insertions} + + + -{preview.code.deletions} + +
+
+ {t('chat.turnChangesSubtitle')} +
+
+ + +
+ +
+ {files.map((filePath) => { + const isExpanded = expandedPath === filePath + const diffState = diffByPath[filePath] + return ( +
+ + + {isExpanded && ( +
+ {diffState?.loading ? ( +
+ {t('chat.turnChangesDiffLoading')} +
+ ) : diffState?.error ? ( +
+ {diffState.error} +
+ ) : diffState?.diff ? ( + + ) : ( +
+ {t('chat.turnChangesDiffUnavailable')} +
+ )} +
+ )} +
+ ) + })} +
+ + {error && ( +
+ {error} +
+ )} +
+ ) +} + +function DiffPreview({ diff }: { diff: string }) { + const lines = diff.split('\n').slice(0, 220) + return ( +
+      {lines.map((line, index) => {
+        const colorClass = line.startsWith('+') && !line.startsWith('+++')
+          ? 'text-[var(--color-success)]'
+          : line.startsWith('-') && !line.startsWith('---')
+            ? 'text-[var(--color-error)]'
+            : line.startsWith('@@')
+              ? 'text-[var(--color-brand)]'
+              : 'text-[var(--color-code-fg)]'
+        return (
+          
+ {line || ' '} +
+ ) + })} +
+ ) +} + +function relativizeWorkspacePath(filePath: string, workDir: string | null): string { + const normalizedPath = filePath.replace(/\\/g, '/') + if (!workDir || !normalizedPath.startsWith('/')) return normalizedPath + + const normalizedWorkDir = workDir.replace(/\\/g, '/').replace(/\/+$/, '') + if (normalizedPath === normalizedWorkDir) return '' + if (normalizedPath.startsWith(`${normalizedWorkDir}/`)) { + return normalizedPath.slice(normalizedWorkDir.length + 1) + } + return normalizedPath +} diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index ff2ae162..4597f680 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -682,6 +682,227 @@ describe('MessageList nested tool calls', () => { }) }) + it('shows a current-turn change card from checkpoint preview', async () => { + vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({ + target: { + targetUserMessageId: 'user-2', + userMessageIndex: 1, + userMessageCount: 2, + }, + conversation: { + messagesRemoved: 2, + }, + code: { + available: true, + filesChanged: ['src/App.tsx', 'src/lib/api.ts'], + insertions: 12, + deletions: 4, + }, + }) + vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({ + state: 'ok', + workDir: '/tmp/example-project', + repoName: 'example-project', + branch: null, + isGitRepo: false, + changedFiles: [], + }) + + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'user-1', + type: 'user_text', + content: '第一段', + timestamp: 1, + }, + { + id: 'assistant-1', + type: 'assistant_text', + content: 'ok', + timestamp: 2, + }, + { + id: 'user-2', + type: 'user_text', + content: '第二段', + timestamp: 3, + }, + { + id: 'assistant-2', + type: 'assistant_text', + content: 'done', + timestamp: 4, + }, + ], + }), + }, + }) + + render() + + expect(await screen.findByText('2 files changed')).toBeTruthy() + expect(screen.getByText('+12')).toBeTruthy() + expect(screen.getByText('-4')).toBeTruthy() + expect(screen.getByText('src/App.tsx')).toBeTruthy() + expect(screen.getByText('src/lib/api.ts')).toBeTruthy() + expect(sessionsApi.rewind).toHaveBeenCalledWith(ACTIVE_TAB, { + targetUserMessageId: 'user-2', + userMessageIndex: 1, + expectedContent: '第二段', + dryRun: true, + }) + }) + + it('expands a current-turn changed file diff', async () => { + vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({ + target: { + targetUserMessageId: 'user-1', + userMessageIndex: 0, + userMessageCount: 1, + }, + conversation: { + messagesRemoved: 2, + }, + code: { + available: true, + filesChanged: ['src/App.tsx'], + insertions: 1, + deletions: 1, + }, + }) + vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({ + state: 'ok', + workDir: '/tmp/example-project', + repoName: 'example-project', + branch: null, + isGitRepo: false, + changedFiles: [], + }) + vi.spyOn(sessionsApi, 'getWorkspaceDiff').mockResolvedValue({ + state: 'ok', + path: 'src/App.tsx', + diff: 'diff --session a/src/App.tsx b/src/App.tsx\n-old\n+new', + }) + + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'user-1', + type: 'user_text', + content: '改一下', + timestamp: 1, + }, + { + id: 'assistant-1', + type: 'assistant_text', + content: 'done', + timestamp: 2, + }, + ], + }), + }, + }) + + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Show diff for src/App.tsx' })) + + expect(await screen.findByText('+new')).toBeTruthy() + expect(sessionsApi.getWorkspaceDiff).toHaveBeenCalledWith(ACTIVE_TAB, 'src/App.tsx') + }) + + it('undoes the current turn from the change card using checkpoint rewind', async () => { + vi.spyOn(sessionsApi, 'rewind') + .mockResolvedValueOnce({ + target: { + targetUserMessageId: 'user-1', + userMessageIndex: 0, + userMessageCount: 1, + }, + conversation: { + messagesRemoved: 2, + }, + code: { + available: true, + filesChanged: ['src/App.tsx'], + insertions: 1, + deletions: 0, + }, + }) + .mockResolvedValueOnce({ + target: { + targetUserMessageId: 'user-1', + userMessageIndex: 0, + userMessageCount: 1, + }, + conversation: { + messagesRemoved: 2, + removedMessageIds: ['user-1', 'assistant-1'], + }, + code: { + available: true, + filesChanged: ['src/App.tsx'], + insertions: 1, + deletions: 0, + }, + }) + vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({ + state: 'ok', + workDir: '/tmp/example-project', + repoName: 'example-project', + branch: null, + isGitRepo: false, + changedFiles: [], + }) + const reloadHistory = vi.fn().mockResolvedValue(undefined) + const queueComposerPrefill = vi.fn() + + useChatStore.setState({ + reloadHistory, + queueComposerPrefill, + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'user-1', + type: 'user_text', + content: '做一个页面', + timestamp: 1, + }, + { + id: 'assistant-1', + type: 'assistant_text', + content: 'done', + timestamp: 2, + }, + ], + }), + }, + }) + + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Undo current turn changes' })) + + await waitFor(() => { + expect(sessionsApi.rewind).toHaveBeenLastCalledWith(ACTIVE_TAB, { + targetUserMessageId: 'user-1', + userMessageIndex: 0, + expectedContent: '做一个页面', + }) + }) + expect(reloadHistory).toHaveBeenCalledWith(ACTIVE_TAB) + expect(queueComposerPrefill).toHaveBeenCalledWith(ACTIVE_TAB, { + text: '做一个页面', + attachments: undefined, + }) + }) + it('shows raw startup details under translated CLI startup errors', () => { useChatStore.setState({ sessions: { diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 2f7d5bbc..3ec44029 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -17,6 +17,7 @@ import { PermissionDialog } from './PermissionDialog' import { AskUserQuestion } from './AskUserQuestion' import { StreamingIndicator } from './StreamingIndicator' import { InlineTaskSummary } from './InlineTaskSummary' +import { CurrentTurnChangeCard } from './CurrentTurnChangeCard' import type { AgentTaskNotification, UIMessage } from '../../types/chat' import { Modal } from '../shared/Modal' import { Button } from '../shared/Button' @@ -34,6 +35,19 @@ type RenderModel = { childToolCallsByParent: Map } +type RewindTurnTarget = { + messageId: string + userMessageIndex: number + content: string + attachments?: Extract['attachments'] +} + +type CurrentTurnPreview = { + target: RewindTurnTarget + preview: SessionRewindResponse + workDir: string | null +} + function appendChildToolCall( childToolCallsByParent: Map, parentToolUseId: string, @@ -104,6 +118,41 @@ export function buildRenderModel(messages: UIMessage[]): RenderModel { return { renderItems: items, toolResultMap, childToolCallsByParent } } +export function getLatestCompletedTurnTarget(messages: UIMessage[]): RewindTurnTarget | null { + let userMessageIndex = -1 + let latestTarget: (RewindTurnTarget & { messageOffset: number }) | null = null + + for (let messageOffset = 0; messageOffset < messages.length; messageOffset += 1) { + const message = messages[messageOffset] + if (!message || message.type !== 'user_text' || message.pending) continue + userMessageIndex += 1 + latestTarget = { + messageId: message.id, + userMessageIndex, + content: message.content, + attachments: message.attachments, + messageOffset, + } + } + + if (!latestTarget) return null + + const hasResponseAfterTarget = messages + .slice(latestTarget.messageOffset + 1) + .some((message) => + message.type === 'assistant_text' || + message.type === 'tool_use' || + message.type === 'tool_result' || + message.type === 'error' || + message.type === 'task_summary', + ) + + if (!hasResponseAfterTarget) return null + + const { messageOffset: _messageOffset, ...target } = latestTarget + return target +} + type MessageListProps = { sessionId?: string | null compact?: boolean @@ -151,6 +200,10 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { const [rewindError, setRewindError] = useState(null) const [isLoadingPreview, setIsLoadingPreview] = useState(false) const [isExecutingRewind, setIsExecutingRewind] = useState(false) + const [currentTurnPreview, setCurrentTurnPreview] = useState(null) + const [currentTurnError, setCurrentTurnError] = useState(null) + const [isLoadingCurrentTurnPreview, setIsLoadingCurrentTurnPreview] = useState(false) + const [isUndoingCurrentTurn, setIsUndoingCurrentTurn] = useState(false) const updateAutoScrollState = useCallback(() => { const container = scrollContainerRef.current @@ -216,6 +269,69 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { () => buildRenderModel(messages), [messages], ) + const latestTurnTarget = useMemo(() => getLatestCompletedTurnTarget(messages), [messages]) + + useEffect(() => { + if ( + !resolvedSessionId || + !latestTurnTarget || + chatState !== 'idle' || + isMemberSession + ) { + setCurrentTurnPreview(null) + setCurrentTurnError(null) + setIsLoadingCurrentTurnPreview(false) + return + } + + let cancelled = false + setIsLoadingCurrentTurnPreview(true) + setCurrentTurnPreview(null) + setCurrentTurnError(null) + + Promise.all([ + sessionsApi.rewind(resolvedSessionId, { + targetUserMessageId: latestTurnTarget.messageId, + userMessageIndex: latestTurnTarget.userMessageIndex, + expectedContent: latestTurnTarget.content, + dryRun: true, + }), + sessionsApi.getWorkspaceStatus(resolvedSessionId).catch(() => null), + ]) + .then(([preview, workspaceStatus]) => { + if (cancelled) return + if (!preview.code.available || preview.code.filesChanged.length === 0) { + setCurrentTurnPreview(null) + return + } + setCurrentTurnPreview({ + target: latestTurnTarget, + preview, + workDir: workspaceStatus?.workDir ?? null, + }) + }) + .catch((error) => { + if (cancelled) return + const message = + error instanceof ApiError + ? typeof error.body === 'object' && error.body && 'message' in error.body + ? String((error.body as { message: unknown }).message) + : error.message + : error instanceof Error + ? error.message + : String(error) + setCurrentTurnError(message) + }) + .finally(() => { + if (!cancelled) { + setIsLoadingCurrentTurnPreview(false) + } + }) + + return () => { + cancelled = true + } + }, [chatState, isMemberSession, latestTurnTarget, resolvedSessionId]) const closeRewindModal = useCallback(() => { if (isExecutingRewind) return @@ -286,6 +402,67 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { t, ]) + const handleUndoCurrentTurn = useCallback(async () => { + if (!resolvedSessionId || !currentTurnPreview || isUndoingCurrentTurn) return + + const target = currentTurnPreview.target + setIsUndoingCurrentTurn(true) + setCurrentTurnError(null) + + try { + if (chatState !== 'idle') { + stopGeneration(resolvedSessionId) + } + + const result = await sessionsApi.rewind(resolvedSessionId, { + targetUserMessageId: target.messageId, + userMessageIndex: target.userMessageIndex, + expectedContent: target.content, + }) + + await reloadHistory(resolvedSessionId) + queueComposerPrefill(resolvedSessionId, { + text: target.content, + attachments: target.attachments, + }) + + addToast({ + type: 'success', + message: result.code.available + ? t('chat.rewindSuccessWithCode', { + count: result.conversation.messagesRemoved, + }) + : t('chat.rewindSuccessConversationOnly', { + count: result.conversation.messagesRemoved, + }), + }) + + setCurrentTurnPreview(null) + } catch (error) { + const message = + error instanceof ApiError + ? typeof error.body === 'object' && error.body && 'message' in error.body + ? String((error.body as { message: unknown }).message) + : error.message + : error instanceof Error + ? error.message + : String(error) + setCurrentTurnError(message) + } finally { + setIsUndoingCurrentTurn(false) + } + }, [ + addToast, + chatState, + currentTurnPreview, + isUndoingCurrentTurn, + queueComposerPrefill, + reloadHistory, + resolvedSessionId, + stopGeneration, + t, + ]) + let visibleUserMessageIndex = -1 return ( @@ -360,6 +537,25 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { )} + {!isLoadingCurrentTurnPreview && currentTurnPreview && resolvedSessionId && ( + { + void handleUndoCurrentTurn() + }} + /> + )} + + {!currentTurnPreview && currentTurnError && ( +
+ {currentTurnError} +
+ )} +
diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx index 500ecb0f..4d969ba4 100644 --- a/desktop/src/components/workspace/WorkspacePanel.test.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx @@ -164,7 +164,10 @@ describe('WorkspacePanel', () => { const view = await renderPanel('session-changed') - expect(view.getByTestId('workspace-panel').style.maxWidth).toBe('calc(100% - 348px)') + const compactPanel = view.getByTestId('workspace-panel') + expect(compactPanel.style.width).toBe('520px') + expect(compactPanel.style.maxWidth).toBe('36%') + expect(compactPanel.style.minWidth).toBe('min(340px, 40%)') await waitFor(() => { expect(getMocks().getWorkspaceStatusMock).toHaveBeenCalledWith('session-changed') @@ -209,6 +212,10 @@ describe('WorkspacePanel', () => { await waitFor(() => { expect(view.getByTestId('workspace-code').textContent).toContain('console.log("new")') }) + const expandedPanel = view.getByTestId('workspace-panel') + expect(expandedPanel.style.width).toBe('860px') + expect(expandedPanel.style.maxWidth).toBe('min(62%, calc(100% - 328px))') + expect(expandedPanel.style.minWidth).toBe('min(420px, 54%)') expect(view.getAllByText('Diff').length).toBeGreaterThan(0) }) diff --git a/desktop/src/components/workspace/WorkspacePanel.tsx b/desktop/src/components/workspace/WorkspacePanel.tsx index a7007105..50431de4 100644 --- a/desktop/src/components/workspace/WorkspacePanel.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.tsx @@ -738,6 +738,11 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) { if (!isOpen) return null + const hasPreviewTabs = previewTabs.length > 0 + const panelWidth = hasPreviewTabs ? width : Math.min(width, 520) + const panelMaxWidth = hasPreviewTabs ? 'min(62%, calc(100% - 328px))' : '36%' + const panelMinWidth = hasPreviewTabs ? 'min(420px, 54%)' : 'min(340px, 40%)' + const handleRefresh = () => { void loadStatus(sessionId) if (activeView === 'all') { @@ -975,9 +980,9 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {