From f5cf5932b491a2e9f97b61fb4a46d0b9f169fd19 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: Fri, 1 May 2026 20:53:04 +0800 Subject: [PATCH] fix: keep workspace changes and session checkpoints reliable The desktop workspace panel and chat transcript were mixing project-level file state with per-turn session history, which made rewind, file attachment, and changed-file previews brittle across multi-turn and historical sessions. This keeps turn checkpoints durable in the transcript, makes workspace refreshes happen at the right lifecycle points, and hardens long file previews without blocking the UI. Constraint: Right-side workspace changes are project working-tree state, while chat turn cards are session checkpoint state. Rejected: Treat every changed-file panel entry as session-local | new sessions must still reveal existing dirty project files. Confidence: high Scope-risk: moderate Directive: Do not couple global workspace status to session checkpoint cards without preserving both product meanings. Tested: cd desktop && bun run test src/components/workspace/WorkspacePanel.test.tsx Tested: cd desktop && bun run lint Tested: git diff --check Tested: cd desktop && bun run test Not-tested: Packaged Tauri runtime smoke test --- desktop/src/api/sessions.ts | 29 + .../src/components/chat/ChatInput.test.tsx | 158 +++++ desktop/src/components/chat/ChatInput.tsx | 27 +- .../components/chat/CurrentTurnChangeCard.tsx | 81 ++- .../components/chat/FileSearchMenu.test.tsx | 63 +- .../src/components/chat/FileSearchMenu.tsx | 24 +- .../src/components/chat/MessageList.test.tsx | 612 +++++++++++------- desktop/src/components/chat/MessageList.tsx | 389 ++++++----- .../workspace/WorkspaceCodeSurface.tsx | 34 +- .../workspace/WorkspacePanel.test.tsx | 203 +++++- .../components/workspace/WorkspacePanel.tsx | 261 +++++--- desktop/src/i18n/locales/en.ts | 26 +- desktop/src/i18n/locales/zh.ts | 26 +- desktop/src/pages/EmptySession.tsx | 37 +- desktop/src/stores/chatStore.test.ts | 50 +- desktop/src/stores/chatStore.ts | 13 +- .../src/stores/workspacePanelStore.test.ts | 59 ++ desktop/src/stores/workspacePanelStore.ts | 11 +- src/server/__tests__/sessions.test.ts | 256 ++++++++ src/server/api/sessions.ts | 51 ++ src/server/services/sessionRewindService.ts | 421 +++++++++++- 21 files changed, 2257 insertions(+), 574 deletions(-) create mode 100644 desktop/src/components/chat/ChatInput.test.tsx diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index 137bdbd0..d4ebf8a4 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -201,6 +201,22 @@ export type WorkspaceDiffResult = { error?: string } +export type SessionTurnCheckpoint = { + target: SessionRewindResponse['target'] + conversation?: SessionRewindResponse['conversation'] + code: SessionRewindResponse['code'] + workDir?: string +} + +export type SessionTurnCheckpointsResponse = { + checkpoints: SessionTurnCheckpoint[] +} + +export type TurnCheckpointDiffResult = WorkspaceDiffResult & { + target?: SessionRewindResponse['target'] + workDir?: string +} + function buildWorkspacePath( sessionId: string, resource: 'status' | 'tree' | 'file' | 'diff', @@ -279,6 +295,19 @@ export const sessionsApi = { return api.get(buildWorkspacePath(sessionId, 'diff', workspacePath)) }, + getTurnCheckpoints(sessionId: string) { + return api.get(`/api/sessions/${sessionId}/turn-checkpoints`) + }, + + getTurnCheckpointDiff(sessionId: string, targetUserMessageId: string, workspacePath: string) { + const query = new URLSearchParams() + query.set('targetUserMessageId', targetUserMessageId) + query.set('path', workspacePath) + return api.get( + `/api/sessions/${sessionId}/turn-checkpoints/diff?${query.toString()}`, + ) + }, + rewind(sessionId: string, body: { targetUserMessageId?: string userMessageIndex?: number diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx new file mode 100644 index 00000000..acbcae60 --- /dev/null +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -0,0 +1,158 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import '@testing-library/jest-dom' + +const mocks = vi.hoisted(() => ({ + getGitInfo: vi.fn(), + search: vi.fn(), + browse: vi.fn(), + wsSend: vi.fn(), +})) + +vi.mock('../../api/sessions', () => ({ + sessionsApi: { + getGitInfo: mocks.getGitInfo, + }, +})) + +vi.mock('../../api/filesystem', () => ({ + filesystemApi: { + search: mocks.search, + browse: mocks.browse, + }, +})) + +vi.mock('../../api/websocket', () => ({ + wsManager: { + connect: vi.fn(), + disconnect: vi.fn(), + onMessage: vi.fn(() => () => {}), + clearHandlers: vi.fn(), + send: mocks.wsSend, + }, +})) + +vi.mock('../controls/PermissionModeSelector', () => ({ + PermissionModeSelector: () => , +})) + +vi.mock('../controls/ModelSelector', () => ({ + ModelSelector: () => , +})) + +import { ChatInput } from './ChatInput' +import { useChatStore } from '../../stores/chatStore' +import { useSessionStore } from '../../stores/sessionStore' +import { useSettingsStore } from '../../stores/settingsStore' +import { useTabStore } from '../../stores/tabStore' +import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore' + +describe('ChatInput file mentions', () => { + const sessionId = 'session-file-mention' + const initialChatState = useChatStore.getInitialState() + const initialSessionState = useSessionStore.getInitialState() + const initialTabState = useTabStore.getInitialState() + const initialWorkspaceContextState = useWorkspaceChatContextStore.getInitialState() + + beforeEach(() => { + vi.clearAllMocks() + useSettingsStore.setState({ locale: 'en' }) + useChatStore.setState(initialChatState, true) + useSessionStore.setState(initialSessionState, true) + useTabStore.setState(initialTabState, true) + useWorkspaceChatContextStore.setState(initialWorkspaceContextState, true) + + useTabStore.setState({ + activeTabId: sessionId, + tabs: [{ sessionId, title: 'Project', type: 'session', status: 'idle' }], + }) + useSessionStore.setState({ + sessions: [{ + id: sessionId, + title: 'Project', + createdAt: '2026-05-01T00:00:00.000Z', + modifiedAt: '2026-05-01T00:00:00.000Z', + messageCount: 1, + projectPath: '/repo', + workDir: '/repo', + workDirExists: true, + }], + activeSessionId: sessionId, + }) + useChatStore.setState({ + sessions: { + [sessionId]: { + messages: [{ id: 'existing', type: 'assistant_text', content: 'ready', 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, + }, + }, + }) + mocks.getGitInfo.mockResolvedValue({ branch: 'main', repoName: 'repo', workDir: '/repo', changedFiles: 0 }) + }) + + it('turns a selected @ file into a chip without corrupting the typed path', async () => { + mocks.search.mockResolvedValueOnce({ + currentPath: '/repo/backend/src', + parentPath: '/repo/backend', + query: 'conditions.py', + entries: [ + { name: 'conditions.py', path: '/repo/backend/src/conditions.py', isDirectory: false }, + ], + }) + + render() + + const input = screen.getByRole('textbox') as HTMLTextAreaElement + const mention = '@backend/src/conditions.py' + fireEvent.change(input, { + target: { + value: `${mention} 记一下这个文件讲了什么东西。`, + selectionStart: mention.length, + }, + }) + + fireEvent.click(await screen.findByText('conditions.py')) + + await waitFor(() => { + expect(input.value).toBe('记一下这个文件讲了什么东西。') + }) + expect(screen.getByText('conditions.py')).toBeInTheDocument() + + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, { + type: 'user_message', + content: '记一下这个文件讲了什么东西。', + attachments: [{ + type: 'file', + name: 'conditions.py', + path: '/repo/backend/src/conditions.py', + lineStart: undefined, + lineEnd: undefined, + note: undefined, + quote: undefined, + }], + }) + const messages = useChatStore.getState().sessions[sessionId]?.messages ?? [] + expect(messages[messages.length - 1]).toMatchObject({ + type: 'user_text', + content: '记一下这个文件讲了什么东西。', + modelContent: '@"/repo/backend/src/conditions.py" 记一下这个文件讲了什么东西。', + attachments: [{ name: 'conditions.py', path: '/repo/backend/src/conditions.py' }], + }) + }) +}) diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index ad884be1..4d7b66c7 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -310,7 +310,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro // Extract filter text after @ const filter = textBeforeCursor.slice(pos + 1) setAtFilter(filter) - setAtCursorPos(cursorPos) + setAtCursorPos(pos) setSlashMenuOpen(false) setFileSearchOpen(true) }, []) @@ -629,17 +629,34 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro ref={fileSearchRef} cwd={resolvedWorkDir || ''} filter={atFilter} + onNavigate={(relativePath) => { + if (atCursorPos < 0) return + const replacement = `@${relativePath}` + const tokenEnd = atCursorPos + 1 + atFilter.length + const newValue = `${input.slice(0, atCursorPos)}${replacement}${input.slice(tokenEnd)}` + const newCursorPos = atCursorPos + replacement.length + setInput(newValue) + setAtFilter(relativePath) + requestAnimationFrame(() => { + textareaRef.current?.focus() + textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos) + }) + }} onSelect={(path, name) => { if (atCursorPos >= 0) { - // Insert name at cursor position, replacing filter text - const newValue = `${input.slice(0, atCursorPos)}${name}${input.slice(atCursorPos)}` - const newCursorPos = atCursorPos + name.length + const referenceName = name.split('/').filter(Boolean).pop() ?? name + const tokenEnd = atCursorPos + 1 + atFilter.length + const beforeToken = input.slice(0, atCursorPos) + const afterToken = beforeToken ? input.slice(tokenEnd) : input.slice(tokenEnd).replace(/^\s+/, '') + const spacer = beforeToken && afterToken && !/\s$/.test(beforeToken) && !/^\s/.test(afterToken) ? ' ' : '' + const newValue = `${beforeToken}${spacer}${afterToken}` + const newCursorPos = atCursorPos + spacer.length if (activeTabId) { addWorkspaceReference(activeTabId, { kind: 'file', path, absolutePath: path, - name, + name: referenceName, }) } setInput(newValue) diff --git a/desktop/src/components/chat/CurrentTurnChangeCard.tsx b/desktop/src/components/chat/CurrentTurnChangeCard.tsx index 23109e21..c425bbd8 100644 --- a/desktop/src/components/chat/CurrentTurnChangeCard.tsx +++ b/desktop/src/components/chat/CurrentTurnChangeCard.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo, useState } from 'react' -import { sessionsApi, type SessionRewindResponse } from '../../api/sessions' +import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions' import { useTranslation } from '../../i18n' import { WorkspaceDiffSurface } from '../workspace/WorkspaceCodeSurface' @@ -11,48 +11,60 @@ type DiffPreviewState = { type CurrentTurnChangeCardProps = { sessionId: string - preview: SessionRewindResponse + targetUserMessageId: string + checkpoint: SessionTurnCheckpoint workDir: string | null error: string | null isUndoing: boolean + isLatest: boolean onUndo: () => void } +type ChangedFileEntry = { + apiPath: string + displayPath: string +} + export function CurrentTurnChangeCard({ sessionId, - preview, + targetUserMessageId, + checkpoint, workDir, error, isUndoing, + isLatest, 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 files = useMemo( + () => checkpoint.code.filesChanged.map((filePath) => ({ + apiPath: filePath, + displayPath: relativizeWorkspacePath(filePath, workDir), + })), + [checkpoint.code.filesChanged, workDir], ) - const toggleDiff = useCallback((filePath: string) => { - const nextExpandedPath = expandedPath === filePath ? null : filePath + const toggleDiff = useCallback((fileEntry: ChangedFileEntry) => { + const nextExpandedPath = expandedPath === fileEntry.apiPath ? null : fileEntry.apiPath setExpandedPath(nextExpandedPath) - if (!nextExpandedPath || diffByPath[filePath]?.diff || diffByPath[filePath]?.loading) { + if (!nextExpandedPath || diffByPath[fileEntry.apiPath]?.diff || diffByPath[fileEntry.apiPath]?.loading) { return } setDiffByPath((current) => ({ ...current, - [filePath]: { loading: true }, + [fileEntry.apiPath]: { loading: true }, })) void sessionsApi - .getWorkspaceDiff(sessionId, filePath) + .getTurnCheckpointDiff(sessionId, targetUserMessageId, fileEntry.apiPath) .then((result) => { setDiffByPath((current) => ({ ...current, - [filePath]: { + [fileEntry.apiPath]: { loading: false, diff: result.state === 'ok' ? result.diff || '' : undefined, error: result.state === 'ok' @@ -64,7 +76,7 @@ export function CurrentTurnChangeCard({ .catch((diffError) => { setDiffByPath((current) => ({ ...current, - [filePath]: { + [fileEntry.apiPath]: { loading: false, error: diffError instanceof Error ? diffError.message @@ -72,12 +84,25 @@ export function CurrentTurnChangeCard({ }, })) }) - }, [diffByPath, expandedPath, sessionId, t]) + }, [diffByPath, expandedPath, sessionId, t, targetUserMessageId]) + + const cardLabel = isLatest + ? t('chat.turnChangesLatestCardLabel') + : t('chat.turnChangesHistoricalCardLabel') + const subtitle = isLatest + ? t('chat.turnChangesLatestSubtitle') + : t('chat.turnChangesHistoricalSubtitle') + const undoLabel = isLatest + ? t('chat.turnChangesLatestUndo') + : t('chat.turnChangesHistoricalUndo') + const undoAria = isLatest + ? t('chat.turnChangesLatestUndoAria') + : t('chat.turnChangesHistoricalUndoAria') return (
@@ -86,14 +111,14 @@ export function CurrentTurnChangeCard({ {t('chat.turnChangesTitle', { count: files.length })} - +{preview.code.insertions} + +{checkpoint.code.insertions} - -{preview.code.deletions} + -{checkpoint.code.deletions}
- {t('chat.turnChangesSubtitle')} + {subtitle}
@@ -101,26 +126,26 @@ export function CurrentTurnChangeCard({ type="button" onClick={onUndo} disabled={isUndoing} - aria-label={t('chat.turnChangesUndoAria')} + aria-label={undoAria} className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-brand)]/40 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-50" > undo - {isUndoing ? t('chat.turnChangesUndoing') : t('chat.turnChangesUndo')} + {isUndoing ? t('chat.turnChangesUndoing') : undoLabel}
- {files.map((filePath) => { - const isExpanded = expandedPath === filePath - const diffState = diffByPath[filePath] + {files.map((fileEntry) => { + const isExpanded = expandedPath === fileEntry.apiPath + const diffState = diffByPath[fileEntry.apiPath] return ( -
+
@@ -145,7 +170,7 @@ export function CurrentTurnChangeCard({ ) : diffState?.diff ? ( ) : ( diff --git a/desktop/src/components/chat/FileSearchMenu.test.tsx b/desktop/src/components/chat/FileSearchMenu.test.tsx index ba6cd561..00aed914 100644 --- a/desktop/src/components/chat/FileSearchMenu.test.tsx +++ b/desktop/src/components/chat/FileSearchMenu.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' -import { render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' import { FileSearchMenu } from './FileSearchMenu' import { ApiError } from '../../api/client' @@ -55,4 +55,65 @@ describe('FileSearchMenu', () => { expect(screen.getByText('preview.png')).toBeInTheDocument() }) }) + + it('navigates directories without selecting them as attachments', async () => { + const onSelect = vi.fn() + const onNavigate = vi.fn() + vi.mocked(filesystemApi.search).mockResolvedValueOnce({ + currentPath: '/repo', + parentPath: '/', + query: 'backend', + entries: [ + { name: 'backend', path: '/repo/backend', isDirectory: true }, + ], + }) + vi.mocked(filesystemApi.browse).mockResolvedValueOnce({ + currentPath: '/repo/backend', + parentPath: '/repo', + entries: [ + { name: 'src', path: '/repo/backend/src', isDirectory: true }, + ], + }) + + render( + , + ) + + fireEvent.click(await screen.findByText('backend')) + + expect(onSelect).not.toHaveBeenCalled() + expect(onNavigate).toHaveBeenCalledWith('backend/') + await waitFor(() => { + expect(filesystemApi.browse).toHaveBeenCalledWith('/repo/backend', { includeFiles: true }) + }) + }) + + it('passes nested relative file paths when selecting a file', async () => { + const onSelect = vi.fn() + vi.mocked(filesystemApi.search).mockResolvedValueOnce({ + currentPath: '/repo/backend/src', + parentPath: '/repo/backend', + query: 'pictactic', + entries: [ + { name: 'pictactic', path: '/repo/backend/src/pictactic', isDirectory: false }, + ], + }) + + render( + , + ) + + fireEvent.click(await screen.findByText('pictactic')) + + expect(onSelect).toHaveBeenCalledWith('/repo/backend/src/pictactic', 'backend/src/pictactic') + }) }) diff --git a/desktop/src/components/chat/FileSearchMenu.tsx b/desktop/src/components/chat/FileSearchMenu.tsx index f89c1531..89ed23c7 100644 --- a/desktop/src/components/chat/FileSearchMenu.tsx +++ b/desktop/src/components/chat/FileSearchMenu.tsx @@ -18,9 +18,14 @@ type Props = { cwd: string filter?: string onSelect: (path: string, relativePath: string) => void + onNavigate?: (relativePath: string) => void } -export const FileSearchMenu = forwardRef(({ cwd, filter = '', onSelect }, ref) => { +function joinRelativePath(base: string, name: string) { + return [base.replace(/\/+$/, ''), name].filter(Boolean).join('/') +} + +export const FileSearchMenu = forwardRef(({ cwd, filter = '', onSelect, onNavigate }, ref) => { const t = useTranslation() const [entries, setEntries] = useState([]) const [errorMessage, setErrorMessage] = useState(null) @@ -118,13 +123,19 @@ export const FileSearchMenu = forwardRef(({ cwd, fi } if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault() - if (entries[selectedIndex]) { - onSelect(entries[selectedIndex]!.path, entries[selectedIndex]!.name) + const selected = entries[selectedIndex] + if (selected) { + if (selected.isDirectory) { + void loadDir(selected.path, '') + onNavigate?.(`${joinRelativePath(filter.slice(0, filter.lastIndexOf('/') + 1), selected.name)}/`) + } else { + onSelect(selected.path, joinRelativePath(filter.slice(0, filter.lastIndexOf('/') + 1), selected.name)) + } } return } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [entries, selectedIndex]) + }, [entries, selectedIndex, filter, loadDir, onNavigate, onSelect]) useImperativeHandle(ref, () => ({ handleKeyDown }), [handleKeyDown]) @@ -185,7 +196,8 @@ export const FileSearchMenu = forwardRef(({ cwd, fi key={entry.path} data-index={i} onClick={() => { - void loadDir(entry.path, filter) + void loadDir(entry.path, '') + onNavigate?.(`${joinRelativePath(filter.slice(0, filter.lastIndexOf('/') + 1), entry.name)}/`) }} onMouseEnter={() => setSelectedIndex(i)} className={`w-full flex items-center gap-3 px-3 py-2 text-left transition-colors ${ @@ -204,7 +216,7 @@ export const FileSearchMenu = forwardRef(({ cwd, fi
) diff --git a/desktop/src/components/workspace/WorkspaceCodeSurface.tsx b/desktop/src/components/workspace/WorkspaceCodeSurface.tsx index 601e4d2f..5dc32076 100644 --- a/desktop/src/components/workspace/WorkspaceCodeSurface.tsx +++ b/desktop/src/components/workspace/WorkspaceCodeSurface.tsx @@ -1,7 +1,9 @@ +import { useEffect, useState } from 'react' import { Highlight, type PrismTheme } from 'prism-react-renderer' import { useTranslation } from '../../i18n' -export const WORKSPACE_PREVIEW_LINE_LIMIT = 420 +export const WORKSPACE_PREVIEW_LINE_LIMIT = 2000 +export const WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD = 5000 export const workspacePrismTheme: PrismTheme = { plain: { @@ -95,10 +97,15 @@ export function WorkspaceDiffSurface({ lineLimit?: number }) { const t = useTranslation() + const [showAllLines, setShowAllLines] = useState(false) const lines = value.split('\n') - const visibleLines = lines.slice(0, lineLimit) - const hiddenLineCount = Math.max(0, lines.length - visibleLines.length) + const visibleLines = showAllLines ? lines : lines.slice(0, lineLimit) const language = getLanguageFromPath(path) + const usePlainLargePreview = showAllLines && lines.length > WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD + + useEffect(() => { + setShowAllLines(false) + }, [path, value]) return (
@@ -119,7 +126,7 @@ export function WorkspaceDiffSurface({ return (
- {isCodeLine ? ( + {isCodeLine && !usePlainLargePreview ? ( code ? : ' ' ) : ( code || ' ' @@ -163,9 +170,20 @@ export function WorkspaceDiffSurface({ ) })} - {hiddenLineCount > 0 && ( -
- {t('workspace.previewLineLimit', { count: lineLimit })} + {lines.length > lineLimit && ( +
+ + {showAllLines + ? t('workspace.previewAllLines', { total: lines.length }) + : t('workspace.previewLineLimit', { count: visibleLines.length, total: lines.length })} + +
)}
diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx index 5c9e7fe0..20f226cb 100644 --- a/desktop/src/components/workspace/WorkspacePanel.test.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx @@ -120,6 +120,7 @@ vi.mock('../../api/sessions', () => ({ })) import { useSettingsStore } from '../../stores/settingsStore' +import { useChatStore } from '../../stores/chatStore' import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore' import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' import { WorkspacePanel } from './WorkspacePanel' @@ -128,17 +129,38 @@ describe('WorkspacePanel', () => { const workspaceInitialState = useWorkspacePanelStore.getInitialState() const workspaceChatInitialState = useWorkspaceChatContextStore.getInitialState() const settingsInitialState = useSettingsStore.getInitialState() + const chatInitialState = useChatStore.getInitialState() beforeEach(async () => { vi.clearAllMocks() await setWorkspaceState(workspaceInitialState) + useChatStore.setState(chatInitialState, true) useWorkspaceChatContextStore.setState(workspaceChatInitialState, true) await setSettingsState({ ...settingsInitialState, locale: 'en' }) + + getMocks().getWorkspaceStatusMock.mockImplementation(async (sessionId: string) => + useWorkspacePanelStore.getState().statusBySession[sessionId] ?? { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + ) + getMocks().getWorkspaceTreeMock.mockImplementation(async (sessionId: string, path = '') => + useWorkspacePanelStore.getState().treeBySessionPath[sessionId]?.[path] ?? { + state: 'ok', + path, + entries: [], + }, + ) }) afterEach(async () => { cleanup() await setWorkspaceState(workspaceInitialState) + useChatStore.setState(chatInitialState, true) useWorkspaceChatContextStore.setState(workspaceChatInitialState, true) await setSettingsState(settingsInitialState) vi.restoreAllMocks() @@ -234,6 +256,101 @@ describe('WorkspacePanel', () => { expect(view.getAllByText('Diff').length).toBeGreaterThan(0) }) + it('refreshes status on open and switches back to changed files when new changes exist', async () => { + getMocks().getWorkspaceStatusMock.mockResolvedValue({ + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [ + { + path: 'src/Fresh.ts', + status: 'modified', + additions: 4, + deletions: 1, + }, + ], + }) + getMocks().getWorkspaceTreeMock.mockResolvedValue({ + state: 'ok', + path: '', + entries: [{ name: 'src', path: 'src', isDirectory: true }], + }) + + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-stale-all': { + isOpen: true, + activeView: 'all', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-stale-all': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + })) + + const view = await renderPanel('session-stale-all') + + await waitFor(() => { + expect(getMocks().getWorkspaceStatusMock).toHaveBeenCalledWith('session-stale-all') + }) + await waitFor(() => { + expect(view.getByRole('button', { name: 'Changed files' })).toBeTruthy() + }) + expect(view.getByText('src/Fresh.ts')).toBeTruthy() + }) + + it('loads workspace status when opened while the chat is running', async () => { + getMocks().getWorkspaceStatusMock.mockResolvedValue({ + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [ + { + path: 'src/running.ts', + status: 'modified', + additions: 1, + deletions: 0, + }, + ], + }) + + useChatStore.setState({ + sessions: { + 'session-running-open': { + chatState: 'thinking', + } as never, + }, + }) + + await act(() => { + useWorkspacePanelStore.getState().openPanel('session-running-open') + }) + + const view = await renderPanel('session-running-open') + + await waitFor(() => { + expect(getMocks().getWorkspaceStatusMock).toHaveBeenCalledWith('session-running-open') + }) + await waitFor(() => { + expect(view.getByText('src/running.ts')).toBeTruthy() + }) + expect(view.queryByText('Loading...')).toBeNull() + }) + it('renders transcript-derived changed files for non-git sessions', async () => { getMocks().getWorkspaceStatusMock.mockResolvedValue({ state: 'ok', @@ -459,6 +576,7 @@ describe('WorkspacePanel', () => { 'session-tabs': { isOpen: true, activeView: 'changed', + hasUserSelectedView: true, }, }, statusBySession: { @@ -534,6 +652,7 @@ describe('WorkspacePanel', () => { 'session-dark-theme': { isOpen: true, activeView: 'changed', + hasUserSelectedView: true, }, }, statusBySession: { @@ -579,8 +698,8 @@ describe('WorkspacePanel', () => { expect(classNameContains(codeSurface, 'bg-white')).toBe(false) }) - it('caps rendered preview lines to keep large diffs responsive', async () => { - const longDiff = Array.from({ length: 650 }, (_, index) => `+line ${index + 1}`).join('\n') + it('can expand long diff previews beyond the default rendered line cap', async () => { + const longDiff = Array.from({ length: 2300 }, (_, index) => `+line ${index + 1}`).join('\n') await setWorkspaceState((state) => ({ ...state, @@ -589,6 +708,7 @@ describe('WorkspacePanel', () => { 'session-large-preview': { isOpen: true, activeView: 'changed', + hasUserSelectedView: true, }, }, statusBySession: { @@ -623,9 +743,59 @@ describe('WorkspacePanel', () => { 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() + expect(highlightedCode).toContain('+line 2000') + expect(highlightedCode).not.toContain('+line 2001') + await clickElement(view.getByRole('button', { name: 'Show all loaded lines' })) + + await waitFor(() => { + expect(view.getByTestId('workspace-code').textContent).toContain('+line 2300') + }) + expect(view.getByRole('button', { name: 'Collapse preview' })).toBeTruthy() + }) + + it('can expand long file previews beyond the default rendered line cap', async () => { + const longFile = Array.from({ length: 2300 }, (_, index) => `const line${index + 1} = ${index + 1}`).join('\n') + + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-large-file-preview': { + isOpen: true, + activeView: 'all', + }, + }, + previewTabsBySession: { + ...state.previewTabsBySession, + 'session-large-file-preview': [{ + id: 'file:large-file.ts', + path: 'large-file.ts', + kind: 'file', + title: 'large-file.ts', + content: longFile, + language: 'typescript', + previewType: 'text', + state: 'ok', + }], + }, + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + 'session-large-file-preview': 'file:large-file.ts', + }, + })) + + const view = await renderPanel('session-large-file-preview') + const highlightedCode = view.getByTestId('workspace-code').textContent ?? '' + + expect(highlightedCode).toContain('const line1 = 1') + expect(highlightedCode).toContain('const line2000 = 2000') + expect(highlightedCode).not.toContain('const line2001 = 2001') + await clickElement(view.getByRole('button', { name: 'Show all loaded lines' })) + + await waitFor(() => { + expect(view.getByTestId('workspace-code').textContent).toContain('const line2300 = 2300') + }) + expect(view.getByRole('button', { name: 'Collapse preview' })).toBeTruthy() }) it('renders image previews from workspace files', async () => { @@ -724,6 +894,7 @@ describe('WorkspacePanel', () => { 'session-preview-menu': { isOpen: true, activeView: 'changed', + hasUserSelectedView: true, }, }, statusBySession: { @@ -922,6 +1093,7 @@ describe('WorkspacePanel', () => { 'session-zh': { isOpen: true, activeView: 'changed', + hasUserSelectedView: true, }, }, statusBySession: { @@ -950,6 +1122,7 @@ describe('WorkspacePanel', () => { 'session-compact-header': { isOpen: true, activeView: 'changed', + hasUserSelectedView: true, }, }, statusBySession: { @@ -987,6 +1160,7 @@ describe('WorkspacePanel', () => { 'session-empty': { isOpen: true, activeView: 'changed', + hasUserSelectedView: true, }, }, statusBySession: { @@ -1006,6 +1180,20 @@ describe('WorkspacePanel', () => { expect(view.getByText('No changes')).toBeTruthy() + getMocks().getWorkspaceStatusMock.mockImplementation(async (sessionId: string) => { + if (sessionId === 'session-error') { + throw new Error('status failed') + } + return useWorkspacePanelStore.getState().statusBySession[sessionId] ?? { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + } + }) + await setWorkspaceState((state) => ({ ...state, panelBySession: { @@ -1013,6 +1201,7 @@ describe('WorkspacePanel', () => { 'session-error': { isOpen: true, activeView: 'changed', + hasUserSelectedView: true, }, }, errors: { @@ -1028,6 +1217,8 @@ describe('WorkspacePanel', () => { view.rerender() }) - expect(view.getByText('status failed')).toBeTruthy() + await waitFor(() => { + expect(view.getByText('status failed')).toBeTruthy() + }) }) }) diff --git a/desktop/src/components/workspace/WorkspacePanel.tsx b/desktop/src/components/workspace/WorkspacePanel.tsx index b65830ba..333061be 100644 --- a/desktop/src/components/workspace/WorkspacePanel.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, type MouseEvent } from 'react' +import { useEffect, useMemo, useRef, useState, type MouseEvent } from 'react' import { Highlight } from 'prism-react-renderer' import type { WorkspaceChangedFile, @@ -14,11 +14,13 @@ import { type WorkspacePreviewKind, type WorkspacePreviewTab, } from '../../stores/workspacePanelStore' +import { useChatStore } from '../../stores/chatStore' import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore' import { MarkdownRenderer } from '../markdown/MarkdownRenderer' import { getFileExtension, normalizePrismLanguage, + WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD, WORKSPACE_PREVIEW_LINE_LIMIT, WorkspaceDiffSurface, workspacePrismTheme, @@ -315,11 +317,18 @@ function CodeSurface({ const t = useTranslation() const [commentLine, setCommentLine] = useState(null) const [commentDraft, setCommentDraft] = useState('') + const [showAllLines, setShowAllLines] = useState(false) const lines = value.split('\n') - const visibleLines = lines.slice(0, WORKSPACE_PREVIEW_LINE_LIMIT) - const visibleCode = visibleLines.join('\n') - const hiddenLineCount = Math.max(0, lines.length - visibleLines.length) + const visibleLines = showAllLines ? lines : lines.slice(0, WORKSPACE_PREVIEW_LINE_LIMIT) const activeQuote = commentLine ? visibleLines[commentLine - 1] ?? '' : '' + const usePlainLargePreview = showAllLines && lines.length > WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD + const visibleCode = usePlainLargePreview ? '' : visibleLines.join('\n') + + useEffect(() => { + setShowAllLines(false) + setCommentLine(null) + setCommentDraft('') + }, [language, value]) const submitLineComment = () => { if (!commentLine || !commentDraft.trim()) return @@ -328,99 +337,142 @@ function CodeSurface({ setCommentDraft('') } + const renderLineCommentEditor = (lineNumber: number) => { + if (commentLine !== lineNumber) return null + + return ( +
+