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/scripts/e2e-rewind-agent-browser.sh b/desktop/scripts/e2e-rewind-agent-browser.sh index 7b2ba1ab..3b8c6e66 100755 --- a/desktop/scripts/e2e-rewind-agent-browser.sh +++ b/desktop/scripts/e2e-rewind-agent-browser.sh @@ -137,10 +137,10 @@ fi ${AB} screenshot "${ARTIFACT_DIR}/03-after-edit.png" >/dev/null -${AB} eval "const button = [...document.querySelectorAll('button')].find((node) => node.getAttribute('aria-label') === 'Rewind to here'); if (!button) throw new Error('Rewind button not found'); button.click();" +${AB} find role button click --name "Undo current turn changes" ${AB} wait 1500 -${AB} screenshot "${ARTIFACT_DIR}/04-rewind-modal.png" >/dev/null -${AB} find role button click --name "Rewind here" +${AB} screenshot "${ARTIFACT_DIR}/04-undo-current-turn-confirm.png" >/dev/null +${AB} find role button click --name "Undo current turn" for _ in $(seq 1 120); do if grep -q "before-rewind" "${TARGET_FILE}" && ! grep -q "after-rewind" "${TARGET_FILE}"; then diff --git a/desktop/scripts/e2e-rewind-complex-agent-browser.sh b/desktop/scripts/e2e-rewind-complex-agent-browser.sh index 1739f0f0..265d8c02 100755 --- a/desktop/scripts/e2e-rewind-complex-agent-browser.sh +++ b/desktop/scripts/e2e-rewind-complex-agent-browser.sh @@ -171,10 +171,10 @@ wait_for_file_contains "${README_FILE}" "Second turn touched README" 60 wait_for_file_contains "${GENERATED_FILE}" "second-turn" 60 ${AB} screenshot "${ARTIFACT_DIR}/04-after-second-turn.png" >/dev/null -${AB} eval "const buttons = [...document.querySelectorAll('button')].filter((node) => node.getAttribute('aria-label') === 'Rewind to here'); if (buttons.length < 2) throw new Error('Expected at least two Rewind buttons, found ' + buttons.length); buttons[buttons.length - 1].click();" +${AB} find role button click --name "Undo current turn changes" ${AB} wait 1500 -${AB} screenshot "${ARTIFACT_DIR}/05-rewind-second-turn-modal.png" >/dev/null -${AB} find role button click --name "Rewind here" +${AB} screenshot "${ARTIFACT_DIR}/05-undo-second-turn-confirm.png" >/dev/null +${AB} find role button click --name "Undo current turn" for _ in $(seq 1 120); do if grep -q "turn-one" "${APP_FILE}" \ diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index a711ca3a..01beb273 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest' +import { beforeEach, describe, it, expect, vi } from 'vitest' import { fireEvent, render, screen } from '@testing-library/react' import '@testing-library/jest-dom' @@ -46,9 +46,14 @@ import { ToolInspection } from '../pages/ToolInspection' import { Sidebar } from '../components/layout/Sidebar' import { UserMessage } from '../components/chat/UserMessage' import { useChatStore } from '../stores/chatStore' +import { useSettingsStore } from '../stores/settingsStore' import { useSessionStore } from '../stores/sessionStore' import { useTabStore } from '../stores/tabStore' +beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) +}) + /** * Core rendering tests: content-only pages must render without crashing * and contain key structural elements from the prototype. @@ -576,7 +581,8 @@ describe('Design system compliance', () => { html.includes('C47A5A') || html.includes('8F482F') || html.includes('var(--color-brand)') || - html.includes('bg-[var(--color-brand)]'), + html.includes('bg-[var(--color-brand)]') || + html.includes('var(--gradient-btn-primary)'), ).toBe(true) unmount() } diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index b82a913b..137bdbd0 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -139,6 +139,82 @@ 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 + truncated?: boolean + readBytes?: 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 +263,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/AskUserQuestion.test.tsx b/desktop/src/components/chat/AskUserQuestion.test.tsx index 4a8686f1..b6d15d85 100644 --- a/desktop/src/components/chat/AskUserQuestion.test.tsx +++ b/desktop/src/components/chat/AskUserQuestion.test.tsx @@ -24,6 +24,7 @@ vi.mock('../../api/sessions', () => ({ import { AskUserQuestion } from './AskUserQuestion' import { useChatStore } from '../../stores/chatStore' +import { useSettingsStore } from '../../stores/settingsStore' import { useTabStore } from '../../stores/tabStore' const ACTIVE_TAB = 'active-tab' @@ -31,6 +32,7 @@ const ACTIVE_TAB = 'active-tab' describe('AskUserQuestion', () => { beforeEach(() => { sendMock.mockReset() + useSettingsStore.setState({ locale: 'en' }) useTabStore.setState({ activeTabId: ACTIVE_TAB, tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', type: 'session', status: 'idle' }], diff --git a/desktop/src/components/chat/AttachmentGallery.tsx b/desktop/src/components/chat/AttachmentGallery.tsx index 484dfda4..2d83e685 100644 --- a/desktop/src/components/chat/AttachmentGallery.tsx +++ b/desktop/src/components/chat/AttachmentGallery.tsx @@ -5,8 +5,13 @@ export type AttachmentPreview = { id?: string type: 'image' | 'file' name: string + path?: string data?: string previewUrl?: string + lineStart?: number + lineEnd?: number + note?: string + quote?: string } type Props = { @@ -35,7 +40,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove } return ( <> -
+
{attachments.map((attachment, index) => { if (attachment.type === 'image' && (attachment.previewUrl || attachment.data)) { const src = attachment.previewUrl || attachment.data || '' @@ -80,18 +85,29 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove } return (
- attach_file - {attachment.name} + + {attachment.lineStart ? 'chat_bubble' : 'description'} + + + {attachment.name} + {attachment.lineStart + ? `:L${attachment.lineStart}${attachment.lineEnd && attachment.lineEnd !== attachment.lineStart ? `-L${attachment.lineEnd}` : ''}` + : ''} + {onRemove && attachment.id && ( )}
diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index d41734ed..ad884be1 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -6,6 +6,11 @@ import { useUIStore } from '../../stores/uiStore' import { useSessionStore } from '../../stores/sessionStore' import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore' import { useTeamStore } from '../../stores/teamStore' +import { + formatWorkspaceReferencePrompt, + useWorkspaceChatContextStore, + type WorkspaceChatReference, +} from '../../stores/workspaceChatContextStore' import { sessionsApi } from '../../api/sessions' import { PermissionModeSelector } from '../controls/PermissionModeSelector' import { ModelSelector } from '../controls/ModelSelector' @@ -29,16 +34,37 @@ type Attachment = { id: string name: string type: 'image' | 'file' + path?: string mimeType?: string previewUrl?: string data?: string + lineStart?: number + lineEnd?: number + note?: string + quote?: string } type ChatInputProps = { variant?: 'default' | 'hero' + compact?: boolean } -export function ChatInput({ variant = 'default' }: ChatInputProps) { +const EMPTY_WORKSPACE_REFERENCES: WorkspaceChatReference[] = [] + +function workspaceReferenceToAttachment(reference: WorkspaceChatReference): Attachment { + return { + id: reference.id, + name: reference.name, + type: 'file', + path: reference.path, + lineStart: reference.lineStart, + lineEnd: reference.lineEnd, + note: reference.note, + quote: reference.quote, + } +} + +export function ChatInput({ variant = 'default', compact = false }: ChatInputProps) { const t = useTranslation() const [input, setInput] = useState('') const [attachments, setAttachments] = useState([]) @@ -67,13 +93,27 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null) const [gitInfo, setGitInfo] = useState(null) const hasMessages = useChatStore((s) => activeTabId ? (s.sessions[activeTabId]?.messages?.length ?? 0) > 0 : false) + const workspaceReferences = useWorkspaceChatContextStore( + (s) => activeTabId ? s.referencesBySession[activeTabId] ?? EMPTY_WORKSPACE_REFERENCES : EMPTY_WORKSPACE_REFERENCES, + ) + const addWorkspaceReference = useWorkspaceChatContextStore((s) => s.addReference) + const removeWorkspaceReference = useWorkspaceChatContextStore((s) => s.removeReference) + const clearWorkspaceReferences = useWorkspaceChatContextStore((s) => s.clearReferences) const isMemberSession = !!memberInfo 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 hasWorkspaceReferences = !isMemberSession && workspaceReferences.length > 0 + const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || (!isMemberSession && (attachments.length > 0 || hasWorkspaceReferences))) + const isHeroComposer = variant === 'hero' && !isMemberSession && !compact const resolvedWorkDir = activeSession?.workDir || gitInfo?.workDir || undefined + const composerAttachments = useMemo( + () => [ + ...attachments, + ...workspaceReferences.map(workspaceReferenceToAttachment), + ], + [attachments, workspaceReferences], + ) useEffect(() => { textareaRef.current?.focus() @@ -302,7 +342,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { const handleSubmit = () => { const text = input.trim() - if ((!text && (!attachments.length || isMemberSession)) || isWorkspaceMissing) return + if ((!text && ((!attachments.length && !hasWorkspaceReferences) || isMemberSession)) || isWorkspaceMissing) return const slashUiAction = !isMemberSession && text.startsWith('/') ? resolveSlashUiAction(text.slice(1)) : null if (slashUiAction?.type === 'panel') { @@ -324,16 +364,55 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { return } - const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({ + const workspaceReferencePrompt = !isMemberSession + ? formatWorkspaceReferencePrompt(workspaceReferences) + : '' + const contentForModel = [workspaceReferencePrompt, text].filter(Boolean).join('\n\n') + const displayContent = text || ( + workspaceReferences.length > 0 + ? t('chat.workspaceReferencesOnly', { count: workspaceReferences.length }) + : '' + ) + const uploadAttachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({ type: attachment.type, name: attachment.name, + path: attachment.path, data: attachment.data, mimeType: attachment.mimeType, + lineStart: attachment.lineStart, + lineEnd: attachment.lineEnd, + note: attachment.note, + quote: attachment.quote, })) + const workspaceAttachmentPayload: AttachmentRef[] = workspaceReferences.map((reference) => ({ + type: 'file' as const, + name: reference.name, + path: reference.absolutePath ?? reference.path, + lineStart: reference.lineStart, + lineEnd: reference.lineEnd, + note: reference.note, + quote: reference.quote, + })) + const visibleAttachmentPayload: AttachmentRef[] = [ + ...uploadAttachmentPayload, + ...workspaceReferences.map((reference) => ({ + type: 'file' as const, + name: reference.name, + path: reference.path, + lineStart: reference.lineStart, + lineEnd: reference.lineEnd, + note: reference.note, + quote: reference.quote, + })), + ] - sendMessage(activeTabId!, text, attachmentPayload) + sendMessage(activeTabId!, contentForModel, [...uploadAttachmentPayload, ...workspaceAttachmentPayload], { + displayContent, + displayAttachments: visibleAttachmentPayload, + }) setInput('') setAttachments([]) + if (!isMemberSession) clearWorkspaceReferences(activeTabId!) setPlusMenuOpen(false) setSlashMenuOpen(false) setFileSearchOpen(false) @@ -487,6 +566,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { const removeAttachment = (id: string) => { setAttachments((prev) => prev.filter((attachment) => attachment.id !== id)) + if (activeTabId) removeWorkspaceReference(activeTabId, id) } const insertSlashCommand = () => { @@ -517,12 +597,30 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { const slashCommandsLabel = isHeroComposer ? t('empty.slashCommands') : t('chat.slashCommands') return ( -
-
+
+
event.preventDefault()} onDrop={handleDrop} > @@ -531,11 +629,19 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { ref={fileSearchRef} cwd={resolvedWorkDir || ''} filter={atFilter} - onSelect={(_path, name) => { + 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 + if (activeTabId) { + addWorkspaceReference(activeTabId, { + kind: 'file', + path, + absolutePath: path, + name, + }) + } setInput(newValue) setFileSearchOpen(false) setAtFilter('') @@ -599,12 +705,12 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
)} - {attachments.length > 0 && ( + {composerAttachments.length > 0 && ( isHeroComposer ? ( - + ) : (
- +
) )} @@ -637,14 +743,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 +789,30 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { )}
- + )}
-
+
{!isMemberSession && activeTabId && ( - + )}
@@ -710,12 +830,13 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { {!isMemberSession && ( -
+
{hasMessages ? ( ) : ( ({ })) import { useChatStore } from '../../stores/chatStore' +import { useSettingsStore } from '../../stores/settingsStore' import { ComputerUsePermissionModal } from './ComputerUsePermissionModal' describe('ComputerUsePermissionModal', () => { beforeEach(() => { sendMock.mockReset() openSettingsMock.mockReset() + useSettingsStore.setState({ locale: 'en' }) useChatStore.setState({ sessions: {} }) }) diff --git a/desktop/src/components/chat/CurrentTurnChangeCard.tsx b/desktop/src/components/chat/CurrentTurnChangeCard.tsx new file mode 100644 index 00000000..23109e21 --- /dev/null +++ b/desktop/src/components/chat/CurrentTurnChangeCard.tsx @@ -0,0 +1,182 @@ +import { useCallback, useMemo, useState } from 'react' +import { sessionsApi, type SessionRewindResponse } from '../../api/sessions' +import { useTranslation } from '../../i18n' +import { WorkspaceDiffSurface } from '../workspace/WorkspaceCodeSurface' + +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 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/FileSearchMenu.test.tsx b/desktop/src/components/chat/FileSearchMenu.test.tsx index 45ba72c2..ba6cd561 100644 --- a/desktop/src/components/chat/FileSearchMenu.test.tsx +++ b/desktop/src/components/chat/FileSearchMenu.test.tsx @@ -4,6 +4,7 @@ import '@testing-library/jest-dom' import { FileSearchMenu } from './FileSearchMenu' import { ApiError } from '../../api/client' import { filesystemApi } from '../../api/filesystem' +import { useSettingsStore } from '../../stores/settingsStore' vi.mock('../../api/filesystem', () => ({ filesystemApi: { @@ -15,6 +16,7 @@ vi.mock('../../api/filesystem', () => ({ describe('FileSearchMenu', () => { beforeEach(() => { vi.clearAllMocks() + useSettingsStore.setState({ locale: 'en' }) }) it('shows an explicit error when directory browsing is denied', async () => { diff --git a/desktop/src/components/chat/MessageActionBar.tsx b/desktop/src/components/chat/MessageActionBar.tsx index 34070cae..3861bd40 100644 --- a/desktop/src/components/chat/MessageActionBar.tsx +++ b/desktop/src/components/chat/MessageActionBar.tsx @@ -3,22 +3,17 @@ import { CopyButton } from '../shared/CopyButton' type Props = { copyText?: string copyLabel: string - onRewind?: () => void - rewindLabel?: string align?: 'start' | 'end' } export function MessageActionBar({ copyText, copyLabel, - onRewind, - rewindLabel = 'Rewind to here', align = 'start', }: Props) { const hasCopy = Boolean(copyText?.trim()) - const hasRewind = Boolean(onRewind) - if (!hasCopy && !hasRewind) return null + if (!hasCopy) return null return (
- {hasRewind && ( - - )} - {hasCopy && ( - - )} +
) diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index ff2ae162..d0966d77 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -563,7 +563,7 @@ describe('MessageList nested tool calls', () => { expect(assistantShell?.className).not.toContain('ml-10') }) - it('opens a rewind preview modal for user messages', async () => { + it('does not expose the old message-level rewind action', async () => { vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({ target: { targetUserMessageId: 'user-1', @@ -575,11 +575,19 @@ describe('MessageList nested tool calls', () => { }, code: { available: true, - filesChanged: ['src/example.ts'], - insertions: 6, - deletions: 2, + filesChanged: ['src/App.tsx'], + insertions: 4, + deletions: 1, }, }) + vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({ + state: 'ok', + workDir: '/tmp/example-project', + repoName: 'example-project', + branch: null, + isGitRepo: false, + changedFiles: [], + }) useChatStore.setState({ sessions: { @@ -588,9 +596,15 @@ describe('MessageList nested tool calls', () => { { id: 'user-1', type: 'user_text', - content: '回到这一步重做', + content: '做一个页面', timestamp: 1, }, + { + id: 'assistant-1', + type: 'assistant_text', + content: 'done', + timestamp: 2, + }, ], }), }, @@ -598,21 +612,11 @@ describe('MessageList nested tool calls', () => { render() - fireEvent.click(screen.getByRole('button', { name: 'Rewind to here' })) - - const dialog = await screen.findByRole('dialog') - expect(within(dialog).getByText('Rewind Conversation')).toBeTruthy() - expect(within(dialog).getByText('回到这一步重做')).toBeTruthy() - expect(within(dialog).getByText('src/example.ts')).toBeTruthy() - expect(sessionsApi.rewind).toHaveBeenCalledWith(ACTIVE_TAB, { - targetUserMessageId: 'user-1', - userMessageIndex: 0, - expectedContent: '回到这一步重做', - dryRun: true, - }) + expect(await screen.findByRole('button', { name: 'Undo current turn changes' })).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Rewind to here' })).toBeNull() }) - it('confirms rewind with the selected message id and prompt guard', async () => { + it('shows a current-turn change card from checkpoint preview', async () => { vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({ target: { targetUserMessageId: 'user-2', @@ -623,18 +627,22 @@ describe('MessageList nested tool calls', () => { messagesRemoved: 2, }, code: { - available: false, - filesChanged: [], - insertions: 0, - deletions: 0, + available: true, + filesChanged: ['src/App.tsx', 'src/lib/api.ts'], + insertions: 12, + deletions: 4, }, }) - const reloadHistory = vi.fn().mockResolvedValue(undefined) - const queueComposerPrefill = vi.fn() + vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({ + state: 'ok', + workDir: '/tmp/example-project', + repoName: 'example-project', + branch: null, + isGitRepo: false, + changedFiles: [], + }) useChatStore.setState({ - reloadHistory, - queueComposerPrefill, sessions: { [ACTIVE_TAB]: makeSessionState({ messages: [ @@ -654,8 +662,15 @@ describe('MessageList nested tool calls', () => { id: 'user-2', type: 'user_text', content: '第二段', + modelContent: '@"/tmp/example-project/src/App.tsx" 第二段', timestamp: 3, }, + { + id: 'assistant-2', + type: 'assistant_text', + content: 'done', + timestamp: 4, + }, ], }), }, @@ -663,25 +678,348 @@ describe('MessageList nested tool calls', () => { render() - const buttons = screen.getAllByRole('button', { name: 'Rewind to here' }) - fireEvent.click(buttons[1]!) - const dialog = await screen.findByRole('dialog') - fireEvent.click(within(dialog).getByRole('button', { name: /Rewind here/ })) + expect(await screen.findByText('2 files changed')).toBeTruthy() + expect(screen.getByLabelText('Current turn changed files').className).toContain('w-full max-w-[860px]') + 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: '@"/tmp/example-project/src/App.tsx" 第二段', + 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' })) + + const diffSurface = await screen.findByTestId('workspace-code') + expect(diffSurface.textContent).toContain('+new') + expect(sessionsApi.getWorkspaceDiff).toHaveBeenCalledWith(ACTIVE_TAB, 'src/App.tsx') + }) + + it('confirms before undoing the current turn from the change card', 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' })) + + expect(sessionsApi.rewind).toHaveBeenCalledTimes(1) + const dialog = await screen.findByRole('dialog', { name: 'Undo current turn?' }) + expect(within(dialog).getByText('This will rewind the latest assistant response and restore tracked files for this turn.')).toBeTruthy() + + fireEvent.click(within(dialog).getByRole('button', { name: 'Undo current turn' })) + + 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('undoes only the latest completed turn when earlier turns also changed files', async () => { + vi.spyOn(sessionsApi, 'rewind') + .mockResolvedValueOnce({ + target: { + targetUserMessageId: 'user-2', + userMessageIndex: 1, + userMessageCount: 2, + }, + conversation: { + messagesRemoved: 2, + }, + code: { + available: true, + filesChanged: ['src/second.ts'], + insertions: 7, + deletions: 2, + }, + }) + .mockResolvedValueOnce({ + target: { + targetUserMessageId: 'user-2', + userMessageIndex: 1, + userMessageCount: 2, + }, + conversation: { + messagesRemoved: 2, + removedMessageIds: ['user-2', 'assistant-2'], + }, + code: { + available: true, + filesChanged: ['src/second.ts'], + insertions: 7, + deletions: 2, + }, + }) + 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: 'first done', + timestamp: 2, + }, + { + id: 'user-2', + type: 'user_text', + content: '第二轮需求', + timestamp: 3, + }, + { + id: 'assistant-2', + type: 'assistant_text', + content: 'second done', + timestamp: 4, + }, + ], + }), + }, + }) + + render() + + expect(await screen.findByText('1 files changed')).toBeTruthy() + expect(screen.getByText('src/second.ts')).toBeTruthy() + expect(sessionsApi.rewind).toHaveBeenCalledWith(ACTIVE_TAB, { + targetUserMessageId: 'user-2', + userMessageIndex: 1, + expectedContent: '第二轮需求', + dryRun: true, + }) + + fireEvent.click(screen.getByRole('button', { name: 'Undo current turn changes' })) + const dialog = await screen.findByRole('dialog', { name: 'Undo current turn?' }) + fireEvent.click(within(dialog).getByRole('button', { name: 'Undo current turn' })) await waitFor(() => { expect(sessionsApi.rewind).toHaveBeenLastCalledWith(ACTIVE_TAB, { targetUserMessageId: 'user-2', userMessageIndex: 1, - expectedContent: '第二段', + expectedContent: '第二轮需求', }) }) expect(reloadHistory).toHaveBeenCalledWith(ACTIVE_TAB) expect(queueComposerPrefill).toHaveBeenCalledWith(ACTIVE_TAB, { - text: '第二段', + text: '第二轮需求', attachments: undefined, }) }) + it('does not show a stale current-turn change card when the latest completed turn has no files', async () => { + vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({ + target: { + targetUserMessageId: 'user-2', + userMessageIndex: 1, + userMessageCount: 2, + }, + conversation: { + messagesRemoved: 2, + }, + code: { + available: true, + filesChanged: [], + insertions: 0, + deletions: 0, + }, + }) + + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'user-1', + type: 'user_text', + content: '第一轮改文件', + timestamp: 1, + }, + { + id: 'assistant-1', + type: 'assistant_text', + content: 'first done', + timestamp: 2, + }, + { + id: 'user-2', + type: 'user_text', + content: '第二轮只解释', + timestamp: 3, + }, + { + id: 'assistant-2', + type: 'assistant_text', + content: 'second done', + timestamp: 4, + }, + ], + }), + }, + }) + + render() + + await waitFor(() => { + expect(sessionsApi.rewind).toHaveBeenCalledWith(ACTIVE_TAB, { + targetUserMessageId: 'user-2', + userMessageIndex: 1, + expectedContent: '第二轮只解释', + dryRun: true, + }) + }) + expect(screen.queryByLabelText('Current turn changed files')).toBeNull() + }) + 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 aa980645..8fd854c0 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -17,9 +17,9 @@ 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' +import { ConfirmDialog } from '../shared/ConfirmDialog' type ToolCall = Extract type ToolResult = Extract @@ -34,6 +34,20 @@ type RenderModel = { childToolCallsByParent: Map } +type RewindTurnTarget = { + messageId: string + userMessageIndex: number + content: string + expectedContent: string + attachments?: Extract['attachments'] +} + +type CurrentTurnPreview = { + target: RewindTurnTarget + preview: SessionRewindResponse + workDir: string | null +} + function appendChildToolCall( childToolCallsByParent: Map, parentToolUseId: string, @@ -104,8 +118,45 @@ 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, + expectedContent: message.modelContent ?? 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 } const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 48 @@ -117,7 +168,7 @@ function isNearScrollBottom(element: HTMLElement) { ) } -export function MessageList({ sessionId }: MessageListProps = {}) { +export function MessageList({ sessionId, compact = false }: MessageListProps = {}) { const activeTabId = useTabStore((s) => s.activeTabId) const resolvedSessionId = sessionId ?? activeTabId const sessionState = useChatStore((s) => @@ -140,16 +191,11 @@ export function MessageList({ sessionId }: MessageListProps = {}) { const shouldAutoScrollRef = useRef(true) const lastSessionIdRef = useRef(resolvedSessionId) const t = useTranslation() - const [rewindTarget, setRewindTarget] = useState<{ - messageId: string - userMessageIndex: number - content: string - attachments?: Extract['attachments'] - } | null>(null) - const [rewindPreview, setRewindPreview] = useState(null) - 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 [currentTurnUndoConfirmOpen, setCurrentTurnUndoConfirmOpen] = useState(false) const updateAutoScrollState = useCallback(() => { const container = scrollContainerRef.current @@ -168,25 +214,50 @@ export function MessageList({ sessionId }: MessageListProps = {}) { bottomRef.current?.scrollIntoView?.({ behavior: 'smooth' }) }, [messages.length, resolvedSessionId, streamingText]) + const { toolResultMap, childToolCallsByParent, renderItems } = useMemo( + () => buildRenderModel(messages), + [messages], + ) + const latestTurnTarget = useMemo(() => getLatestCompletedTurnTarget(messages), [messages]) + useEffect(() => { - if (!resolvedSessionId || !rewindTarget) return + if ( + !resolvedSessionId || + !latestTurnTarget || + chatState !== 'idle' || + isMemberSession + ) { + setCurrentTurnPreview(null) + setCurrentTurnError(null) + setIsLoadingCurrentTurnPreview(false) + return + } let cancelled = false - setIsLoadingPreview(true) - setRewindPreview(null) - setRewindError(null) + setIsLoadingCurrentTurnPreview(true) + setCurrentTurnPreview(null) + setCurrentTurnError(null) - void sessionsApi - .rewind(resolvedSessionId, { - targetUserMessageId: rewindTarget.messageId, - userMessageIndex: rewindTarget.userMessageIndex, - expectedContent: rewindTarget.content, + Promise.all([ + sessionsApi.rewind(resolvedSessionId, { + targetUserMessageId: latestTurnTarget.messageId, + userMessageIndex: latestTurnTarget.userMessageIndex, + expectedContent: latestTurnTarget.expectedContent, dryRun: true, - }) - .then((preview) => { - if (!cancelled) { - setRewindPreview(preview) + }), + 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 @@ -198,37 +269,25 @@ export function MessageList({ sessionId }: MessageListProps = {}) { : error instanceof Error ? error.message : String(error) - setRewindError(message) + setCurrentTurnError(message) }) .finally(() => { if (!cancelled) { - setIsLoadingPreview(false) + setIsLoadingCurrentTurnPreview(false) } }) return () => { cancelled = true } - }, [resolvedSessionId, rewindTarget]) + }, [chatState, isMemberSession, latestTurnTarget, resolvedSessionId]) - const { toolResultMap, childToolCallsByParent, renderItems } = useMemo( - () => buildRenderModel(messages), - [messages], - ) + const handleUndoCurrentTurn = useCallback(async () => { + if (!resolvedSessionId || !currentTurnPreview || isUndoingCurrentTurn) return - const closeRewindModal = useCallback(() => { - if (isExecutingRewind) return - setRewindTarget(null) - setRewindPreview(null) - setRewindError(null) - setIsLoadingPreview(false) - }, [isExecutingRewind]) - - const handleConfirmRewind = useCallback(async () => { - if (!resolvedSessionId || !rewindTarget || isExecutingRewind) return - - setIsExecutingRewind(true) - setRewindError(null) + const target = currentTurnPreview.target + setIsUndoingCurrentTurn(true) + setCurrentTurnError(null) try { if (chatState !== 'idle') { @@ -236,15 +295,15 @@ export function MessageList({ sessionId }: MessageListProps = {}) { } const result = await sessionsApi.rewind(resolvedSessionId, { - targetUserMessageId: rewindTarget.messageId, - userMessageIndex: rewindTarget.userMessageIndex, - expectedContent: rewindTarget.content, + targetUserMessageId: target.messageId, + userMessageIndex: target.userMessageIndex, + expectedContent: target.expectedContent, }) await reloadHistory(resolvedSessionId) queueComposerPrefill(resolvedSessionId, { - text: rewindTarget.content, - attachments: rewindTarget.attachments, + text: target.content, + attachments: target.attachments, }) addToast({ @@ -258,8 +317,8 @@ export function MessageList({ sessionId }: MessageListProps = {}) { }), }) - setRewindTarget(null) - setRewindPreview(null) + setCurrentTurnPreview(null) + setCurrentTurnUndoConfirmOpen(false) } catch (error) { const message = error instanceof ApiError @@ -269,31 +328,30 @@ export function MessageList({ sessionId }: MessageListProps = {}) { : error instanceof Error ? error.message : String(error) - setRewindError(message) + setCurrentTurnError(message) + setCurrentTurnUndoConfirmOpen(false) } finally { - setIsExecutingRewind(false) + setIsUndoingCurrentTurn(false) } }, [ addToast, chatState, - isExecutingRewind, + currentTurnPreview, + isUndoingCurrentTurn, queueComposerPrefill, reloadHistory, resolvedSessionId, - rewindTarget, stopGeneration, t, ]) - let visibleUserMessageIndex = -1 - return (
-
+
{renderItems.map((item) => { if (item.kind === 'tool_group') { return ( @@ -312,10 +370,6 @@ export function MessageList({ sessionId }: MessageListProps = {}) { } const msg = item.message - const rewindableUserIndex = - msg.type === 'user_text' && !msg.pending - ? ++visibleUserMessageIndex - : null return ( { - setRewindTarget({ - messageId: message.id, - userMessageIndex, - content: message.content, - attachments: message.attachments, - }) - } - : undefined - } /> ) })} @@ -359,121 +400,43 @@ export function MessageList({ sessionId }: MessageListProps = {}) { )} + {!isLoadingCurrentTurnPreview && currentTurnPreview && resolvedSessionId && ( + { + setCurrentTurnUndoConfirmOpen(true) + }} + /> + )} + + {!currentTurnPreview && currentTurnError && ( +
+ {currentTurnError} +
+ )} +
- - - - - } - > -
-
-
- {t('chat.rewindPromptLabel')} -
-
- {rewindTarget?.content || t('chat.rewindAttachmentOnly')} -
-
- - {isLoadingPreview && ( -
- {t('chat.rewindLoading')} -
- )} - - {!isLoadingPreview && rewindPreview && ( -
-
-
- history - {t('chat.rewindConversationCardTitle')} -
-

- {t('chat.rewindConversationCardBody', { - count: rewindPreview.conversation.messagesRemoved, - })} -

-
- -
-
- code - {t('chat.rewindCodeCardTitle')} -
- {rewindPreview.code.available ? ( -
-
{t('chat.rewindCodeFiles', { count: rewindPreview.code.filesChanged.length })}
-
{t('chat.rewindCodeInsertions', { count: rewindPreview.code.insertions })}
-
{t('chat.rewindCodeDeletions', { count: rewindPreview.code.deletions })}
-
- ) : ( -

- {rewindPreview.code.reason || t('chat.rewindCodeUnavailable')} -

- )} -
-
- )} - - {!isLoadingPreview && rewindPreview?.code.available && rewindPreview.code.filesChanged.length > 0 && ( -
-
- {t('chat.rewindFilesLabel')} -
-
- {rewindPreview.code.filesChanged.slice(0, 8).map((filePath) => ( - - {filePath} - - ))} - {rewindPreview.code.filesChanged.length > 8 && ( - - {t('chat.rewindFilesMore', { - count: rewindPreview.code.filesChanged.length - 8, - })} - - )} -
-
- )} - - {rewindError && ( -
- {rewindError} -
- )} -
-
+ { + if (!isUndoingCurrentTurn) { + setCurrentTurnUndoConfirmOpen(false) + } + }} + onConfirm={handleUndoCurrentTurn} + title={t('chat.turnChangesConfirmTitle')} + body={t('chat.turnChangesConfirmBody')} + confirmLabel={t('chat.turnChangesConfirmUndo')} + cancelLabel={t('common.cancel')} + confirmVariant="danger" + loading={isUndoingCurrentTurn} + />
) } @@ -483,18 +446,11 @@ export const MessageBlock = memo(function MessageBlock({ activeThinkingId, agentTaskNotifications, toolResult, - rewindableUserIndex, - onRequestRewind, }: { message: UIMessage activeThinkingId: string | null agentTaskNotifications: Record toolResult?: { content: unknown; isError: boolean } | null - rewindableUserIndex?: number | null - onRequestRewind?: ( - message: Extract, - userMessageIndex: number, - ) => void }) { const t = useTranslation() @@ -504,12 +460,6 @@ export const MessageBlock = memo(function MessageBlock({ onRequestRewind(message, rewindableUserIndex) - : undefined - } - rewindLabel={t('chat.rewindAction')} /> ) case 'assistant_text': diff --git a/desktop/src/components/chat/UserMessage.tsx b/desktop/src/components/chat/UserMessage.tsx index 729ed489..9162384e 100644 --- a/desktop/src/components/chat/UserMessage.tsx +++ b/desktop/src/components/chat/UserMessage.tsx @@ -5,11 +5,9 @@ import { MessageActionBar } from './MessageActionBar' type Props = { content: string attachments?: UIAttachment[] - onRewind?: () => void - rewindLabel?: string } -export function UserMessage({ content, attachments, onRewind, rewindLabel }: Props) { +export function UserMessage({ content, attachments }: Props) { const hasText = content.trim().length > 0 return ( @@ -35,8 +33,6 @@ export function UserMessage({ content, attachments, onRewind, rewindLabel }: Pro )} diff --git a/desktop/src/components/chat/chatBlocks.test.tsx b/desktop/src/components/chat/chatBlocks.test.tsx index 270144e2..de59a471 100644 --- a/desktop/src/components/chat/chatBlocks.test.tsx +++ b/desktop/src/components/chat/chatBlocks.test.tsx @@ -4,10 +4,12 @@ import { ThinkingBlock } from './ThinkingBlock' import { ToolCallBlock } from './ToolCallBlock' import { PermissionDialog } from './PermissionDialog' import { useChatStore } from '../../stores/chatStore' +import { useSettingsStore } from '../../stores/settingsStore' import { useTabStore } from '../../stores/tabStore' describe('chat blocks', () => { beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) useTabStore.setState({ activeTabId: 'active-tab', tabs: [{ sessionId: 'active-tab', title: 'Test', type: 'session' as const, status: 'idle' }] }) useChatStore.setState({ sessions: {} }) }) 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/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index dd665f4d..f380c64a 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -11,7 +11,6 @@ vi.mock('../../i18n', () => ({ const translations: Record = { 'sidebar.newSession': 'New Session', 'sidebar.scheduled': 'Scheduled', - 'sidebar.terminal': 'Terminal', 'sidebar.settings': 'Settings', 'sidebar.searchPlaceholder': 'Search sessions', 'sidebar.noSessions': 'No sessions', @@ -105,26 +104,6 @@ describe('Sidebar', () => { expect(screen.getByRole('complementary')).not.toHaveAttribute('data-tauri-drag-region') }) - it('opens each terminal click as a first-class app tab', () => { - render() - - fireEvent.click(screen.getByRole('button', { name: 'Terminal' })) - fireEvent.click(screen.getByRole('button', { name: 'Terminal' })) - - const terminalTabs = useTabStore.getState().tabs.filter((tab) => tab.type === 'terminal') - expect(terminalTabs).toHaveLength(2) - expect(terminalTabs.map((tab) => tab.title)).toEqual(['Terminal 1', 'Terminal 2']) - expect(useTabStore.getState().activeTabId).toBe(terminalTabs[1]!.sessionId) - - useTabStore.getState().closeTab(terminalTabs[0]!.sessionId) - useTabStore.getState().openTerminalTab() - - expect(useTabStore.getState().tabs.filter((tab) => tab.type === 'terminal').map((tab) => tab.title)).toEqual([ - 'Terminal 2', - 'Terminal 3', - ]) - }) - it('shows a toast when session creation fails', async () => { createSession.mockRejectedValue(new Error('boom')) diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 9f967e3f..c0add799 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -26,7 +26,6 @@ export function Sidebar() { const sidebarOpen = useUIStore((s) => s.sidebarOpen) const toggleSidebar = useUIStore((s) => s.toggleSidebar) const activeTabId = useTabStore((s) => s.activeTabId) - const activeTabType = useTabStore((s) => s.tabs.find((tab) => tab.sessionId === s.activeTabId)?.type) const closeTab = useTabStore((s) => s.closeTab) const disconnectSession = useChatStore((s) => s.disconnectSession) const [searchQuery, setSearchQuery] = useState('') @@ -203,15 +202,6 @@ export function Sidebar() { > {t('sidebar.scheduled')} - useTabStore.getState().openTerminalTab()} - icon={terminal} - > - {t('sidebar.terminal')} -
{sidebarOpen ? ( 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..134b6570 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -1,8 +1,10 @@ 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' +import { Folder, FolderOpen, SquareTerminal } from 'lucide-react' const TAB_WIDTH = 180 const DRAG_START_THRESHOLD = 4 @@ -14,6 +16,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 +81,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 +106,7 @@ export function TabBar() { } disconnectSession(sessionId) - closeTab(sessionId) + closeTabWithCleanup(tab) } const handleContextMenu = (e: React.MouseEvent, sessionId: string) => { @@ -105,7 +119,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 +129,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 +139,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 +147,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 +279,22 @@ export function TabBar() { ))}
+
+ } + label={t('tabs.openTerminal')} + onClick={() => useTabStore.getState().openTerminalTab()} + /> + {isActiveSessionTab && activeTabId && ( + : } + label={t(isWorkspacePanelOpen ? 'tabs.hideWorkspace' : 'tabs.showWorkspace')} + onClick={() => useWorkspacePanelStore.getState().togglePanel(activeTabId)} + active={isWorkspacePanelOpen} + /> + )} +
+ {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/shared/UpdateChecker.test.tsx b/desktop/src/components/shared/UpdateChecker.test.tsx index da1b0c60..e29e2785 100644 --- a/desktop/src/components/shared/UpdateChecker.test.tsx +++ b/desktop/src/components/shared/UpdateChecker.test.tsx @@ -3,10 +3,12 @@ import { render, screen } from '@testing-library/react' import '@testing-library/jest-dom' import { UpdateChecker } from './UpdateChecker' +import { useSettingsStore } from '../../stores/settingsStore' import { useUpdateStore } from '../../stores/updateStore' describe('UpdateChecker', () => { beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) Object.defineProperty(window, '__TAURI__', { value: {}, configurable: true, diff --git a/desktop/src/components/workspace/WorkspaceCodeSurface.tsx b/desktop/src/components/workspace/WorkspaceCodeSurface.tsx new file mode 100644 index 00000000..601e4d2f --- /dev/null +++ b/desktop/src/components/workspace/WorkspaceCodeSurface.tsx @@ -0,0 +1,174 @@ +import { Highlight, type PrismTheme } from 'prism-react-renderer' +import { useTranslation } from '../../i18n' + +export const WORKSPACE_PREVIEW_LINE_LIMIT = 420 + +export 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)' } }, + ], +} + +export 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() +} + +export 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 +} + +export function getLanguageFromPath(path: string) { + return normalizePrismLanguage(getFileExtension(path) || 'text') +} + +export 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 + })} + + )} + + ) +} + +export function WorkspaceDiffSurface({ + value, + path, + className = 'min-h-0 flex-1 overflow-auto bg-[var(--color-code-bg)]', + lineLimit = WORKSPACE_PREVIEW_LINE_LIMIT, +}: { + value: string + path: string + className?: string + lineLimit?: number +}) { + const t = useTranslation() + const lines = value.split('\n') + const visibleLines = lines.slice(0, lineLimit) + 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: lineLimit })} +
+ )} +
+
+ ) +} diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx new file mode 100644 index 00000000..5c9e7fe0 --- /dev/null +++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx @@ -0,0 +1,1033 @@ +// @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) + }) +} + +function classNameContains(element: Element | null, needle: string) { + let current = element + while (current) { + if (typeof current.className === 'string' && current.className.includes(needle)) { + return true + } + current = current.parentElement + } + return false +} + +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 { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore' +import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' +import { WorkspacePanel } from './WorkspacePanel' + +describe('WorkspacePanel', () => { + const workspaceInitialState = useWorkspacePanelStore.getInitialState() + const workspaceChatInitialState = useWorkspaceChatContextStore.getInitialState() + const settingsInitialState = useSettingsStore.getInitialState() + + beforeEach(async () => { + vi.clearAllMocks() + await setWorkspaceState(workspaceInitialState) + useWorkspaceChatContextStore.setState(workspaceChatInitialState, true) + await setSettingsState({ ...settingsInitialState, locale: 'en' }) + }) + + afterEach(async () => { + cleanup() + await setWorkspaceState(workspaceInitialState) + useWorkspaceChatContextStore.setState(workspaceChatInitialState, true) + 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') + + 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') + }) + + 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")') + }) + 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) + }) + + 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('opens to all files when the current turn has no changed files', 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 }> + }>() + + getMocks().getWorkspaceStatusMock.mockReturnValue(statusRequest.promise) + getMocks().getWorkspaceTreeMock.mockReturnValue(rootTreeRequest.promise) + + await act(() => { + useWorkspacePanelStore.getState().openPanel('session-empty-tree') + }) + + const view = await renderPanel('session-empty-tree') + expect(view.getByRole('button', { name: 'Changed files' })).toBeTruthy() + + await act(async () => { + statusRequest.resolve({ + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }) + await statusRequest.promise + }) + + await waitFor(() => { + expect(useWorkspacePanelStore.getState().getActiveView('session-empty-tree')).toBe('all') + expect(getMocks().getWorkspaceTreeMock).toHaveBeenCalledWith('session-empty-tree', '') + }) + + await act(async () => { + rootTreeRequest.resolve({ + state: 'ok', + path: '', + entries: [ + { name: 'src', path: 'src', isDirectory: true }, + { name: 'README.md', path: 'README.md', isDirectory: false }, + ], + }) + await rootTreeRequest.promise + }) + + expect(view.getByRole('button', { name: 'All files' })).toBeTruthy() + expect(await view.findByText('src')).toBeTruthy() + expect(await view.findByText('README.md')).toBeTruthy() + expect(view.queryByText('No changes')).toBeNull() + }) + + 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('uses theme tokens for the panel, preview tabs, and code surface in dark mode', async () => { + await setSettingsState({ ...settingsInitialState, locale: 'en', theme: 'dark' }) + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-dark-theme': { + isOpen: true, + activeView: 'changed', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-dark-theme': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + previewTabsBySession: { + ...state.previewTabsBySession, + 'session-dark-theme': [{ + id: 'file:src/theme.ts', + path: 'src/theme.ts', + kind: 'file', + title: 'theme.ts', + language: 'typescript', + content: 'export const theme = "dark"', + state: 'ok', + size: 27, + }], + }, + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + 'session-dark-theme': 'file:src/theme.ts', + }, + })) + + const view = await renderPanel('session-dark-theme') + const panel = view.getByTestId('workspace-panel') + const tabList = view.getByRole('tablist', { name: 'Preview tabs' }) + const codeSurface = view.getByTestId('workspace-code') + + expect(panel.className).toContain('bg-[var(--color-surface)]') + expect(panel.className).not.toContain('bg-white') + expect(tabList.className).toContain('bg-[var(--color-surface-container-lowest)]') + expect(tabList.className).not.toContain('bg-white') + expect(classNameContains(codeSurface, 'bg-[var(--color-code-bg)]')).toBe(true) + 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') + + 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('renders markdown file previews as formatted documents', async () => { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-markdown-preview': { + isOpen: true, + activeView: 'all', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-markdown-preview': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + previewTabsBySession: { + ...state.previewTabsBySession, + 'session-markdown-preview': [{ + id: 'file:README.md', + path: 'README.md', + kind: 'file', + title: 'README.md', + language: 'markdown', + content: '# Project Notes\n\n- **Done** item\n\n```ts\nexport const ok = true\n```', + state: 'ok', + size: 70, + }], + }, + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + 'session-markdown-preview': 'file:README.md', + }, + })) + + const view = await renderPanel('session-markdown-preview') + + expect(view.getByRole('heading', { name: 'Project Notes', level: 1 })).toBeTruthy() + expect(view.getByText('Done')).toBeTruthy() + expect(view.container.textContent).toContain('export const ok = true') + expect(view.queryByTestId('workspace-code')).toBeNull() + }) + + it('opens a context menu for preview tabs and closes tabs to the right', async () => { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-preview-menu': { + isOpen: true, + activeView: 'changed', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-preview-menu': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + previewTabsBySession: { + ...state.previewTabsBySession, + 'session-preview-menu': [ + { + id: 'file:src/App.jsx', + path: 'src/App.jsx', + kind: 'file', + title: 'App.jsx', + language: 'jsx', + content: 'app', + state: 'ok', + size: 3, + }, + { + id: 'diff:vite.config.js', + path: 'vite.config.js', + kind: 'diff', + title: 'vite.config.js', + diff: '@@ -1 +1 @@', + state: 'ok', + size: 12, + }, + { + id: 'file:src/index.css', + path: 'src/index.css', + kind: 'file', + title: 'index.css', + language: 'css', + content: 'body{}', + state: 'ok', + size: 6, + }, + ], + }, + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + 'session-preview-menu': 'diff:vite.config.js', + }, + })) + + const view = await renderPanel('session-preview-menu') + + await act(() => { + fireEvent.contextMenu(view.getByRole('tab', { name: /vite\.config\.js/i }), { + clientX: 320, + clientY: 42, + }) + }) + + await clickElement(view.getByRole('menuitem', { name: 'Close to the Right' })) + + expect(useWorkspacePanelStore.getState().previewTabsBySession['session-preview-menu']).toMatchObject([ + { id: 'file:src/App.jsx' }, + { id: 'diff:vite.config.js' }, + ]) + expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview-menu']).toBe('diff:vite.config.js') + expect(view.queryByRole('tab', { name: /index\.css/i })).toBeNull() + }) + + it('adds a workspace file to the chat context from the file tree menu', async () => { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-add-file': { + isOpen: true, + activeView: 'all', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-add-file': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + treeBySessionPath: { + ...state.treeBySessionPath, + 'session-add-file': { + '': { + state: 'ok', + path: '', + entries: [{ name: 'App.tsx', path: 'src/App.tsx', isDirectory: false }], + }, + }, + }, + })) + + const view = await renderPanel('session-add-file') + + await act(() => { + fireEvent.contextMenu(view.getByRole('button', { name: /App\.tsx/i }), { + clientX: 260, + clientY: 80, + }) + }) + + await clickElement(view.getByRole('menuitem', { name: 'Add to chat' })) + + expect(useWorkspaceChatContextStore.getState().referencesBySession['session-add-file']).toMatchObject([ + { + kind: 'file', + path: 'src/App.tsx', + absolutePath: '/repo/src/App.tsx', + name: 'App.tsx', + }, + ]) + }) + + it('adds a line comment from a code preview to the chat context', async () => { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-line-comment': { + isOpen: true, + activeView: 'all', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-line-comment': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + previewTabsBySession: { + ...state.previewTabsBySession, + 'session-line-comment': [{ + id: 'file:src/App.tsx', + path: 'src/App.tsx', + kind: 'file', + title: 'App.tsx', + language: 'tsx', + content: 'const title = "Todo"\nexport default title', + state: 'ok', + size: 42, + }], + }, + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + 'session-line-comment': 'file:src/App.tsx', + }, + })) + + const view = await renderPanel('session-line-comment') + + await clickElement(view.getByRole('button', { name: 'Comment line 1' })) + const textarea = view.getByPlaceholderText('Describe what should change here...') + await act(() => { + fireEvent.change(textarea, { target: { value: 'Rename this title' } }) + }) + await clickElement(view.getByRole('button', { name: 'Add comment' })) + + expect(useWorkspaceChatContextStore.getState().referencesBySession['session-line-comment']).toMatchObject([ + { + kind: 'code-comment', + path: 'src/App.tsx', + absolutePath: '/repo/src/App.tsx', + name: 'App.tsx', + lineStart: 1, + lineEnd: 1, + note: 'Rename this title', + quote: 'const title = "Todo"', + }, + ]) + }) + + 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('keeps the workspace header controls compact', async () => { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-compact-header': { + isOpen: true, + activeView: 'changed', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-compact-header': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + })) + + const view = await renderPanel('session-compact-header') + const viewMenuButton = view.getByRole('button', { name: 'Changed files' }) + const refreshButton = view.getByRole('button', { name: 'Refresh workspace' }) + const closeButton = view.getByRole('button', { name: 'Close workspace panel' }) + + expect(viewMenuButton.className).toContain('text-[14px]') + expect(viewMenuButton.className).not.toContain('text-[18px]') + expect(viewMenuButton.querySelector('.material-symbols-outlined')?.className).toContain('text-[15px]') + expect(refreshButton.className).toContain('h-7 w-7') + expect(closeButton.className).toContain('h-7 w-7') + expect(refreshButton.querySelector('.material-symbols-outlined')?.className).toContain('text-[16px]') + expect(closeButton.querySelector('.material-symbols-outlined')?.className).toContain('text-[16px]') + }) + + 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..b65830ba --- /dev/null +++ b/desktop/src/components/workspace/WorkspacePanel.tsx @@ -0,0 +1,1173 @@ +import { useEffect, useMemo, useState, type MouseEvent } from 'react' +import { Highlight } 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 WorkspacePreviewCloseScope, + type WorkspacePreviewKind, + type WorkspacePreviewTab, +} from '../../stores/workspacePanelStore' +import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore' +import { MarkdownRenderer } from '../markdown/MarkdownRenderer' +import { + getFileExtension, + normalizePrismLanguage, + WORKSPACE_PREVIEW_LINE_LIMIT, + WorkspaceDiffSurface, + workspacePrismTheme, +} from './WorkspaceCodeSurface' + +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 + onFileContextMenu: (event: MouseEvent, 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 FILE_BADGE_META: Record = { + ts: { label: 'TS', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' }, + tsx: { label: 'TSX', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' }, + js: { label: 'JS', className: 'bg-[var(--color-warning)]/16 text-[var(--color-warning)]' }, + jsx: { label: 'JSX', className: 'bg-[var(--color-warning)]/16 text-[var(--color-warning)]' }, + json: { label: '{}', className: 'bg-[var(--color-tertiary)]/14 text-[var(--color-tertiary)]' }, + md: { label: 'MD', className: 'bg-[var(--color-text-tertiary)]/14 text-[var(--color-text-secondary)]' }, + css: { label: 'CSS', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' }, + html: { label: 'H', className: 'bg-[var(--color-brand)]/14 text-[var(--color-brand)]' }, + png: { label: 'IMG', className: 'bg-[var(--color-success)]/14 text-[var(--color-success)]' }, + jpg: { label: 'IMG', className: 'bg-[var(--color-success)]/14 text-[var(--color-success)]' }, + jpeg: { label: 'IMG', className: 'bg-[var(--color-success)]/14 text-[var(--color-success)]' }, + gif: { label: 'IMG', className: 'bg-[var(--color-success)]/14 text-[var(--color-success)]' }, + svg: { label: 'SVG', className: 'bg-[var(--color-success)]/14 text-[var(--color-success)]' }, +} + +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 getFileBadgeMeta(name: string) { + const extension = getFileExtension(name) + return FILE_BADGE_META[extension] ?? { + label: extension ? extension.slice(0, 3).toUpperCase() : 'TXT', + className: 'bg-[var(--color-text-tertiary)]/12 text-[var(--color-text-secondary)]', + } +} + +function resolveWorkspaceAttachmentPath(workDir: string | undefined, filePath: string) { + if (!workDir || filePath.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(filePath)) return filePath + return `${workDir.replace(/[\\/]+$/, '')}/${filePath.replace(/^[/\\]+/, '')}` +} + +function isMarkdownPreview(tab: WorkspacePreviewTab) { + if (tab.kind !== 'file') return false + const language = (tab.language ?? '').toLowerCase() + const extension = getFileExtension(tab.path) + return language === 'markdown' || language === 'md' || extension === 'md' || extension === 'markdown' +} + +function FileTypeBadge({ name, subtle = false }: { name: string; subtle?: boolean }) { + const meta = getFileBadgeMeta(name) + 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, + onAddLineComment, +}: { + value: string + language: string + onAddLineComment: (line: number, note: string, quote: string) => void +}) { + const t = useTranslation() + const [commentLine, setCommentLine] = useState(null) + const [commentDraft, setCommentDraft] = useState('') + 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 activeQuote = commentLine ? visibleLines[commentLine - 1] ?? '' : '' + + const submitLineComment = () => { + if (!commentLine || !commentDraft.trim()) return + onAddLineComment(commentLine, commentDraft.trim(), activeQuote) + setCommentLine(null) + setCommentDraft('') + } + + return ( +
+
+ + {({ tokens, getLineProps, getTokenProps }) => ( +
+              {tokens.map((line, index) => {
+                const { key: lineKey, ...lineProps } = getLineProps({ line, key: index })
+                const lineNumber = index + 1
+                return (
+                  
+
+ + + {line.length === 1 && line[0]?.empty ? ' ' : line.map((token, tokenIndex) => { + const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex }) + return + })} + +
+ {commentLine === lineNumber && ( +
+