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 ( +
+