Merge commit 'df52bea0d0cb5889f9c5e324c54f2bdce9e30378'

This commit is contained in:
程序员阿江(Relakkes) 2026-05-01 17:19:31 +08:00
commit 10b974dac3
45 changed files with 8527 additions and 531 deletions

3
.gitignore vendored
View File

@ -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
.idea

View File

@ -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

View File

@ -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}" \

View File

@ -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()
}

View File

@ -139,6 +139,82 @@ export type SessionInspectionResponse = {
errors?: Record<string, string>
}
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<WorkspaceStatusResult>(buildWorkspacePath(sessionId, 'status'))
},
getWorkspaceTree(sessionId: string, workspacePath = '') {
return api.get<WorkspaceTreeResult>(buildWorkspacePath(sessionId, 'tree', workspacePath))
},
getWorkspaceFile(sessionId: string, workspacePath: string) {
return api.get<WorkspaceReadFileResult>(buildWorkspacePath(sessionId, 'file', workspacePath))
},
getWorkspaceDiff(sessionId: string, workspacePath: string) {
return api.get<WorkspaceDiffResult>(buildWorkspacePath(sessionId, 'diff', workspacePath))
},
rewind(sessionId: string, body: {
targetUserMessageId?: string
userMessageIndex?: number

View File

@ -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' }],

View File

@ -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 (
<>
<div className={isComposer ? 'flex flex-wrap items-center gap-2' : 'grid grid-cols-1 gap-2 sm:grid-cols-2'}>
<div className={isComposer ? 'flex flex-wrap items-center gap-2' : 'flex flex-wrap justify-end gap-2'}>
{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 (
<div
key={attachment.id || `${attachment.name}-${index}`}
className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2 text-xs text-[var(--color-text-secondary)]"
className={[
'group/file inline-flex max-w-full min-w-0 items-center gap-2 rounded-full border border-[var(--color-border)]',
'bg-[var(--color-surface-container-low)] text-[var(--color-text-secondary)] shadow-[0_1px_2px_rgba(0,0,0,0.04)]',
isComposer ? 'h-9 px-3' : 'h-9 px-3',
].join(' ')}
>
<span className="material-symbols-outlined text-[14px]">attach_file</span>
<span className="max-w-[220px] truncate">{attachment.name}</span>
<span className="material-symbols-outlined shrink-0 text-[17px] text-[var(--color-text-tertiary)]">
{attachment.lineStart ? 'chat_bubble' : 'description'}
</span>
<span className="min-w-0 max-w-[220px] truncate text-[13px] font-medium leading-none text-[var(--color-text-primary)]">
{attachment.name}
{attachment.lineStart
? `:L${attachment.lineStart}${attachment.lineEnd && attachment.lineEnd !== attachment.lineStart ? `-L${attachment.lineEnd}` : ''}`
: ''}
</span>
{onRemove && attachment.id && (
<button
type="button"
onClick={() => onRemove(attachment.id!)}
className="ml-1 text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-error)]"
className="ml-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
aria-label={`Remove ${attachment.name}`}
>
<span className="material-symbols-outlined text-[14px]">close</span>
<span className="material-symbols-outlined text-[17px]">close</span>
</button>
)}
</div>

View File

@ -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<Attachment[]>([])
@ -67,13 +93,27 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null)
const [gitInfo, setGitInfo] = useState<GitInfo | null>(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 (
<div className={isHeroComposer ? 'bg-[var(--color-surface)] px-8 pb-4' : 'bg-[var(--color-surface)] px-4 py-4'}>
<div className={isHeroComposer ? 'mx-auto flex w-full max-w-3xl flex-col gap-2' : 'mx-auto max-w-[860px]'}>
<div
className={
isHeroComposer
? 'bg-[var(--color-surface)] px-8 pb-4'
: compact
? 'border-t border-[var(--color-border)]/70 bg-[var(--color-surface)] px-3 py-3'
: 'bg-[var(--color-surface)] px-4 py-4'
}
>
<div
className={
isHeroComposer
? 'mx-auto flex w-full max-w-3xl flex-col gap-2'
: compact
? 'mx-auto max-w-full'
: 'mx-auto max-w-[860px]'
}
>
<div
className={isHeroComposer
? 'glass-panel relative flex flex-col gap-3 rounded-xl p-4 transition-colors'
: 'glass-panel relative rounded-xl p-4 transition-colors'}
: compact
? 'glass-panel relative rounded-xl p-3 transition-colors'
: 'glass-panel relative rounded-xl p-4 transition-colors'}
onDragOver={(event) => 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) {
</div>
)}
{attachments.length > 0 && (
{composerAttachments.length > 0 && (
isHeroComposer ? (
<AttachmentGallery attachments={attachments} variant="composer" onRemove={removeAttachment} />
<AttachmentGallery attachments={composerAttachments} variant="composer" onRemove={removeAttachment} />
) : (
<div className="px-3 pt-3">
<AttachmentGallery attachments={attachments} variant="composer" onRemove={removeAttachment} />
<AttachmentGallery attachments={composerAttachments} variant="composer" onRemove={removeAttachment} />
</div>
)
)}
@ -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'
}`}
/>
)}
<div className={isHeroComposer
? 'flex items-center justify-between border-t border-[var(--color-border-separator)] pt-3'
: 'absolute bottom-0 left-0 right-0 flex items-center justify-between border-t border-[var(--color-border-separator)] px-3 py-3'}>
<div className="flex items-center gap-2">
: `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'
}`}>
<div className="flex min-w-0 items-center gap-2">
{!isMemberSession && (
<>
<div ref={plusMenuRef} className="relative">
@ -679,20 +789,30 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
)}
</div>
<PermissionModeSelector />
<PermissionModeSelector compact={compact} />
</>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex min-w-0 items-center gap-2">
{!isMemberSession && activeTabId && (
<ModelSelector runtimeKey={activeTabId} disabled={isActive} />
<ModelSelector runtimeKey={activeTabId} disabled={isActive} compact={compact} />
)}
<button
onClick={!isMemberSession && isActive ? () => stopGeneration(activeTabId!) : handleSubmit}
disabled={!isMemberSession && isActive ? false : !canSubmit}
title={!isMemberSession && isActive ? t('chat.stopTitle') : undefined}
className={`flex w-[112px] items-center justify-center gap-1 rounded-lg px-3 py-1.5 text-xs font-semibold transition-all hover:brightness-105 disabled:opacity-30 ${
title={
!isMemberSession && isActive
? t('chat.stopTitle')
: compact
? isMemberSession
? t('common.send')
: t('common.run')
: undefined
}
className={`flex items-center justify-center gap-1 rounded-lg text-xs font-semibold transition-all hover:brightness-105 disabled:opacity-30 ${
compact ? 'h-8 w-8 px-0 py-0' : 'w-[112px] px-3 py-1.5'
} ${
!isMemberSession && isActive
? 'bg-[var(--color-error-container)] text-[var(--color-on-error-container)]'
: 'bg-[image:var(--gradient-btn-primary)] text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)]'
@ -701,7 +821,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
<span className="material-symbols-outlined text-[14px]">
{!isMemberSession && isActive ? 'stop' : 'arrow_forward'}
</span>
{!isMemberSession && isActive ? t('common.stop') : isMemberSession ? t('common.send') : t('common.run')}
{!compact && (!isMemberSession && isActive ? t('common.stop') : isMemberSession ? t('common.send') : t('common.run'))}
</button>
</div>
</div>
@ -710,12 +830,13 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileSelect} />
{!isMemberSession && (
<div className="mt-3 px-1">
<div className={compact ? 'mt-2 flex min-w-0 justify-center px-1' : 'mt-3 px-1'}>
{hasMessages ? (
<ProjectContextChip
workDir={resolvedWorkDir}
repoName={gitInfo?.repoName || null}
branch={gitInfo?.branch || null}
compact={compact}
/>
) : (
<DirectoryPicker

View File

@ -73,12 +73,14 @@ vi.mock('../../api/computerUse', () => ({
}))
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: {} })
})

View File

@ -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<string | null>(null)
const [diffByPath, setDiffByPath] = useState<Record<string, DiffPreviewState>>({})
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 (
<section
className="mx-auto mb-5 w-full max-w-[860px] overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-sm"
aria-label={t('chat.turnChangesCardLabel')}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
<div className="min-w-0">
<div className="flex flex-wrap items-baseline gap-2">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
{t('chat.turnChangesTitle', { count: files.length })}
</span>
<span className="font-mono text-sm font-semibold text-[var(--color-success)]">
+{preview.code.insertions}
</span>
<span className="font-mono text-sm font-semibold text-[var(--color-error)]">
-{preview.code.deletions}
</span>
</div>
<div className="mt-0.5 text-xs text-[var(--color-text-tertiary)]">
{t('chat.turnChangesSubtitle')}
</div>
</div>
<button
type="button"
onClick={onUndo}
disabled={isUndoing}
aria-label={t('chat.turnChangesUndoAria')}
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-brand)]/40 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="material-symbols-outlined text-[15px]">undo</span>
{isUndoing ? t('chat.turnChangesUndoing') : t('chat.turnChangesUndo')}
</button>
</div>
<div className="divide-y divide-[var(--color-border)]">
{files.map((filePath) => {
const isExpanded = expandedPath === filePath
const diffState = diffByPath[filePath]
return (
<div key={filePath}>
<button
type="button"
onClick={() => toggleDiff(filePath)}
aria-label={t(
isExpanded ? 'chat.turnChangesHideDiffAria' : 'chat.turnChangesShowDiffAria',
{ path: filePath },
)}
className="flex min-h-11 w-full items-center gap-3 px-4 text-left text-sm text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35"
>
<span className="material-symbols-outlined shrink-0 text-[17px] text-[var(--color-text-tertiary)]">
{isExpanded ? 'keyboard_arrow_down' : 'chevron_right'}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-[13px]">
{filePath}
</span>
</button>
{isExpanded && (
<div className="border-t border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-4 py-3">
{diffState?.loading ? (
<div className="text-xs text-[var(--color-text-tertiary)]">
{t('chat.turnChangesDiffLoading')}
</div>
) : diffState?.error ? (
<div className="text-xs text-[var(--color-error)]">
{diffState.error}
</div>
) : diffState?.diff ? (
<WorkspaceDiffSurface
value={diffState.diff}
path={filePath}
className="max-h-[430px] overflow-auto rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-code-bg)]"
/>
) : (
<div className="text-xs text-[var(--color-text-tertiary)]">
{t('chat.turnChangesDiffUnavailable')}
</div>
)}
</div>
)}
</div>
)
})}
</div>
{error && (
<div className="border-t border-[var(--color-error)]/20 bg-[var(--color-error-container)]/18 px-4 py-3 text-xs text-[var(--color-error)]">
{error}
</div>
)}
</section>
)
}
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
}

View File

@ -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 () => {

View File

@ -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 (
<div
@ -29,27 +24,13 @@ export function MessageActionBar({
}`}
>
<div className="flex items-center gap-1.5">
{hasRewind && (
<button
type="button"
onClick={onRewind}
aria-label={rewindLabel}
title={rewindLabel}
className="inline-flex min-h-7 items-center gap-1 rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface-container-low)] px-2.5 text-[11px] font-medium text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
>
<span className="material-symbols-outlined text-[14px]">undo</span>
<span className="hidden min-[920px]:inline">Rewind</span>
</button>
)}
{hasCopy && (
<CopyButton
text={copyText!}
label={copyLabel}
displayLabel="Copy"
displayCopiedLabel="Copied"
className="inline-flex min-h-7 items-center rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface-container-low)] px-2.5 text-[11px] font-medium text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
/>
)}
<CopyButton
text={copyText!}
label={copyLabel}
displayLabel="Copy"
displayCopiedLabel="Copied"
className="inline-flex min-h-7 items-center rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface-container-low)] px-2.5 text-[11px] font-medium text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
/>
</div>
</div>
)

View File

@ -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(<MessageList />)
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(<MessageList />)
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(<MessageList />)
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(<MessageList />)
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(<MessageList />)
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(<MessageList />)
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: {

View File

@ -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<UIMessage, { type: 'tool_use' }>
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
@ -34,6 +34,20 @@ type RenderModel = {
childToolCallsByParent: Map<string, ToolCall[]>
}
type RewindTurnTarget = {
messageId: string
userMessageIndex: number
content: string
expectedContent: string
attachments?: Extract<UIMessage, { type: 'user_text' }>['attachments']
}
type CurrentTurnPreview = {
target: RewindTurnTarget
preview: SessionRewindResponse
workDir: string | null
}
function appendChildToolCall(
childToolCallsByParent: Map<string, ToolCall[]>,
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<string | null | undefined>(resolvedSessionId)
const t = useTranslation()
const [rewindTarget, setRewindTarget] = useState<{
messageId: string
userMessageIndex: number
content: string
attachments?: Extract<UIMessage, { type: 'user_text' }>['attachments']
} | null>(null)
const [rewindPreview, setRewindPreview] = useState<SessionRewindResponse | null>(null)
const [rewindError, setRewindError] = useState<string | null>(null)
const [isLoadingPreview, setIsLoadingPreview] = useState(false)
const [isExecutingRewind, setIsExecutingRewind] = useState(false)
const [currentTurnPreview, setCurrentTurnPreview] = useState<CurrentTurnPreview | null>(null)
const [currentTurnError, setCurrentTurnError] = useState<string | null>(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 (
<div
ref={scrollContainerRef}
onScroll={updateAutoScrollState}
className="flex-1 overflow-y-auto px-4 py-4"
className={`flex-1 overflow-y-auto ${compact ? 'px-3 py-3 pb-5' : 'px-4 py-4'}`}
>
<div className="mx-auto max-w-[860px]">
<div className={compact ? 'mx-auto max-w-full' : 'mx-auto max-w-[860px]'}>
{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 (
<MessageBlock
key={msg.id}
@ -330,19 +384,6 @@ export function MessageList({ sessionId }: MessageListProps = {}) {
})()
: null
}
rewindableUserIndex={rewindableUserIndex}
onRequestRewind={
!isMemberSession
? (message, userMessageIndex) => {
setRewindTarget({
messageId: message.id,
userMessageIndex,
content: message.content,
attachments: message.attachments,
})
}
: undefined
}
/>
)
})}
@ -359,121 +400,43 @@ export function MessageList({ sessionId }: MessageListProps = {}) {
<StreamingIndicator />
)}
{!isLoadingCurrentTurnPreview && currentTurnPreview && resolvedSessionId && (
<CurrentTurnChangeCard
sessionId={resolvedSessionId}
preview={currentTurnPreview.preview}
workDir={currentTurnPreview.workDir}
error={currentTurnError}
isUndoing={isUndoingCurrentTurn}
onUndo={() => {
setCurrentTurnUndoConfirmOpen(true)
}}
/>
)}
{!currentTurnPreview && currentTurnError && (
<div className="mx-auto mb-5 w-full max-w-[860px] rounded-[var(--radius-lg)] border border-[var(--color-error)]/25 bg-[var(--color-error-container)]/18 px-4 py-3 text-xs text-[var(--color-error)]">
{currentTurnError}
</div>
)}
<div ref={bottomRef} />
</div>
<Modal
open={Boolean(rewindTarget)}
onClose={closeRewindModal}
title={t('chat.rewindModalTitle')}
footer={
<>
<Button
variant="ghost"
onClick={closeRewindModal}
disabled={isExecutingRewind}
>
{t('common.cancel')}
</Button>
<Button
onClick={() => {
void handleConfirmRewind()
}}
loading={isExecutingRewind}
disabled={isLoadingPreview || Boolean(rewindError)}
icon={
!isExecutingRewind ? (
<span className="material-symbols-outlined text-[16px]">undo</span>
) : undefined
}
>
{t('chat.rewindConfirm')}
</Button>
</>
}
>
<div className="space-y-4">
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
<div className="mb-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
{t('chat.rewindPromptLabel')}
</div>
<div className="whitespace-pre-wrap break-words text-sm leading-relaxed text-[var(--color-text-primary)]">
{rewindTarget?.content || t('chat.rewindAttachmentOnly')}
</div>
</div>
{isLoadingPreview && (
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3 text-sm text-[var(--color-text-secondary)]">
{t('chat.rewindLoading')}
</div>
)}
{!isLoadingPreview && rewindPreview && (
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_220px]">
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[var(--color-text-primary)]">
<span className="material-symbols-outlined text-[16px] text-[var(--color-brand)]">history</span>
{t('chat.rewindConversationCardTitle')}
</div>
<p className="text-sm leading-relaxed text-[var(--color-text-secondary)]">
{t('chat.rewindConversationCardBody', {
count: rewindPreview.conversation.messagesRemoved,
})}
</p>
</div>
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[var(--color-text-primary)]">
<span className="material-symbols-outlined text-[16px] text-[var(--color-brand)]">code</span>
{t('chat.rewindCodeCardTitle')}
</div>
{rewindPreview.code.available ? (
<div className="space-y-1 text-sm text-[var(--color-text-secondary)]">
<div>{t('chat.rewindCodeFiles', { count: rewindPreview.code.filesChanged.length })}</div>
<div>{t('chat.rewindCodeInsertions', { count: rewindPreview.code.insertions })}</div>
<div>{t('chat.rewindCodeDeletions', { count: rewindPreview.code.deletions })}</div>
</div>
) : (
<p className="text-sm leading-relaxed text-[var(--color-text-secondary)]">
{rewindPreview.code.reason || t('chat.rewindCodeUnavailable')}
</p>
)}
</div>
</div>
)}
{!isLoadingPreview && rewindPreview?.code.available && rewindPreview.code.filesChanged.length > 0 && (
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
{t('chat.rewindFilesLabel')}
</div>
<div className="flex flex-wrap gap-2">
{rewindPreview.code.filesChanged.slice(0, 8).map((filePath) => (
<span
key={filePath}
className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)]"
>
{filePath}
</span>
))}
{rewindPreview.code.filesChanged.length > 8 && (
<span className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)]">
{t('chat.rewindFilesMore', {
count: rewindPreview.code.filesChanged.length - 8,
})}
</span>
)}
</div>
</div>
)}
{rewindError && (
<div className="rounded-[var(--radius-lg)] border border-[var(--color-error)]/30 bg-[var(--color-error-container)]/22 px-4 py-3 text-sm text-[var(--color-error)]">
{rewindError}
</div>
)}
</div>
</Modal>
<ConfirmDialog
open={currentTurnUndoConfirmOpen}
onClose={() => {
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}
/>
</div>
)
}
@ -483,18 +446,11 @@ export const MessageBlock = memo(function MessageBlock({
activeThinkingId,
agentTaskNotifications,
toolResult,
rewindableUserIndex,
onRequestRewind,
}: {
message: UIMessage
activeThinkingId: string | null
agentTaskNotifications: Record<string, AgentTaskNotification>
toolResult?: { content: unknown; isError: boolean } | null
rewindableUserIndex?: number | null
onRequestRewind?: (
message: Extract<UIMessage, { type: 'user_text' }>,
userMessageIndex: number,
) => void
}) {
const t = useTranslation()
@ -504,12 +460,6 @@ export const MessageBlock = memo(function MessageBlock({
<UserMessage
content={message.content}
attachments={message.attachments}
onRewind={
typeof rewindableUserIndex === 'number' && onRequestRewind
? () => onRequestRewind(message, rewindableUserIndex)
: undefined
}
rewindLabel={t('chat.rewindAction')}
/>
)
case 'assistant_text':

View File

@ -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
<MessageActionBar
copyText={content}
copyLabel="Copy prompt"
onRewind={onRewind}
rewindLabel={rewindLabel}
align="end"
/>
)}

View File

@ -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: {} })
})

View File

@ -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({
<button
onClick={() => !disabled && setOpen(!open)}
disabled={disabled}
className="flex max-w-[280px] items-center gap-2 rounded-full bg-[var(--color-surface-container-low)] px-3 py-1.5 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] disabled:cursor-not-allowed disabled:opacity-50"
className={`flex items-center gap-2 rounded-full bg-[var(--color-surface-container-low)] text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] disabled:cursor-not-allowed disabled:opacity-50 ${
compact ? 'max-w-[152px] px-2.5 py-1.5' : 'max-w-[280px] px-3 py-1.5'
}`}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--color-text-primary)]">
<span className={`${compact ? 'text-xs' : 'text-sm'} min-w-0 flex-1 truncate font-semibold text-[var(--color-text-primary)]`}>
{buttonModelLabel}
</span>
{buttonProviderLabel && (
{!compact && buttonProviderLabel && (
<span className="max-w-[108px] flex-shrink-0 truncate text-[11px] text-[var(--color-text-tertiary)]">
{buttonProviderLabel}
</span>

View File

@ -18,13 +18,14 @@ const MODE_ICONS: Record<PermissionMode, string> = {
type Props = {
workDir?: string
compact?: boolean
/** Controlled mode: override current value */
value?: PermissionMode
/** Controlled mode: called on change instead of updating global store */
onChange?: (mode: PermissionMode) => void
}
export function PermissionModeSelector({ workDir: workDirProp, value, onChange }: Props = {}) {
export function PermissionModeSelector({ workDir: workDirProp, compact = false, value, onChange }: Props = {}) {
const t = useTranslation()
const { permissionMode: storeMode, setPermissionMode } = useSettingsStore()
const setSessionPermissionMode = useChatStore((s) => s.setSessionPermissionMode)
@ -104,11 +105,20 @@ export function PermissionModeSelector({ workDir: workDirProp, value, onChange }
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-[var(--color-surface-container-low)] hover:bg-[var(--color-surface-hover)] rounded-full text-xs font-medium text-[var(--color-text-secondary)] transition-colors"
title={compact ? MODE_LABELS[currentMode] : undefined}
className={`flex items-center bg-[var(--color-surface-container-low)] font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] ${
compact
? 'h-8 w-8 justify-center rounded-full p-0'
: 'gap-1.5 rounded-full px-2.5 py-1.5 text-xs'
}`}
>
<span className="material-symbols-outlined text-[14px]">{MODE_ICONS[currentMode]}</span>
<span>{MODE_LABELS[currentMode]}</span>
<span className="material-symbols-outlined text-[12px]">expand_more</span>
{!compact && (
<>
<span>{MODE_LABELS[currentMode]}</span>
<span className="material-symbols-outlined text-[12px]">expand_more</span>
</>
)}
</button>
{open && (

View File

@ -11,7 +11,6 @@ vi.mock('../../i18n', () => ({
const translations: Record<string, string> = {
'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(<Sidebar />)
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'))

View File

@ -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')}
</NavItem>
<NavItem
active={activeTabType === 'terminal'}
collapsed={!sidebarOpen}
label={t('sidebar.terminal')}
onClick={() => useTabStore.getState().openTerminalTab()}
icon={<span className="material-symbols-outlined text-[18px]">terminal</span>}
>
{t('sidebar.terminal')}
</NavItem>
</div>
{sidebarOpen ? (

View File

@ -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<ReturnType<typeof useChatStore.getState>>)
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
delete (window as typeof window & { __TAURI__?: unknown }).__TAURI__
})
@ -356,11 +361,122 @@ describe('TabBar', () => {
render(<TabBar />)
})
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<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
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<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
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<ReturnType<typeof useChatStore.getState>>)
const { rerender } = render(<TabBar />)
expect(screen.queryByRole('button', { name: 'Show Workspace' })).not.toBeInTheDocument()
await act(async () => {
useTabStore.getState().setActiveTab('__settings__')
})
rerender(<TabBar />)
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<ReturnType<typeof useChatStore.getState>>)
useWorkspacePanelStore.getState().openPanel('tab-1')
await act(async () => {
render(<TabBar />)
})
fireEvent.click(screen.getByLabelText('Close First Session'))
expect(useWorkspacePanelStore.getState().panelBySession['tab-1']).toBeUndefined()
})
})

View File

@ -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<HTMLDivElement>(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() {
))}
</div>
<div className="flex shrink-0 items-center gap-1 border-l border-[var(--color-border)]/70 px-2">
<ToolbarIconButton
icon={<SquareTerminal size={17} strokeWidth={1.9} />}
label={t('tabs.openTerminal')}
onClick={() => useTabStore.getState().openTerminalTab()}
/>
{isActiveSessionTab && activeTabId && (
<ToolbarIconButton
icon={isWorkspacePanelOpen ? <FolderOpen size={18} strokeWidth={1.9} /> : <Folder size={18} strokeWidth={1.9} />}
label={t(isWorkspacePanelOpen ? 'tabs.hideWorkspace' : 'tabs.showWorkspace')}
onClick={() => useWorkspacePanelStore.getState().togglePanel(activeTabId)}
active={isWorkspacePanelOpen}
/>
)}
</div>
{isTauri && (
<div
data-testid="tab-bar-drag-gutter"
@ -331,7 +361,11 @@ export function TabBar() {
{t('common.cancel')}
</button>
<button
onClick={() => { closeTab(closingTabId); setClosingTabId(null) }}
onClick={() => {
const tab = tabs.find((item) => item.sessionId === closingTabId)
if (tab) closeTabWithCleanup(tab)
setClosingTabId(null)
}}
className="px-3 py-1.5 text-xs rounded-lg border border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]"
>
{t('tabs.closeConfirmKeep')}
@ -340,7 +374,8 @@ export function TabBar() {
onClick={() => {
useChatStore.getState().stopGeneration(closingTabId)
disconnectSession(closingTabId)
closeTab(closingTabId)
const tab = tabs.find((item) => item.sessionId === closingTabId)
if (tab) closeTabWithCleanup(tab)
setClosingTabId(null)
}}
className="px-3 py-1.5 text-xs rounded-lg bg-[var(--color-brand)] text-white hover:opacity-90"
@ -423,3 +458,32 @@ const TabItem = forwardRef<HTMLDivElement, {
)
})
TabItem.displayName = 'TabItem'
function ToolbarIconButton({
icon,
label,
onClick,
active = false,
}: {
icon: React.ReactNode
label: string
onClick: () => void
active?: boolean
}) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
data-active={active ? 'true' : 'false'}
className={`inline-flex h-8 w-8 items-center justify-center rounded-[10px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${
active
? 'bg-[var(--color-surface-hover)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
}`}
>
{icon}
</button>
)
}

View File

@ -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 (
<div className="inline-flex max-w-full items-center gap-2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-4 py-2 text-sm text-[var(--color-text-secondary)]">
<div
className={`inline-flex max-w-full items-center rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] text-[var(--color-text-secondary)] ${
compact ? 'gap-1.5 px-3 py-1.5 text-xs' : 'gap-2 px-4 py-2 text-sm'
}`}
>
{branch ? (
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" className="shrink-0 text-[var(--color-text-secondary)]">
<svg width={compact ? 15 : 18} height={compact ? 15 : 18} viewBox="0 0 16 16" fill="currentColor" className="shrink-0 text-[var(--color-text-secondary)]">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
) : (
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">folder</span>
<span className={`material-symbols-outlined text-[var(--color-text-secondary)] ${compact ? 'text-[15px]' : 'text-[18px]'}`}>folder</span>
)}
<span className="truncate font-medium text-[var(--color-text-primary)]">{label}</span>
{branch ? (

View File

@ -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,

View File

@ -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<string, string> = {
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 (
<Highlight
theme={workspacePrismTheme}
code={value}
language={normalizePrismLanguage(language)}
>
{({ tokens, getTokenProps }) => (
<>
{(tokens[0] ?? []).map((token, tokenIndex) => {
const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex })
return <span key={String(tokenKey)} {...tokenProps} />
})}
</>
)}
</Highlight>
)
}
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 (
<div className={className}>
<div className="relative min-w-max py-2">
<pre
data-workspace-code=""
data-testid="workspace-code"
className="m-0 font-[var(--font-mono)] text-[12px] leading-[1.55] text-[var(--color-code-fg)]"
>
{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 (
<div
key={`${index}:${line}`}
className={`grid grid-cols-[48px_18px_minmax(0,1fr)] gap-2 px-3 ${
isAdded
? 'bg-[var(--color-diff-added-bg)]'
: isRemoved
? 'bg-[var(--color-diff-removed-bg)]'
: isHunk
? 'bg-[var(--color-diff-highlight-bg)]'
: 'hover:bg-[var(--color-surface-hover)]'
}`}
>
<span className="select-none text-right text-[11px] text-[var(--color-text-tertiary)]">
{index + 1}
</span>
<span
className={`select-none text-center ${
isAdded
? 'text-[var(--color-diff-added-text)]'
: isRemoved
? 'text-[var(--color-diff-removed-text)]'
: 'text-[var(--color-text-tertiary)]'
}`}
>
{prefix}
</span>
<span
className={`whitespace-pre pr-6 ${
isFileHeader
? 'font-semibold text-[var(--color-text-secondary)]'
: isHunk
? 'font-semibold text-[var(--color-warning)]'
: ''
}`}
>
{isCodeLine ? (
code ? <InlineHighlightedCode value={code} language={language} /> : ' '
) : (
code || ' '
)}
</span>
</div>
)
})}
</pre>
{hiddenLineCount > 0 && (
<div className="sticky bottom-0 border-t border-[var(--color-border)] bg-[var(--color-surface-glass)] px-3 py-2 text-xs text-[var(--color-text-tertiary)] backdrop-blur">
{t('workspace.previewLineLimit', { count: lineLimit })}
</div>
)}
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -41,6 +41,41 @@ export const en = {
'titlebar.terminal': 'Terminal',
'titlebar.history': 'History',
// ─── Workspace Panel ───────────────────────────────
'workspace.changedFiles': 'Changed files',
'workspace.allFiles': 'All files',
'workspace.viewTabs': 'Workspace views',
'workspace.previewTabs': 'Preview tabs',
'workspace.filterPlaceholder': 'Filter files...',
'workspace.clearFilter': 'Clear file filter',
'workspace.refresh': 'Refresh workspace',
'workspace.closePanel': 'Close workspace panel',
'workspace.resizePanel': 'Resize workspace panel',
'workspace.closeTab': 'Close tab',
'workspace.preview': 'Preview',
'workspace.previewEmpty': 'Select a file to preview.',
'workspace.notGitRepo': 'Not a git repository.',
'workspace.missingWorkdir': 'Working directory is missing.',
'workspace.loadError': 'Failed to load workspace data.',
'workspace.noChanges': 'No changes',
'workspace.noFiles': 'No files',
'workspace.noMatchingFiles': 'No matching files',
'workspace.previewKind.diff': 'Diff',
'workspace.previewKind.file': 'File',
'workspace.previewState.loading': 'Loading preview...',
'workspace.previewState.binary': 'Binary file preview is unavailable.',
'workspace.previewState.tooLarge': 'File is too large to preview.',
'workspace.previewState.missing': 'File not found.',
'workspace.imagePreviewUnavailable': 'Image preview is unavailable.',
'workspace.previewLineLimit': 'Showing first {count} lines. Open in your editor for the full file.',
'workspace.addToChat': 'Add to chat',
'workspace.copyPath': 'Copy path',
'workspace.localComment': 'Local comment',
'workspace.commentLine': 'Comment line {line}',
'workspace.commentLineTarget': 'Line {line}',
'workspace.commentPlaceholder': 'Describe what should change here...',
'workspace.addCommentToChat': 'Add comment',
// ─── Status Bar ──────────────────────────────────────
'status.connected': 'Connected',
'status.connecting': 'Connecting...',
@ -520,6 +555,7 @@ export const en = {
'chat.placeholder': 'Ask Claude to edit, debug or explain...',
'chat.placeholderMissing': 'This session points to a missing workspace. Create a new session or pick another project.',
'chat.addFiles': 'Add files or photos',
'chat.workspaceReferencesOnly': 'Added {count} workspace references',
'chat.slashCommands': 'Slash commands',
'slash.mcp.title': 'Available MCP tools',
'slash.mcp.subtitle': 'Global and current-project MCP servers available in this chat context.',
@ -610,23 +646,21 @@ export const en = {
'chat.select': 'select',
'chat.dismiss': 'dismiss',
'chat.stopTitle': 'Stop generation (Cmd+.)',
'chat.rewindAction': 'Rewind to here',
'chat.rewindModalTitle': 'Rewind Conversation',
'chat.rewindConfirm': 'Rewind here',
'chat.rewindPromptLabel': 'Selected prompt',
'chat.rewindAttachmentOnly': 'This message only contains attachments.',
'chat.rewindLoading': 'Inspecting file checkpoints for this turn...',
'chat.rewindConversationCardTitle': 'Conversation rollback',
'chat.rewindConversationCardBody': 'Remove this prompt and the {count} active messages that follow it, then reopen the composer at that point.',
'chat.rewindCodeCardTitle': 'Code rollback',
'chat.rewindCodeFiles': '{count} files will be restored',
'chat.rewindCodeInsertions': '{count} insertions will be removed',
'chat.rewindCodeDeletions': '{count} deletions will be restored',
'chat.rewindCodeUnavailable': 'No file checkpoint is available for this prompt. The conversation can still be rewound.',
'chat.rewindFilesLabel': 'Affected files',
'chat.rewindFilesMore': '+{count} more',
'chat.rewindSuccessWithCode': 'Rewound {count} messages and restored tracked files.',
'chat.rewindSuccessConversationOnly': 'Rewound {count} messages. No file checkpoint was available for this turn.',
'chat.turnChangesCardLabel': 'Current turn changed files',
'chat.turnChangesTitle': '{count} files changed',
'chat.turnChangesSubtitle': 'Current turn checkpoint',
'chat.turnChangesUndo': 'Undo',
'chat.turnChangesUndoing': 'Undoing...',
'chat.turnChangesUndoAria': 'Undo current turn changes',
'chat.turnChangesConfirmTitle': 'Undo current turn?',
'chat.turnChangesConfirmBody': 'This will rewind the latest assistant response and restore tracked files for this turn.',
'chat.turnChangesConfirmUndo': 'Undo current turn',
'chat.turnChangesShowDiffAria': 'Show diff for {path}',
'chat.turnChangesHideDiffAria': 'Hide diff for {path}',
'chat.turnChangesDiffLoading': 'Loading diff...',
'chat.turnChangesDiffUnavailable': 'Diff unavailable.',
// ─── Streaming Indicator ──────────────────────────────────────
'streaming.thinking': 'Thinking',
@ -983,6 +1017,9 @@ export const en = {
'tabs.closeConfirmMessage': 'This session is still running. What would you like to do?',
'tabs.closeConfirmKeep': 'Keep Running',
'tabs.closeConfirmStop': 'Stop & Close',
'tabs.openTerminal': 'Open Terminal',
'tabs.showWorkspace': 'Show Workspace',
'tabs.hideWorkspace': 'Hide Workspace',
} as const
export type TranslationKey = keyof typeof en

View File

@ -43,6 +43,41 @@ export const zh: Record<TranslationKey, string> = {
'titlebar.terminal': '终端',
'titlebar.history': '历史',
// ─── Workspace Panel ───────────────────────────────
'workspace.changedFiles': '已更改文件',
'workspace.allFiles': '所有文件',
'workspace.viewTabs': '工作区视图',
'workspace.previewTabs': '预览标签',
'workspace.filterPlaceholder': '筛选文件...',
'workspace.clearFilter': '清除文件筛选',
'workspace.refresh': '刷新工作区',
'workspace.closePanel': '关闭工作区面板',
'workspace.resizePanel': '调整工作区宽度',
'workspace.closeTab': '关闭标签',
'workspace.preview': '预览',
'workspace.previewEmpty': '选择一个文件进行预览。',
'workspace.notGitRepo': '当前目录不是 Git 仓库。',
'workspace.missingWorkdir': '工作目录不存在。',
'workspace.loadError': '工作区数据加载失败。',
'workspace.noChanges': '没有变更',
'workspace.noFiles': '没有文件',
'workspace.noMatchingFiles': '没有匹配的文件',
'workspace.previewKind.diff': 'Diff',
'workspace.previewKind.file': '文件',
'workspace.previewState.loading': '正在加载预览...',
'workspace.previewState.binary': '二进制文件暂不支持预览。',
'workspace.previewState.tooLarge': '文件过大,无法预览。',
'workspace.previewState.missing': '文件不存在。',
'workspace.imagePreviewUnavailable': '图片无法预览。',
'workspace.previewLineLimit': '仅显示前 {count} 行。完整内容请在编辑器中打开。',
'workspace.addToChat': '添加到聊天',
'workspace.copyPath': '复制路径',
'workspace.localComment': '本地评论',
'workspace.commentLine': '评论第 {line} 行',
'workspace.commentLineTarget': '第 {line} 行',
'workspace.commentPlaceholder': '描述你希望这里怎么改...',
'workspace.addCommentToChat': '添加评论',
// ─── Status Bar ──────────────────────────────────────
'status.connected': '已连接',
'status.connecting': '连接中...',
@ -522,6 +557,7 @@ export const zh: Record<TranslationKey, string> = {
'chat.placeholder': '让 Claude 编辑、调试或解释代码...',
'chat.placeholderMissing': '此会话指向的工作目录缺失。请新建会话或选择其他项目。',
'chat.addFiles': '添加文件或图片',
'chat.workspaceReferencesOnly': '已添加 {count} 个工作区引用',
'chat.slashCommands': '斜杠命令',
'slash.mcp.title': '可用 MCP 工具',
'slash.mcp.subtitle': '展示当前聊天上下文里的全局 MCP 和当前项目 MCP。',
@ -612,23 +648,21 @@ export const zh: Record<TranslationKey, string> = {
'chat.select': '选择',
'chat.dismiss': '关闭',
'chat.stopTitle': '停止生成 (Cmd+.)',
'chat.rewindAction': '回滚到这里',
'chat.rewindModalTitle': '回滚对话',
'chat.rewindConfirm': '执行回滚',
'chat.rewindPromptLabel': '目标提示词',
'chat.rewindAttachmentOnly': '这条消息只包含附件。',
'chat.rewindLoading': '正在检查这一轮的文件检查点...',
'chat.rewindConversationCardTitle': '对话回滚',
'chat.rewindConversationCardBody': '移除这条提示词以及其后的 {count} 条活跃消息,并把输入框恢复到这个位置。',
'chat.rewindCodeCardTitle': '代码回滚',
'chat.rewindCodeFiles': '将恢复 {count} 个文件',
'chat.rewindCodeInsertions': '将移除 {count} 行新增',
'chat.rewindCodeDeletions': '将恢复 {count} 行删除',
'chat.rewindCodeUnavailable': '这条提示词没有可用的文件检查点,仍然可以只回滚对话。',
'chat.rewindFilesLabel': '受影响文件',
'chat.rewindFilesMore': '另有 {count} 个',
'chat.rewindSuccessWithCode': '已回滚 {count} 条消息,并恢复相关文件。',
'chat.rewindSuccessConversationOnly': '已回滚 {count} 条消息。这一轮没有可用的文件检查点。',
'chat.turnChangesCardLabel': '当前轮次已更改文件',
'chat.turnChangesTitle': '{count} 个文件已更改',
'chat.turnChangesSubtitle': '当前轮次检查点',
'chat.turnChangesUndo': '撤销',
'chat.turnChangesUndoing': '正在撤销...',
'chat.turnChangesUndoAria': '撤销当前轮次变更',
'chat.turnChangesConfirmTitle': '撤销当前轮次?',
'chat.turnChangesConfirmBody': '这会回滚最近一次助手回复,并恢复这一轮中被跟踪的文件变更。',
'chat.turnChangesConfirmUndo': '撤销当前轮次',
'chat.turnChangesShowDiffAria': '查看 {path} 的 diff',
'chat.turnChangesHideDiffAria': '收起 {path} 的 diff',
'chat.turnChangesDiffLoading': '正在加载 diff...',
'chat.turnChangesDiffUnavailable': 'Diff 不可用。',
// ─── Streaming Indicator ──────────────────────────────────────
'streaming.thinking': '思考中',
@ -985,4 +1019,7 @@ export const zh: Record<TranslationKey, string> = {
'tabs.closeConfirmMessage': '此会话仍在运行,你想要怎么做?',
'tabs.closeConfirmKeep': '保持运行',
'tabs.closeConfirmStop': '停止并关闭',
'tabs.openTerminal': '打开终端',
'tabs.showWorkspace': '显示工作区',
'tabs.hideWorkspace': '隐藏工作区',
}

View File

@ -1,14 +1,18 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { render } from '@testing-library/react'
import { fireEvent, render, screen, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { act } from 'react'
vi.mock('../components/chat/MessageList', () => ({
MessageList: () => <div data-testid="message-list" />,
MessageList: ({ compact }: { compact?: boolean }) => (
<div data-testid="message-list" data-compact={compact ? 'true' : 'false'} />
),
}))
vi.mock('../components/chat/ChatInput', () => ({
ChatInput: () => <div data-testid="chat-input" />,
ChatInput: ({ compact, variant }: { compact?: boolean; variant?: string }) => (
<div data-testid="chat-input" data-compact={compact ? 'true' : 'false'} data-variant={variant} />
),
}))
vi.mock('../components/teams/TeamStatusBar', () => ({
@ -19,12 +23,20 @@ vi.mock('../components/chat/SessionTaskBar', () => ({
SessionTaskBar: () => <div data-testid="session-task-bar" />,
}))
vi.mock('../components/workspace/WorkspacePanel', () => ({
WorkspacePanel: ({ sessionId }: { sessionId: string }) => (
<div data-testid="workspace-panel">workspace:{sessionId}</div>
),
}))
import { ActiveSession } from './ActiveSession'
import { useChatStore } from '../stores/chatStore'
import { useCLITaskStore } from '../stores/cliTaskStore'
import { useSessionStore } from '../stores/sessionStore'
import { useTabStore } from '../stores/tabStore'
import { useTeamStore } from '../stores/teamStore'
import { useWorkspacePanelStore } from '../stores/workspacePanelStore'
import { WORKSPACE_PANEL_DEFAULT_WIDTH } from '../stores/workspacePanelStore'
afterEach(() => {
vi.useRealTimers()
@ -32,6 +44,7 @@ afterEach(() => {
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
useChatStore.setState({ sessions: {} })
useTeamStore.setState({ teams: [], activeTeam: null, memberColors: new Map(), error: null })
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
})
describe('ActiveSession task polling', () => {
@ -178,4 +191,180 @@ describe('ActiveSession task polling', () => {
unmount()
useCLITaskStore.setState(originalCliTaskState)
})
it('renders the workspace panel to the right of chat and supports resizing', () => {
const sessionId = 'workspace-session'
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'Workspace Session',
createdAt: '2026-04-10T00:00:00.000Z',
modifiedAt: '2026-04-10T00:00:00.000Z',
messageCount: 1,
projectPath: '',
workDir: '/tmp/project',
workDirExists: true,
}],
activeSessionId: sessionId,
isLoading: false,
error: null,
})
useTabStore.setState({
tabs: [{ sessionId, title: 'Workspace Session', type: 'session', status: 'idle' }],
activeTabId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [{ id: 'msg-1', type: 'assistant_text', content: 'hello', timestamp: 1 }],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
useWorkspacePanelStore.getState().openPanel(sessionId)
render(<ActiveSession />)
const contentRow = screen.getByTestId('active-session-content-row')
const chatColumn = screen.getByTestId('active-session-chat-column')
const resizeHandle = screen.getByTestId('workspace-resize-handle')
expect(within(contentRow).getByTestId('message-list')).toBeInTheDocument()
expect(within(contentRow).getByTestId('message-list')).toHaveAttribute('data-compact', 'true')
expect(within(contentRow).getByTestId('workspace-panel')).toHaveTextContent(`workspace:${sessionId}`)
expect(within(chatColumn).getByTestId('chat-input')).toBeInTheDocument()
expect(within(chatColumn).getByTestId('chat-input')).toHaveAttribute('data-compact', 'true')
expect(chatColumn).toHaveClass('flex-1')
expect(chatColumn).not.toHaveClass('shrink-0')
expect(contentRow.children[0]).toBe(chatColumn)
expect(contentRow.children[1]).toBe(resizeHandle)
expect(contentRow.children[2]).toBe(screen.getByTestId('workspace-panel'))
act(() => {
fireEvent.keyDown(resizeHandle, { key: 'ArrowLeft' })
})
expect(useWorkspacePanelStore.getState().width).toBe(WORKSPACE_PANEL_DEFAULT_WIDTH + 32)
})
it('does not render the workspace panel when closed or for member sessions', () => {
const regularSessionId = 'regular-session'
useSessionStore.setState({
sessions: [{
id: regularSessionId,
title: 'Regular Session',
createdAt: '2026-04-10T00:00:00.000Z',
modifiedAt: '2026-04-10T00:00:00.000Z',
messageCount: 0,
projectPath: '',
workDir: '/tmp/project',
workDirExists: true,
}],
activeSessionId: regularSessionId,
isLoading: false,
error: null,
})
useTabStore.setState({
tabs: [{ sessionId: regularSessionId, title: 'Regular Session', type: 'session', status: 'idle' }],
activeTabId: regularSessionId,
})
useChatStore.setState({
sessions: {
[regularSessionId]: {
messages: [],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
const { rerender } = render(<ActiveSession />)
expect(screen.queryByTestId('workspace-panel')).not.toBeInTheDocument()
const memberSessionId = 'team-member:security-reviewer@test-team'
useTeamStore.setState({
teams: [],
activeTeam: {
name: 'test-team',
leadAgentId: 'team-lead@test-team',
leadSessionId: 'leader-session',
members: [
{
agentId: 'team-lead@test-team',
role: 'team-lead',
status: 'running',
sessionId: 'leader-session',
},
{
agentId: 'security-reviewer@test-team',
role: 'security-reviewer',
status: 'running',
},
],
},
memberColors: new Map(),
error: null,
})
useTabStore.setState({
tabs: [{ sessionId: memberSessionId, title: 'security-reviewer', type: 'session', status: 'idle' }],
activeTabId: memberSessionId,
})
useChatStore.setState({
sessions: {
[memberSessionId]: {
messages: [{ id: 'msg-2', type: 'assistant_text', content: 'hello', timestamp: 1 }],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
useWorkspacePanelStore.getState().openPanel(memberSessionId)
rerender(<ActiveSession />)
expect(screen.queryByTestId('workspace-panel')).not.toBeInTheDocument()
expect(screen.getByTestId('message-list')).toBeInTheDocument()
})
})

View File

@ -1,20 +1,95 @@
import { useEffect, useMemo } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useSessionStore } from '../stores/sessionStore'
import { useChatStore } from '../stores/chatStore'
import { useCLITaskStore } from '../stores/cliTaskStore'
import { useTeamStore } from '../stores/teamStore'
import { useWorkspacePanelStore } from '../stores/workspacePanelStore'
import { useTranslation } from '../i18n'
import { MessageList } from '../components/chat/MessageList'
import { ChatInput } from '../components/chat/ChatInput'
import { ComputerUsePermissionModal } from '../components/chat/ComputerUsePermissionModal'
import { TeamStatusBar } from '../components/teams/TeamStatusBar'
import { SessionTaskBar } from '../components/chat/SessionTaskBar'
import { WorkspacePanel } from '../components/workspace/WorkspacePanel'
import { TeamStatusBar } from '../components/teams/TeamStatusBar'
const TASK_POLL_INTERVAL_MS = 1000
const WORKSPACE_RESIZE_STEP = 32
const CHAT_COLUMN_WITH_WORKSPACE_CLASS =
'min-w-[320px] flex-1 border-r border-[var(--color-border)] bg-[var(--color-surface)]'
function WorkspaceResizeHandle() {
const t = useTranslation()
const width = useWorkspacePanelStore((state) => state.width)
const setWidth = useWorkspacePanelStore((state) => state.setWidth)
const [dragState, setDragState] = useState<{ startX: number; startWidth: number } | null>(null)
const dragStateRef = useRef(dragState)
useEffect(() => {
dragStateRef.current = dragState
}, [dragState])
useEffect(() => {
if (!dragState) return
const handlePointerMove = (event: PointerEvent) => {
const current = dragStateRef.current
if (!current) return
setWidth(current.startWidth + current.startX - event.clientX)
}
const handlePointerUp = () => {
setDragState(null)
}
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointerup', handlePointerUp)
window.addEventListener('pointercancel', handlePointerUp)
return () => {
document.body.style.cursor = ''
document.body.style.userSelect = ''
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', handlePointerUp)
window.removeEventListener('pointercancel', handlePointerUp)
}
}, [dragState, setWidth])
return (
<div
role="separator"
aria-label={t('workspace.resizePanel')}
aria-orientation="vertical"
aria-valuenow={width}
tabIndex={0}
data-testid="workspace-resize-handle"
onPointerDown={(event) => {
if (event.button !== 0) return
event.preventDefault()
setDragState({ startX: event.clientX, startWidth: width })
}}
onKeyDown={(event) => {
if (event.key === 'ArrowLeft') {
event.preventDefault()
setWidth(width + WORKSPACE_RESIZE_STEP)
}
if (event.key === 'ArrowRight') {
event.preventDefault()
setWidth(width - WORKSPACE_RESIZE_STEP)
}
}}
className="group relative z-10 flex w-2 shrink-0 cursor-col-resize items-stretch justify-center bg-[var(--color-surface)] outline-none focus-visible:bg-[var(--color-surface-container)]"
>
<div className="my-3 w-px rounded-full bg-[var(--color-border)] transition-colors group-hover:bg-[var(--color-border-focus)] group-focus-visible:bg-[var(--color-border-focus)]" />
</div>
)
}
export function ActiveSession() {
const activeTabId = useTabStore((s) => s.activeTabId)
const activeTabType = useTabStore((s) => s.tabs.find((tab) => tab.sessionId === s.activeTabId)?.type ?? null)
const sessions = useSessionStore((s) => s.sessions)
const connectToSession = useChatStore((s) => s.connectToSession)
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
@ -29,6 +104,11 @@ export function ActiveSession() {
const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null)
const activeTeam = useTeamStore((s) => s.activeTeam)
const isMemberSession = !!memberInfo
const showWorkspacePanel = useWorkspacePanelStore((state) =>
activeTabId && activeTabType === 'session' && !isMemberSession
? state.isPanelOpen(activeTabId)
: false,
)
useEffect(() => {
if (activeTabId && !isMemberSession) {
@ -81,132 +161,167 @@ export function ActiveSession() {
if (!activeTabId) return null
return (
<div className="flex-1 flex flex-col relative overflow-hidden bg-background text-on-surface">
{isMemberSession && (
<div className="shrink-0 border-b border-[var(--color-border)] bg-[var(--color-surface-container)]">
<div className="mx-auto max-w-[860px] flex items-center justify-between gap-4 px-8 py-2">
<div className="min-w-0">
<div className="flex items-center gap-3">
{memberInfo?.status === 'running' && (
<span className="flex h-2 w-2 rounded-full bg-[var(--color-warning)] animate-pulse-dot" />
)}
{memberInfo?.status === 'completed' && (
<span className="material-symbols-outlined text-[14px] text-[var(--color-success)]" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
)}
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">smart_toy</span>
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
{memberInfo?.role}
</span>
{activeTeam && (
<span className="text-[10px] text-[var(--color-text-tertiary)]">
@ {activeTeam.name}
</span>
)}
</div>
<p className="mt-1 text-[11px] text-[var(--color-text-tertiary)]">
{t('teams.memberSessionHint')}
</p>
</div>
<button
onClick={() => {
if (activeTeam?.leadSessionId) {
useTabStore.getState().openTab(
activeTeam.leadSessionId,
t('teams.leader'),
'session',
)
}
}}
disabled={!activeTeam?.leadSessionId}
className="flex shrink-0 items-center gap-1 text-xs font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors disabled:opacity-50 disabled:hover:text-[var(--color-text-secondary)]"
>
<span className="material-symbols-outlined text-[14px]">arrow_back</span>
{t('teams.backToLeader')}
</button>
</div>
</div>
)}
{isEmpty ? (
<div className="flex flex-1 flex-col items-center justify-center p-8 pb-32">
<div className="flex max-w-md flex-col items-center text-center">
{isMemberSession ? (
<>
<span className="material-symbols-outlined text-[48px] mb-4 text-[var(--color-text-tertiary)]">smart_toy</span>
<p className="text-[var(--color-text-secondary)]">
{memberInfo?.status === 'running'
? `${memberInfo.role} ${t('teams.working')}`
: t('teams.noMessages')}
</p>
</>
) : (
<>
<img src="/app-icon.png" alt="Claude Code Haha" className="mb-6 h-24 w-24" />
<h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-headline)' }}>
{t('empty.title')}
</h1>
<p className="mx-auto max-w-xs text-[var(--color-text-secondary)]" style={{ fontFamily: 'var(--font-body)' }}>
{t('empty.subtitle')}
</p>
</>
)}
</div>
</div>
) : (
<>
{!isMemberSession && (
<div className="mx-auto flex w-full max-w-[860px] items-center border-b border-outline-variant/10 px-8 py-3">
<div className="flex-1">
<h1 className="text-lg font-bold font-headline text-on-surface leading-tight">
{session?.title || t('session.untitled')}
</h1>
<div className="flex items-center gap-2 text-[10px] text-outline font-medium mt-1">
{isActive && (
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-success)] animate-pulse-dot" />
{t('session.active')}
</span>
)}
{totalTokens > 0 && (
<>
<span className="text-[var(--color-outline)]">·</span>
<span>{totalTokens.toLocaleString()} t</span>
</>
)}
{lastUpdated && (
<>
<span className="text-[var(--color-outline)]">·</span>
<span>{t('session.lastUpdated', { time: lastUpdated })}</span>
</>
)}
{session?.messageCount !== undefined && session.messageCount > 0 && (
<>
<span className="text-[var(--color-outline)]">·</span>
<span>{t('session.messages', { count: session.messageCount })}</span>
</>
)}
</div>
{session?.workDirExists === false && (
<div className="mt-2 inline-flex max-w-full items-center gap-2 rounded-lg border border-[var(--color-error)]/20 bg-[var(--color-error)]/8 px-3 py-1.5 text-[11px] text-[var(--color-error)]">
<span className="material-symbols-outlined text-[14px]">warning</span>
<span className="truncate">
{t('session.workspaceUnavailable', { dir: session.workDir || 'directory no longer exists' })}
<div className="flex-1 flex relative overflow-hidden bg-background text-on-surface">
<div data-testid="active-session-content-row" className="flex min-h-0 min-w-0 flex-1">
<div
data-testid="active-session-chat-column"
className={`flex flex-col ${showWorkspacePanel ? CHAT_COLUMN_WITH_WORKSPACE_CLASS : 'min-w-[360px] flex-1'}`}
>
{isMemberSession && (
<div className="shrink-0 border-b border-[var(--color-border)] bg-[var(--color-surface-container)]">
<div className="mx-auto max-w-[860px] flex items-center justify-between gap-4 px-8 py-2">
<div className="min-w-0">
<div className="flex items-center gap-3">
{memberInfo?.status === 'running' && (
<span className="flex h-2 w-2 rounded-full bg-[var(--color-warning)] animate-pulse-dot" />
)}
{memberInfo?.status === 'completed' && (
<span className="material-symbols-outlined text-[14px] text-[var(--color-success)]" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
)}
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">smart_toy</span>
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
{memberInfo?.role}
</span>
{activeTeam && (
<span className="text-[10px] text-[var(--color-text-tertiary)]">
@ {activeTeam.name}
</span>
)}
</div>
)}
<p className="mt-1 text-[11px] text-[var(--color-text-tertiary)]">
{t('teams.memberSessionHint')}
</p>
</div>
<button
onClick={() => {
if (activeTeam?.leadSessionId) {
useTabStore.getState().openTab(
activeTeam.leadSessionId,
t('teams.leader'),
'session',
)
}
}}
disabled={!activeTeam?.leadSessionId}
className="flex shrink-0 items-center gap-1 text-xs font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors disabled:opacity-50 disabled:hover:text-[var(--color-text-secondary)]"
>
<span className="material-symbols-outlined text-[14px]">arrow_back</span>
{t('teams.backToLeader')}
</button>
</div>
</div>
)}
<MessageList />
</>
)}
{isEmpty ? (
<div className="flex flex-1 flex-col items-center justify-center p-8 pb-32">
<div className="flex max-w-md flex-col items-center text-center">
{isMemberSession ? (
<>
<span className="material-symbols-outlined text-[48px] mb-4 text-[var(--color-text-tertiary)]">smart_toy</span>
<p className="text-[var(--color-text-secondary)]">
{memberInfo?.status === 'running'
? `${memberInfo.role} ${t('teams.working')}`
: t('teams.noMessages')}
</p>
</>
) : (
<>
<img src="/app-icon.png" alt="Claude Code Haha" className="mb-6 h-24 w-24" />
<h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-headline)' }}>
{t('empty.title')}
</h1>
<p className="mx-auto max-w-xs text-[var(--color-text-secondary)]" style={{ fontFamily: 'var(--font-body)' }}>
{t('empty.subtitle')}
</p>
</>
)}
</div>
</div>
) : (
<>
{!isMemberSession && (
<div
className={
showWorkspacePanel
? 'flex w-full items-center border-b border-[var(--color-border)]/70 px-4 py-3'
: 'mx-auto flex w-full max-w-[860px] items-center border-b border-outline-variant/10 px-8 py-3'
}
>
<div className="min-w-0 flex-1">
<h1
className={
showWorkspacePanel
? 'truncate text-[15px] font-bold font-headline leading-tight text-on-surface'
: 'text-lg font-bold font-headline text-on-surface leading-tight'
}
>
{session?.title || t('session.untitled')}
</h1>
<div
className={
showWorkspacePanel
? 'mt-1 flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap text-[10px] font-medium text-outline'
: 'flex items-center gap-2 text-[10px] text-outline font-medium mt-1'
}
>
{isActive && (
<span className="flex shrink-0 items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-success)] animate-pulse-dot" />
{t('session.active')}
</span>
)}
{totalTokens > 0 && (
<>
<span className="text-[var(--color-outline)]">·</span>
<span>{totalTokens.toLocaleString()} t</span>
</>
)}
{lastUpdated && (
<>
<span className="shrink-0 text-[var(--color-outline)]">·</span>
<span className="truncate">{t('session.lastUpdated', { time: lastUpdated })}</span>
</>
)}
{!showWorkspacePanel && session?.messageCount !== undefined && session.messageCount > 0 && (
<>
<span className="text-[var(--color-outline)]">·</span>
<span>{t('session.messages', { count: session.messageCount })}</span>
</>
)}
</div>
{session?.workDirExists === false && (
<div className="mt-2 inline-flex max-w-full items-center gap-2 rounded-lg border border-[var(--color-error)]/20 bg-[var(--color-error)]/8 px-3 py-1.5 text-[11px] text-[var(--color-error)]">
<span className="material-symbols-outlined text-[14px]">warning</span>
<span className="truncate">
{t('session.workspaceUnavailable', { dir: session.workDir || 'directory no longer exists' })}
</span>
</div>
)}
</div>
</div>
)}
{!isMemberSession && <SessionTaskBar />}
<MessageList compact={showWorkspacePanel} />
</>
)}
<TeamStatusBar />
{!isMemberSession && <SessionTaskBar />}
<ChatInput variant={isEmpty && !isMemberSession ? 'hero' : 'default'} />
<TeamStatusBar />
<ChatInput
variant={isEmpty && !isMemberSession && !showWorkspacePanel ? 'hero' : 'default'}
compact={showWorkspacePanel}
/>
</div>
{showWorkspacePanel ? (
<>
<WorkspaceResizeHandle />
<WorkspacePanel sessionId={activeTabId} />
</>
) : null}
</div>
{!isMemberSession && activeTabId ? (
<ComputerUsePermissionModal

View File

@ -231,6 +231,117 @@ describe('chatStore history mapping', () => {
])
})
it('restores CLI file mentions as visible attachment chips from transcript history', () => {
const messages: MessageEntry[] = [
{
id: 'user-with-file-mention',
type: 'user',
timestamp: '2026-04-06T00:00:00.000Z',
content: '@"/private/tmp/example/src/sentinel.ts" 这个常量是什么?',
},
]
const mapped = mapHistoryMessagesToUiMessages(messages)
expect(mapped).toMatchObject([
{
id: 'user-with-file-mention',
type: 'user_text',
content: '这个常量是什么?',
modelContent: '@"/private/tmp/example/src/sentinel.ts" 这个常量是什么?',
attachments: [{
type: 'file',
name: 'sentinel.ts',
path: '/private/tmp/example/src/sentinel.ts',
}],
},
])
})
it('keeps workspace reference chips visible while sending CLI attachment paths', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
useChatStore.getState().sendMessage(
TEST_SESSION_ID,
'Notes for attached workspace files:\n- src/App.tsx:L4\n Comment: tighten this',
[{
type: 'file',
name: 'App.tsx',
path: '/repo/src/App.tsx',
lineStart: 4,
lineEnd: 4,
note: 'tighten this',
quote: 'const value = 1',
}],
{
displayContent: '改这里',
displayAttachments: [{
type: 'file',
name: 'App.tsx',
path: 'src/App.tsx',
lineStart: 4,
lineEnd: 4,
note: 'tighten this',
quote: 'const value = 1',
}],
},
)
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{
type: 'user_text',
content: '改这里',
modelContent: 'Notes for attached workspace files:\n- src/App.tsx:L4\n Comment: tighten this',
attachments: [{
type: 'file',
name: 'App.tsx',
path: 'src/App.tsx',
lineStart: 4,
lineEnd: 4,
note: 'tighten this',
quote: 'const value = 1',
}],
},
])
expect(sendMock).toHaveBeenCalledWith(
TEST_SESSION_ID,
{
type: 'user_message',
content: 'Notes for attached workspace files:\n- src/App.tsx:L4\n Comment: tighten this',
attachments: [{
type: 'file',
name: 'App.tsx',
path: '/repo/src/App.tsx',
lineStart: 4,
lineEnd: 4,
note: 'tighten this',
quote: 'const value = 1',
}],
},
)
})
it('keeps parent tool linkage for live tool events', () => {
// Initialize the session first
useChatStore.setState({

View File

@ -92,7 +92,7 @@ type ChatStore = {
sessionId: string,
content: string,
attachments?: AttachmentRef[],
options?: { displayContent?: string },
options?: { displayContent?: string; displayAttachments?: AttachmentRef[] },
) => void
respondToPermission: (
sessionId: string,
@ -265,13 +265,19 @@ export const useChatStore = create<ChatStore>((set, get) => ({
const userFacingContent =
options?.displayContent?.trim() || content.trim()
const isMemberSession = !!useTeamStore.getState().getMemberBySessionId(sessionId)
const visibleAttachments = options?.displayAttachments ?? attachments
const uiAttachments: UIAttachment[] | undefined =
attachments && attachments.length > 0
? attachments.map((a) => ({
visibleAttachments && visibleAttachments.length > 0
? visibleAttachments.map((a) => ({
type: a.type,
name: a.name || a.path || a.mimeType || a.type,
path: a.path,
data: a.data,
mimeType: a.mimeType,
lineStart: a.lineStart,
lineEnd: a.lineEnd,
note: a.note,
quote: a.quote,
}))
: undefined
@ -309,6 +315,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
id: nextId(),
type: 'user_text',
content: userFacingContent,
...(userFacingContent !== content ? { modelContent: content } : {}),
attachments: isMemberSession ? undefined : uiAttachments,
timestamp: Date.now(),
...(isMemberSession ? { pending: true } : {}),
@ -918,6 +925,43 @@ type HistoryMappingOptions = {
includeTeammateMessages?: boolean
}
function getReferenceName(referencePath: string): string {
const normalized = referencePath.replace(/\\/g, '/').replace(/\/+$/, '')
const name = normalized.split('/').filter(Boolean).pop()
return name || referencePath
}
function extractLeadingFileReferences(text: string): {
content: string
attachments?: UIAttachment[]
modelContent?: string
} {
const attachments: UIAttachment[] = []
let remaining = text
while (true) {
const match = remaining.match(/^@"([^"]+)"\s*/)
if (!match?.[1]) break
attachments.push({
type: 'file',
name: getReferenceName(match[1]),
path: match[1],
})
remaining = remaining.slice(match[0].length)
}
if (attachments.length === 0) {
return { content: text }
}
return {
content: remaining.trimStart(),
attachments,
modelContent: text,
}
}
/**
* Reconstruct agentTaskNotifications from history.
*
@ -1013,7 +1057,15 @@ export function mapHistoryMessagesToUiMessages(
})
continue
}
uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: msg.content, timestamp })
const parsed = extractLeadingFileReferences(msg.content)
uiMessages.push({
id: msg.id || nextId(),
type: 'user_text',
content: parsed.content,
...(parsed.modelContent ? { modelContent: parsed.modelContent } : {}),
...(parsed.attachments ? { attachments: parsed.attachments } : {}),
timestamp,
})
continue
}
if (msg.type === 'assistant' && typeof msg.content === 'string') {
@ -1043,7 +1095,16 @@ export function mapHistoryMessagesToUiMessages(
else if (block.type === 'tool_result') uiMessages.push({ id: nextId(), type: 'tool_result', toolUseId: block.tool_use_id ?? '', content: block.content, isError: !!block.is_error, timestamp, parentToolUseId: msg.parentToolUseId })
}
if (textParts.length > 0 || attachments.length > 0) {
uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: textParts.join('\n'), attachments: attachments.length > 0 ? attachments : undefined, timestamp })
const parsed = extractLeadingFileReferences(textParts.join('\n'))
const allAttachments = [...(parsed.attachments ?? []), ...attachments]
uiMessages.push({
id: msg.id || nextId(),
type: 'user_text',
content: parsed.content,
...(parsed.modelContent ? { modelContent: parsed.modelContent } : {}),
attachments: allAttachments.length > 0 ? allAttachments : undefined,
timestamp,
})
}
}
}

View File

@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
formatWorkspaceReferencePrompt,
useWorkspaceChatContextStore,
} from './workspaceChatContextStore'
const initialState = useWorkspaceChatContextStore.getInitialState()
describe('workspaceChatContextStore', () => {
beforeEach(() => {
useWorkspaceChatContextStore.setState(initialState, true)
})
it('deduplicates file references per session', () => {
const store = useWorkspaceChatContextStore.getState()
store.addReference('session-1', {
kind: 'file',
path: 'src/App.tsx',
absolutePath: '/repo/src/App.tsx',
name: 'App.tsx',
})
store.addReference('session-1', {
kind: 'file',
path: 'src/App.tsx',
absolutePath: '/repo/src/App.tsx',
name: 'App.tsx',
})
expect(useWorkspaceChatContextStore.getState().referencesBySession['session-1']).toHaveLength(1)
})
it('formats line comments into the request prompt', () => {
const prompt = formatWorkspaceReferencePrompt([
{
id: 'ref-1',
kind: 'code-comment',
path: 'src/App.tsx',
absolutePath: '/repo/src/App.tsx',
name: 'App.tsx',
lineStart: 12,
lineEnd: 12,
note: 'Use a clearer name',
quote: 'const value = 1',
},
])
expect(prompt).toContain('Notes for attached workspace files:')
expect(prompt).toContain('- src/App.tsx:L12')
expect(prompt).toContain('Comment: Use a clearer name')
expect(prompt).toContain('Selected code: const value = 1')
expect(prompt).not.toContain('Use the Read tool')
expect(prompt).not.toContain('Path: /repo/src/App.tsx')
})
it('does not add prompt text for plain file attachments', () => {
const prompt = formatWorkspaceReferencePrompt([
{
id: 'ref-1',
kind: 'file',
path: 'src/App.tsx',
absolutePath: '/repo/src/App.tsx',
name: 'App.tsx',
},
])
expect(prompt).toBe('')
})
})

View File

@ -0,0 +1,118 @@
import { create } from 'zustand'
export type WorkspaceChatReferenceKind = 'file' | 'code-comment'
export type WorkspaceChatReference = {
id: string
kind: WorkspaceChatReferenceKind
path: string
absolutePath?: string
name: string
lineStart?: number
lineEnd?: number
note?: string
quote?: string
}
type WorkspaceChatContextStore = {
referencesBySession: Record<string, WorkspaceChatReference[] | undefined>
addReference: (
sessionId: string,
reference: Omit<WorkspaceChatReference, 'id'> & { id?: string },
) => void
removeReference: (sessionId: string, referenceId: string) => void
clearReferences: (sessionId: string) => void
clearSession: (sessionId: string) => void
}
function makeReferenceId(reference: Omit<WorkspaceChatReference, 'id'>) {
const linePart = reference.lineStart
? `${reference.lineStart}-${reference.lineEnd ?? reference.lineStart}`
: 'file'
const notePart = reference.note ? reference.note.slice(0, 48) : ''
return `${reference.kind}:${reference.path}:${linePart}:${notePart}`
}
function getReferenceDedupKey(reference: WorkspaceChatReference) {
if (reference.kind === 'file') return `${reference.kind}:${reference.path}`
return `${reference.kind}:${reference.path}:${reference.lineStart ?? ''}:${reference.lineEnd ?? ''}:${reference.note ?? ''}`
}
export function formatWorkspaceReferenceLocation(reference: WorkspaceChatReference) {
if (!reference.lineStart) return reference.path
const lineEnd = reference.lineEnd && reference.lineEnd !== reference.lineStart
? `-L${reference.lineEnd}`
: ''
return `${reference.path}:L${reference.lineStart}${lineEnd}`
}
export function formatWorkspaceReferencePrompt(references: WorkspaceChatReference[]) {
const referencesWithContext = references.filter((reference) =>
reference.kind === 'code-comment' ||
!!reference.lineStart ||
!!reference.note?.trim() ||
!!reference.quote?.trim(),
)
if (referencesWithContext.length === 0) return ''
const lines = [
'Notes for attached workspace files:',
...referencesWithContext.map((reference) => {
const location = formatWorkspaceReferenceLocation(reference)
const parts = [`- ${location}`]
if (reference.note?.trim()) parts.push(`Comment: ${reference.note.trim()}`)
if (reference.quote?.trim()) parts.push(`Selected code: ${reference.quote.trim()}`)
return parts.join('\n ')
}),
]
return lines.join('\n')
}
export const useWorkspaceChatContextStore = create<WorkspaceChatContextStore>((set) => ({
referencesBySession: {},
addReference: (sessionId, input) =>
set((state) => {
const reference: WorkspaceChatReference = {
...input,
id: input.id ?? makeReferenceId(input),
}
const existing = state.referencesBySession[sessionId] ?? []
const nextKey = getReferenceDedupKey(reference)
const withoutDuplicate = existing.filter((item) => getReferenceDedupKey(item) !== nextKey)
return {
referencesBySession: {
...state.referencesBySession,
[sessionId]: [...withoutDuplicate, reference],
},
}
}),
removeReference: (sessionId, referenceId) =>
set((state) => {
const existing = state.referencesBySession[sessionId] ?? []
return {
referencesBySession: {
...state.referencesBySession,
[sessionId]: existing.filter((reference) => reference.id !== referenceId),
},
}
}),
clearReferences: (sessionId) =>
set((state) => ({
referencesBySession: {
...state.referencesBySession,
[sessionId]: [],
},
})),
clearSession: (sessionId) =>
set((state) => {
if (!(sessionId in state.referencesBySession)) return state
const { [sessionId]: _removed, ...rest } = state.referencesBySession
return { referencesBySession: rest }
}),
}))

View File

@ -0,0 +1,636 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
type HoistedVi = typeof vi & {
hoisted?: <T>(factory: () => T) => T
}
if (typeof (vi as HoistedVi).hoisted !== 'function') {
;(vi as HoistedVi).hoisted = <T>(factory: () => T) => factory()
}
const mocks = vi.hoisted(() => ({
getWorkspaceStatusMock: vi.fn(),
getWorkspaceTreeMock: vi.fn(),
getWorkspaceFileMock: vi.fn(),
getWorkspaceDiffMock: vi.fn(),
}))
vi.mock('../api/sessions', () => ({
sessionsApi: {
getWorkspaceStatus: mocks.getWorkspaceStatusMock,
getWorkspaceTree: mocks.getWorkspaceTreeMock,
getWorkspaceFile: mocks.getWorkspaceFileMock,
getWorkspaceDiff: mocks.getWorkspaceDiffMock,
},
}))
import { sessionsApi } from '../api/sessions'
import {
WORKSPACE_PANEL_DEFAULT_WIDTH,
WORKSPACE_PANEL_MAX_WIDTH,
WORKSPACE_PANEL_MIN_WIDTH,
getWorkspacePreviewTabId,
useWorkspacePanelStore,
} from './workspacePanelStore'
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
describe('workspacePanelStore', () => {
const initialState = useWorkspacePanelStore.getInitialState()
beforeEach(() => {
vi.clearAllMocks()
useWorkspacePanelStore.setState(initialState, true)
})
afterEach(() => {
useWorkspacePanelStore.setState(initialState, true)
vi.restoreAllMocks()
})
it('uses hoisted session api mocks', () => {
expect(sessionsApi.getWorkspaceStatus).toBe(mocks.getWorkspaceStatusMock)
expect(sessionsApi.getWorkspaceTree).toBe(mocks.getWorkspaceTreeMock)
expect(sessionsApi.getWorkspaceFile).toBe(mocks.getWorkspaceFileMock)
expect(sessionsApi.getWorkspaceDiff).toBe(mocks.getWorkspaceDiffMock)
})
it('keeps panel open state and active view isolated per session', () => {
const store = useWorkspacePanelStore.getState()
expect(store.isPanelOpen('session-a')).toBe(false)
expect(store.getActiveView('session-a')).toBe('changed')
expect(store.width).toBe(WORKSPACE_PANEL_DEFAULT_WIDTH)
store.openPanel('session-a')
store.setActiveView('session-a', 'all')
expect(useWorkspacePanelStore.getState().isPanelOpen('session-a')).toBe(true)
expect(useWorkspacePanelStore.getState().getActiveView('session-a')).toBe('all')
expect(useWorkspacePanelStore.getState().isPanelOpen('session-b')).toBe(false)
expect(useWorkspacePanelStore.getState().getActiveView('session-b')).toBe('changed')
store.togglePanel('session-b')
expect(useWorkspacePanelStore.getState().isPanelOpen('session-b')).toBe(true)
expect(useWorkspacePanelStore.getState().isPanelOpen('session-a')).toBe(true)
store.closePanel('session-a')
expect(useWorkspacePanelStore.getState().isPanelOpen('session-a')).toBe(false)
expect(useWorkspacePanelStore.getState().getActiveView('session-a')).toBe('all')
store.setWidth(120)
expect(useWorkspacePanelStore.getState().width).toBe(WORKSPACE_PANEL_MIN_WIDTH)
store.setWidth(1200)
expect(useWorkspacePanelStore.getState().width).toBe(WORKSPACE_PANEL_MAX_WIDTH)
})
it('loads workspace status successfully', async () => {
mocks.getWorkspaceStatusMock.mockResolvedValue({
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [
{
path: 'src/a.ts',
status: 'modified',
additions: 3,
deletions: 1,
},
],
})
await useWorkspacePanelStore.getState().loadStatus('session-1')
expect(mocks.getWorkspaceStatusMock).toHaveBeenCalledWith('session-1')
expect(useWorkspacePanelStore.getState().statusBySession['session-1']).toMatchObject({
branch: 'main',
changedFiles: [{ path: 'src/a.ts' }],
})
expect(useWorkspacePanelStore.getState().loading.statusBySession['session-1']).toBe(false)
expect(useWorkspacePanelStore.getState().errors.statusBySession['session-1']).toBeNull()
})
it('defaults an empty changed-files status to the all-files view', async () => {
mocks.getWorkspaceStatusMock.mockResolvedValue({
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
})
useWorkspacePanelStore.getState().openPanel('session-empty-changes')
expect(useWorkspacePanelStore.getState().getActiveView('session-empty-changes')).toBe('changed')
await useWorkspacePanelStore.getState().loadStatus('session-empty-changes')
expect(useWorkspacePanelStore.getState().statusBySession['session-empty-changes']?.changedFiles).toEqual([])
expect(useWorkspacePanelStore.getState().getActiveView('session-empty-changes')).toBe('all')
})
it('keeps the changed-files view when status contains changes', async () => {
mocks.getWorkspaceStatusMock.mockResolvedValue({
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [
{
path: 'src/app.ts',
status: 'modified',
additions: 2,
deletions: 1,
},
],
})
useWorkspacePanelStore.getState().openPanel('session-has-changes')
await useWorkspacePanelStore.getState().loadStatus('session-has-changes')
expect(useWorkspacePanelStore.getState().getActiveView('session-has-changes')).toBe('changed')
})
it('does not override an explicit changed-files selection when status is empty', async () => {
mocks.getWorkspaceStatusMock.mockResolvedValue({
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
})
useWorkspacePanelStore.getState().openPanel('session-explicit-changed')
useWorkspacePanelStore.getState().setActiveView('session-explicit-changed', 'changed')
await useWorkspacePanelStore.getState().loadStatus('session-explicit-changed')
expect(useWorkspacePanelStore.getState().getActiveView('session-explicit-changed')).toBe('changed')
})
it('captures workspace status request errors', async () => {
mocks.getWorkspaceStatusMock.mockRejectedValue(new Error('status failed'))
await useWorkspacePanelStore.getState().loadStatus('session-err')
expect(useWorkspacePanelStore.getState().statusBySession['session-err']).toBeUndefined()
expect(useWorkspacePanelStore.getState().loading.statusBySession['session-err']).toBe(false)
expect(useWorkspacePanelStore.getState().errors.statusBySession['session-err']).toBe('status failed')
})
it('lazily loads tree nodes when expanding and reuses cached results', async () => {
mocks.getWorkspaceTreeMock.mockResolvedValue({
state: 'ok',
path: '',
entries: [
{ name: 'src', path: 'src', isDirectory: true },
{ name: 'README.md', path: 'README.md', isDirectory: false },
],
})
await useWorkspacePanelStore.getState().toggleTreeNode('session-tree', '')
expect(mocks.getWorkspaceTreeMock).toHaveBeenCalledTimes(1)
expect(mocks.getWorkspaceTreeMock).toHaveBeenCalledWith('session-tree', '')
expect(useWorkspacePanelStore.getState().expandedPathsBySession['session-tree']).toEqual([''])
expect(useWorkspacePanelStore.getState().treeBySessionPath['session-tree']?.['']).toMatchObject({
entries: [{ path: 'src' }, { path: 'README.md' }],
})
await useWorkspacePanelStore.getState().toggleTreeNode('session-tree', '')
expect(useWorkspacePanelStore.getState().expandedPathsBySession['session-tree']).toEqual([])
await useWorkspacePanelStore.getState().toggleTreeNode('session-tree', '')
expect(mocks.getWorkspaceTreeMock).toHaveBeenCalledTimes(1)
expect(useWorkspacePanelStore.getState().expandedPathsBySession['session-tree']).toEqual([''])
})
it('ignores stale tree responses for the same session path', async () => {
const first = deferred<{ state: 'ok'; path: string; entries: Array<{ name: string; path: string; isDirectory: boolean }> }>()
const second = deferred<{ state: 'ok'; path: string; entries: Array<{ name: string; path: string; isDirectory: boolean }> }>()
mocks.getWorkspaceTreeMock
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
const firstLoad = useWorkspacePanelStore.getState().loadTree('session-tree-race', 'src')
const secondLoad = useWorkspacePanelStore.getState().loadTree('session-tree-race', 'src')
second.resolve({
state: 'ok',
path: 'src',
entries: [{ name: 'new.ts', path: 'src/new.ts', isDirectory: false }],
})
await secondLoad
first.resolve({
state: 'ok',
path: 'src',
entries: [{ name: 'old.ts', path: 'src/old.ts', isDirectory: false }],
})
await firstLoad
expect(useWorkspacePanelStore.getState().treeBySessionPath['session-tree-race']?.src?.entries).toEqual([
{ name: 'new.ts', path: 'src/new.ts', isDirectory: false },
])
})
it('opens preview tabs, supports multiple kinds, and reuses duplicates without persistence', async () => {
const storage = typeof globalThis.localStorage === 'undefined' ? null : globalThis.localStorage
const setItemSpy = storage ? vi.spyOn(storage, 'setItem') : null
mocks.getWorkspaceFileMock.mockResolvedValue({
state: 'ok',
path: 'src/a.ts',
content: 'export const a = 1',
language: 'typescript',
size: 18,
})
mocks.getWorkspaceDiffMock.mockResolvedValue({
state: 'ok',
path: 'src/a.ts',
diff: '@@ -1 +1 @@',
})
useWorkspacePanelStore.getState().openPanel('session-preview')
await useWorkspacePanelStore.getState().openPreview('session-preview', 'src/a.ts', 'file')
await useWorkspacePanelStore.getState().openPreview('session-preview', 'src/a.ts', 'diff')
await useWorkspacePanelStore.getState().openPreview('session-preview', 'src/a.ts', 'file')
expect(mocks.getWorkspaceFileMock).toHaveBeenCalledTimes(1)
expect(mocks.getWorkspaceDiffMock).toHaveBeenCalledTimes(1)
const tabs = useWorkspacePanelStore.getState().previewTabsBySession['session-preview']
expect(tabs).toBeDefined()
expect(tabs).toHaveLength(2)
expect(tabs![0]).toMatchObject({
id: 'file:src/a.ts',
kind: 'file',
path: 'src/a.ts',
content: 'export const a = 1',
language: 'typescript',
})
expect(tabs![1]).toMatchObject({
id: 'diff:src/a.ts',
kind: 'diff',
path: 'src/a.ts',
diff: '@@ -1 +1 @@',
})
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview']).toBe('file:src/a.ts')
if (setItemSpy) {
expect(setItemSpy).not.toHaveBeenCalled()
} else {
expect(storage).toBeNull()
}
})
it('closes exact tab id and preserves sibling preview for the same path', async () => {
mocks.getWorkspaceFileMock.mockResolvedValue({
state: 'ok',
path: 'src/a.ts',
content: 'export const a = 1',
language: 'typescript',
size: 18,
})
mocks.getWorkspaceDiffMock.mockResolvedValue({
state: 'ok',
path: 'src/a.ts',
diff: '@@ -1 +1 @@',
})
await useWorkspacePanelStore.getState().openPreview('session-close-path', 'src/a.ts', 'file')
await useWorkspacePanelStore.getState().openPreview('session-close-path', 'src/a.ts', 'diff')
useWorkspacePanelStore.getState().closePreview('session-close-path', 'diff:src/a.ts')
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-close-path']).toEqual([
expect.objectContaining({ id: 'file:src/a.ts' }),
])
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-close-path']).toBe('file:src/a.ts')
})
it('chooses the next sensible active preview when closing the current tab', async () => {
mocks.getWorkspaceFileMock
.mockResolvedValueOnce({
state: 'ok',
path: 'src/a.ts',
content: 'a',
language: 'typescript',
size: 1,
})
.mockResolvedValueOnce({
state: 'ok',
path: 'src/b.ts',
content: 'b',
language: 'typescript',
size: 1,
})
.mockResolvedValueOnce({
state: 'ok',
path: 'src/c.ts',
content: 'c',
language: 'typescript',
size: 1,
})
await useWorkspacePanelStore.getState().openPreview('session-close', 'src/a.ts', 'file')
await useWorkspacePanelStore.getState().openPreview('session-close', 'src/b.ts', 'file')
await useWorkspacePanelStore.getState().openPreview('session-close', 'src/c.ts', 'file')
useWorkspacePanelStore.getState().closePreview('session-close', 'file:src/b.ts')
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-close']).toMatchObject([
{ id: 'file:src/a.ts' },
{ id: 'file:src/c.ts' },
])
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-close']).toBe('file:src/c.ts')
useWorkspacePanelStore.getState().closePreview('session-close', 'file:src/c.ts')
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-close']).toBe('file:src/a.ts')
})
it('closes preview tabs by context-menu scope', () => {
useWorkspacePanelStore.setState((state) => ({
...state,
previewTabsBySession: {
...state.previewTabsBySession,
'session-preview-scope': [
{ id: 'file:a.ts', path: 'a.ts', kind: 'file', title: 'a.ts', state: 'ok', language: 'typescript', size: 1 },
{ id: 'file:b.ts', path: 'b.ts', kind: 'file', title: 'b.ts', state: 'ok', language: 'typescript', size: 1 },
{ id: 'file:c.ts', path: 'c.ts', kind: 'file', title: 'c.ts', state: 'ok', language: 'typescript', size: 1 },
{ id: 'file:d.ts', path: 'd.ts', kind: 'file', title: 'd.ts', state: 'ok', language: 'typescript', size: 1 },
],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
'session-preview-scope': 'file:d.ts',
},
loading: {
...state.loading,
previewByTabId: {
...state.loading.previewByTabId,
'session-preview-scope::file:c.ts': true,
'session-preview-scope::file:d.ts': true,
},
},
errors: {
...state.errors,
previewByTabId: {
...state.errors.previewByTabId,
'session-preview-scope::file:c.ts': 'loading',
'session-preview-scope::file:d.ts': 'loading',
},
},
}))
useWorkspacePanelStore.getState().closePreviewTabs('session-preview-scope', 'file:b.ts', 'right')
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-preview-scope']).toMatchObject([
{ id: 'file:a.ts' },
{ id: 'file:b.ts' },
])
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview-scope']).toBe('file:b.ts')
expect(useWorkspacePanelStore.getState().loading.previewByTabId['session-preview-scope::file:c.ts']).toBeUndefined()
expect(useWorkspacePanelStore.getState().errors.previewByTabId['session-preview-scope::file:d.ts']).toBeUndefined()
useWorkspacePanelStore.getState().closePreviewTabs('session-preview-scope', 'file:b.ts', 'others')
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-preview-scope']).toMatchObject([
{ id: 'file:b.ts' },
])
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview-scope']).toBe('file:b.ts')
useWorkspacePanelStore.getState().closePreviewTabs('session-preview-scope', 'file:b.ts', 'all')
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-preview-scope']).toBeUndefined()
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview-scope']).toBeNull()
})
it('ignores stale status responses for the same session', async () => {
const first = deferred<{
state: 'ok'
workDir: string
repoName: string | null
branch: string | null
isGitRepo: boolean
changedFiles: []
error?: string
}>()
const second = deferred<{
state: 'ok'
workDir: string
repoName: string | null
branch: string | null
isGitRepo: boolean
changedFiles: []
error?: string
}>()
mocks.getWorkspaceStatusMock
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
const firstLoad = useWorkspacePanelStore.getState().loadStatus('session-race')
const secondLoad = useWorkspacePanelStore.getState().loadStatus('session-race')
second.resolve({
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'new',
isGitRepo: true,
changedFiles: [],
})
await secondLoad
first.resolve({
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'old',
isGitRepo: true,
changedFiles: [],
})
await firstLoad
expect(useWorkspacePanelStore.getState().statusBySession['session-race']?.branch).toBe('new')
})
it('ignores stale preview responses after close and reopen', async () => {
const first = deferred<{ state: 'ok'; path: string; content: string; language: string; size: number }>()
const second = deferred<{ state: 'ok'; path: string; content: string; language: string; size: number }>()
mocks.getWorkspaceFileMock
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
const firstOpen = useWorkspacePanelStore.getState().openPreview('session-preview-race', 'src/a.ts', 'file')
useWorkspacePanelStore.getState().closePreview('session-preview-race', 'file:src/a.ts')
const secondOpen = useWorkspacePanelStore.getState().openPreview('session-preview-race', 'src/a.ts', 'file')
second.resolve({
state: 'ok',
path: 'src/a.ts',
content: 'new',
language: 'typescript',
size: 3,
})
await secondOpen
first.resolve({
state: 'ok',
path: 'src/a.ts',
content: 'old',
language: 'typescript',
size: 3,
})
await firstOpen
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-preview-race']).toEqual([
expect.objectContaining({ id: 'file:src/a.ts', content: 'new' }),
])
})
it('does not recreate loading or error state when a loading preview is closed before completion', async () => {
const pending = deferred<{ state: 'ok'; path: string; diff: string }>()
const tabId = getWorkspacePreviewTabId('src/a.ts', 'diff')
const previewKey = `session-preview-close::${tabId}`
mocks.getWorkspaceDiffMock.mockReturnValueOnce(pending.promise)
const openPromise = useWorkspacePanelStore.getState().openPreview('session-preview-close', 'src/a.ts', 'diff')
useWorkspacePanelStore.getState().closePreview('session-preview-close', tabId)
pending.resolve({
state: 'ok',
path: 'src/a.ts',
diff: '@@ -1 +1 @@',
})
await openPromise
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-preview-close']).toBeUndefined()
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview-close']).toBeNull()
expect(useWorkspacePanelStore.getState().loading.previewByTabId[previewKey]).toBeUndefined()
expect(useWorkspacePanelStore.getState().errors.previewByTabId[previewKey]).toBeUndefined()
})
it('clears session UI and cached data for clearSession and resetSessionUi', () => {
useWorkspacePanelStore.setState((state) => ({
...state,
panelBySession: {
'session-clear': { isOpen: true, activeView: 'all' },
'session-reset': { isOpen: true, activeView: 'all' },
},
statusBySession: {
'session-clear': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
'session-reset': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
expandedPathsBySession: {
'session-clear': ['src'],
'session-reset': ['src'],
},
treeBySessionPath: {
'session-clear': {
src: { state: 'ok', path: 'src', entries: [] },
},
'session-reset': {
src: { state: 'ok', path: 'src', entries: [] },
},
},
previewTabsBySession: {
'session-clear': [
{ id: 'file:src/a.ts', path: 'src/a.ts', kind: 'file', title: 'a.ts' },
],
'session-reset': [
{ id: 'file:src/b.ts', path: 'src/b.ts', kind: 'file', title: 'b.ts' },
],
},
activePreviewTabIdBySession: {
'session-clear': 'file:src/a.ts',
'session-reset': 'file:src/b.ts',
},
loading: {
statusBySession: {
'session-clear': true,
'session-reset': true,
},
treeBySessionPath: {
'session-clear::src': true,
'session-reset::src': true,
},
previewByTabId: {
'session-clear::file:src/a.ts': true,
'session-reset::file:src/b.ts': true,
},
},
errors: {
statusBySession: {
'session-clear': 'bad',
'session-reset': 'bad',
},
treeBySessionPath: {
'session-clear::src': 'bad',
'session-reset::src': 'bad',
},
previewByTabId: {
'session-clear::file:src/a.ts': 'bad',
'session-reset::file:src/b.ts': 'bad',
},
},
}))
useWorkspacePanelStore.getState().clearSession('session-clear')
useWorkspacePanelStore.getState().resetSessionUi('session-reset')
const state = useWorkspacePanelStore.getState()
expect(state.panelBySession['session-clear']).toBeUndefined()
expect(state.panelBySession['session-reset']).toBeUndefined()
expect(state.statusBySession['session-clear']).toBeUndefined()
expect(state.statusBySession['session-reset']).toBeUndefined()
expect(state.expandedPathsBySession['session-clear']).toBeUndefined()
expect(state.expandedPathsBySession['session-reset']).toBeUndefined()
expect(state.treeBySessionPath['session-clear']).toBeUndefined()
expect(state.treeBySessionPath['session-reset']).toBeUndefined()
expect(state.previewTabsBySession['session-clear']).toBeUndefined()
expect(state.previewTabsBySession['session-reset']).toBeUndefined()
expect(state.activePreviewTabIdBySession['session-clear']).toBeUndefined()
expect(state.activePreviewTabIdBySession['session-reset']).toBeUndefined()
expect(state.loading.statusBySession['session-clear']).toBeUndefined()
expect(state.loading.statusBySession['session-reset']).toBeUndefined()
expect(state.loading.treeBySessionPath['session-clear::src']).toBeUndefined()
expect(state.loading.treeBySessionPath['session-reset::src']).toBeUndefined()
expect(state.loading.previewByTabId['session-clear::file:src/a.ts']).toBeUndefined()
expect(state.loading.previewByTabId['session-reset::file:src/b.ts']).toBeUndefined()
expect(state.errors.statusBySession['session-clear']).toBeUndefined()
expect(state.errors.statusBySession['session-reset']).toBeUndefined()
expect(state.errors.treeBySessionPath['session-clear::src']).toBeUndefined()
expect(state.errors.treeBySessionPath['session-reset::src']).toBeUndefined()
expect(state.errors.previewByTabId['session-clear::file:src/a.ts']).toBeUndefined()
expect(state.errors.previewByTabId['session-reset::file:src/b.ts']).toBeUndefined()
})
})

View File

@ -0,0 +1,716 @@
import { create } from 'zustand'
import {
sessionsApi,
type WorkspaceDiffResult,
type WorkspaceReadFileResult,
type WorkspaceStatusResult,
type WorkspaceTreeResult,
} from '../api/sessions'
export const WORKSPACE_PANEL_DEFAULT_WIDTH = 860
export const WORKSPACE_PANEL_MIN_WIDTH = 640
export const WORKSPACE_PANEL_MAX_WIDTH = 1120
export type WorkspacePanelView = 'changed' | 'all'
export type WorkspacePreviewKind = 'file' | 'diff'
export type WorkspacePreviewCloseScope = 'current' | 'others' | 'left' | 'right' | 'all'
export type WorkspacePreviewState =
| 'loading'
| WorkspaceReadFileResult['state']
| WorkspaceDiffResult['state']
export type WorkspacePreviewTab = {
id: string
path: string
kind: WorkspacePreviewKind
title: string
language?: string
content?: string
dataUrl?: string
mimeType?: string
previewType?: 'text' | 'image'
diff?: string
state?: WorkspacePreviewState
error?: string
size?: number
}
export type WorkspacePanelSessionState = {
isOpen: boolean
activeView: WorkspacePanelView
hasUserSelectedView?: boolean
}
type WorkspacePanelLoadingState = {
statusBySession: Record<string, boolean | undefined>
treeBySessionPath: Record<string, boolean | undefined>
previewByTabId: Record<string, boolean | undefined>
}
type WorkspacePanelErrorState = {
statusBySession: Record<string, string | null | undefined>
treeBySessionPath: Record<string, string | null | undefined>
previewByTabId: Record<string, string | null | undefined>
}
type WorkspacePanelStore = {
panelBySession: Record<string, WorkspacePanelSessionState | undefined>
width: number
statusBySession: Record<string, WorkspaceStatusResult | undefined>
expandedPathsBySession: Record<string, string[] | undefined>
treeBySessionPath: Record<string, Record<string, WorkspaceTreeResult | undefined> | undefined>
previewTabsBySession: Record<string, WorkspacePreviewTab[] | undefined>
activePreviewTabIdBySession: Record<string, string | null | undefined>
loading: WorkspacePanelLoadingState
errors: WorkspacePanelErrorState
isPanelOpen: (sessionId: string) => boolean
getActiveView: (sessionId: string) => WorkspacePanelView
openPanel: (sessionId: string) => void
closePanel: (sessionId: string) => void
togglePanel: (sessionId: string) => void
setWidth: (width: number) => void
setActiveView: (sessionId: string, view: WorkspacePanelView) => void
loadStatus: (sessionId: string) => Promise<void>
loadTree: (sessionId: string, path?: string) => Promise<void>
toggleTreeNode: (sessionId: string, path: string) => Promise<void>
openPreview: (sessionId: string, path: string, kind: WorkspacePreviewKind) => Promise<void>
closePreview: (sessionId: string, tabId: string) => void
closePreviewTabs: (sessionId: string, tabId: string, scope: WorkspacePreviewCloseScope) => void
clearSession: (sessionId: string) => void
resetSessionUi: (sessionId: string) => void
}
const DEFAULT_PANEL_STATE: WorkspacePanelSessionState = {
isOpen: false,
activeView: 'changed',
}
const statusRequestIds = new Map<string, number>()
const treeRequestIds = new Map<string, number>()
const previewRequestIds = new Map<string, number>()
function nextRequestId(store: Map<string, number>, key: string) {
const requestId = (store.get(key) ?? 0) + 1
store.set(key, requestId)
return requestId
}
function invalidateRequest(store: Map<string, number>, key: string) {
store.set(key, (store.get(key) ?? 0) + 1)
}
function isLatestRequest(store: Map<string, number>, key: string, requestId: number) {
return store.get(key) === requestId
}
export function clampWorkspacePanelWidth(width: number) {
if (!Number.isFinite(width)) return WORKSPACE_PANEL_DEFAULT_WIDTH
const rounded = Math.round(width)
return Math.min(WORKSPACE_PANEL_MAX_WIDTH, Math.max(WORKSPACE_PANEL_MIN_WIDTH, rounded))
}
function getSessionPanelState(
panelBySession: Record<string, WorkspacePanelSessionState | undefined>,
sessionId: string,
) {
return panelBySession[sessionId] ?? DEFAULT_PANEL_STATE
}
function makeTreeKey(sessionId: string, path: string) {
return `${sessionId}::${path}`
}
export function getWorkspacePreviewTabId(path: string, kind: WorkspacePreviewKind) {
return `${kind}:${path}`
}
function makePreviewKey(sessionId: string, tabId: string) {
return `${sessionId}::${tabId}`
}
function getPathTitle(path: string) {
if (!path) return 'Workspace'
const segments = path.split('/').filter(Boolean)
return segments[segments.length - 1] ?? path
}
function stripSessionKeys<T>(record: Record<string, T>, sessionId: string) {
const prefix = `${sessionId}::`
return Object.fromEntries(
Object.entries(record).filter(([key]) => !key.startsWith(prefix)),
) as Record<string, T>
}
function removeRecordKey<T>(record: Record<string, T>, key: string) {
if (!(key in record)) return record
const { [key]: _removed, ...rest } = record
return rest
}
function removeRecordKeys<T>(record: Record<string, T>, keys: string[]) {
let next = record
for (const key of keys) {
next = removeRecordKey(next, key)
}
return next
}
function invalidateSessionScopedRequests(store: Map<string, number>, sessionId: string) {
const prefix = `${sessionId}::`
for (const key of store.keys()) {
if (key.startsWith(prefix)) {
invalidateRequest(store, key)
}
}
}
function upsertPreviewTab(
tabs: WorkspacePreviewTab[],
tabId: string,
update: WorkspacePreviewTab | ((current: WorkspacePreviewTab) => WorkspacePreviewTab),
) {
const index = tabs.findIndex((tab) => tab.id === tabId)
if (index < 0) return tabs
const current = tabs[index]!
const next = typeof update === 'function' ? update(current) : update
const nextTabs = [...tabs]
nextTabs[index] = next
return nextTabs
}
export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) => ({
panelBySession: {},
width: WORKSPACE_PANEL_DEFAULT_WIDTH,
statusBySession: {},
expandedPathsBySession: {},
treeBySessionPath: {},
previewTabsBySession: {},
activePreviewTabIdBySession: {},
loading: {
statusBySession: {},
treeBySessionPath: {},
previewByTabId: {},
},
errors: {
statusBySession: {},
treeBySessionPath: {},
previewByTabId: {},
},
isPanelOpen: (sessionId) => getSessionPanelState(get().panelBySession, sessionId).isOpen,
getActiveView: (sessionId) => getSessionPanelState(get().panelBySession, sessionId).activeView,
openPanel: (sessionId) =>
set((state) => ({
panelBySession: {
...state.panelBySession,
[sessionId]: {
...getSessionPanelState(state.panelBySession, sessionId),
isOpen: true,
},
},
})),
closePanel: (sessionId) =>
set((state) => ({
panelBySession: {
...state.panelBySession,
[sessionId]: {
...getSessionPanelState(state.panelBySession, sessionId),
isOpen: false,
},
},
})),
togglePanel: (sessionId) =>
set((state) => {
const panel = getSessionPanelState(state.panelBySession, sessionId)
return {
panelBySession: {
...state.panelBySession,
[sessionId]: {
...panel,
isOpen: !panel.isOpen,
},
},
}
}),
setWidth: (width) => set({ width: clampWorkspacePanelWidth(width) }),
setActiveView: (sessionId, view) =>
set((state) => ({
panelBySession: {
...state.panelBySession,
[sessionId]: {
...getSessionPanelState(state.panelBySession, sessionId),
activeView: view,
hasUserSelectedView: true,
},
},
})),
loadStatus: async (sessionId) => {
const requestId = nextRequestId(statusRequestIds, sessionId)
set((state) => ({
loading: {
...state.loading,
statusBySession: {
...state.loading.statusBySession,
[sessionId]: true,
},
},
errors: {
...state.errors,
statusBySession: {
...state.errors.statusBySession,
[sessionId]: null,
},
},
}))
try {
const result = await sessionsApi.getWorkspaceStatus(sessionId)
if (!isLatestRequest(statusRequestIds, sessionId, requestId)) return
set((state) => {
const panel = getSessionPanelState(state.panelBySession, sessionId)
const shouldDefaultToAllFiles =
!panel.hasUserSelectedView
&& panel.activeView === 'changed'
&& result.state === 'ok'
&& result.changedFiles.length === 0
return {
panelBySession: {
...state.panelBySession,
[sessionId]: {
...panel,
activeView: shouldDefaultToAllFiles ? 'all' : panel.activeView,
},
},
statusBySession: {
...state.statusBySession,
[sessionId]: result,
},
loading: {
...state.loading,
statusBySession: {
...state.loading.statusBySession,
[sessionId]: false,
},
},
errors: {
...state.errors,
statusBySession: {
...state.errors.statusBySession,
[sessionId]: result.error ?? null,
},
},
}
})
} catch (error) {
if (!isLatestRequest(statusRequestIds, sessionId, requestId)) return
set((state) => ({
loading: {
...state.loading,
statusBySession: {
...state.loading.statusBySession,
[sessionId]: false,
},
},
errors: {
...state.errors,
statusBySession: {
...state.errors.statusBySession,
[sessionId]: error instanceof Error ? error.message : 'Failed to load workspace status',
},
},
}))
}
},
loadTree: async (sessionId, path = '') => {
const treeKey = makeTreeKey(sessionId, path)
const requestId = nextRequestId(treeRequestIds, treeKey)
set((state) => ({
loading: {
...state.loading,
treeBySessionPath: {
...state.loading.treeBySessionPath,
[treeKey]: true,
},
},
errors: {
...state.errors,
treeBySessionPath: {
...state.errors.treeBySessionPath,
[treeKey]: null,
},
},
}))
try {
const result = await sessionsApi.getWorkspaceTree(sessionId, path)
if (!isLatestRequest(treeRequestIds, treeKey, requestId)) return
set((state) => ({
treeBySessionPath: {
...state.treeBySessionPath,
[sessionId]: {
...state.treeBySessionPath[sessionId],
[path]: result,
},
},
loading: {
...state.loading,
treeBySessionPath: {
...state.loading.treeBySessionPath,
[treeKey]: false,
},
},
errors: {
...state.errors,
treeBySessionPath: {
...state.errors.treeBySessionPath,
[treeKey]: result.error ?? null,
},
},
}))
} catch (error) {
if (!isLatestRequest(treeRequestIds, treeKey, requestId)) return
set((state) => ({
loading: {
...state.loading,
treeBySessionPath: {
...state.loading.treeBySessionPath,
[treeKey]: false,
},
},
errors: {
...state.errors,
treeBySessionPath: {
...state.errors.treeBySessionPath,
[treeKey]: error instanceof Error ? error.message : 'Failed to load workspace tree',
},
},
}))
}
},
toggleTreeNode: async (sessionId, path) => {
let shouldLoad = false
set((state) => {
const expanded = new Set(state.expandedPathsBySession[sessionId] ?? [])
if (expanded.has(path)) {
expanded.delete(path)
} else {
expanded.add(path)
if (!state.treeBySessionPath[sessionId]?.[path]) {
shouldLoad = true
}
}
return {
expandedPathsBySession: {
...state.expandedPathsBySession,
[sessionId]: [...expanded],
},
}
})
if (shouldLoad) {
await get().loadTree(sessionId, path)
}
},
openPreview: async (sessionId, path, kind) => {
const tabId = getWorkspacePreviewTabId(path, kind)
const requestKey = makePreviewKey(sessionId, tabId)
const existing = get().previewTabsBySession[sessionId]?.find((tab) => tab.id === tabId)
if (existing) {
set((state) => ({
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
[sessionId]: tabId,
},
}))
return
}
const requestId = nextRequestId(previewRequestIds, requestKey)
const baseTab: WorkspacePreviewTab = {
id: tabId,
path,
kind,
title: getPathTitle(path),
state: 'loading',
}
set((state) => ({
previewTabsBySession: {
...state.previewTabsBySession,
[sessionId]: [...(state.previewTabsBySession[sessionId] ?? []), baseTab],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
[sessionId]: tabId,
},
loading: {
...state.loading,
previewByTabId: {
...state.loading.previewByTabId,
[requestKey]: true,
},
},
errors: {
...state.errors,
previewByTabId: {
...state.errors.previewByTabId,
[requestKey]: null,
},
},
}))
try {
if (kind === 'diff') {
const result = await sessionsApi.getWorkspaceDiff(sessionId, path)
if (!isLatestRequest(previewRequestIds, requestKey, requestId)) return
if (!get().previewTabsBySession[sessionId]?.some((tab) => tab.id === tabId)) return
set((state) => {
const tabs = state.previewTabsBySession[sessionId] ?? []
return {
previewTabsBySession: {
...state.previewTabsBySession,
[sessionId]: upsertPreviewTab(tabs, tabId, (current) => ({
...current,
diff: result.diff ?? '',
content: undefined,
language: undefined,
size: undefined,
state: result.state,
error: result.error,
})),
},
loading: {
...state.loading,
previewByTabId: {
...state.loading.previewByTabId,
[requestKey]: false,
},
},
errors: {
...state.errors,
previewByTabId: {
...state.errors.previewByTabId,
[requestKey]: result.error ?? null,
},
},
}
})
return
}
const result = await sessionsApi.getWorkspaceFile(sessionId, path)
if (!isLatestRequest(previewRequestIds, requestKey, requestId)) return
if (!get().previewTabsBySession[sessionId]?.some((tab) => tab.id === tabId)) return
set((state) => {
const tabs = state.previewTabsBySession[sessionId] ?? []
return {
previewTabsBySession: {
...state.previewTabsBySession,
[sessionId]: upsertPreviewTab(tabs, tabId, (current) => ({
...current,
content: result.content,
dataUrl: result.dataUrl,
mimeType: result.mimeType,
previewType: result.previewType ?? 'text',
diff: undefined,
language: result.language,
size: result.size,
state: result.state,
error: result.error,
})),
},
loading: {
...state.loading,
previewByTabId: {
...state.loading.previewByTabId,
[requestKey]: false,
},
},
errors: {
...state.errors,
previewByTabId: {
...state.errors.previewByTabId,
[requestKey]: result.error ?? null,
},
},
}
})
} catch (error) {
if (!isLatestRequest(previewRequestIds, requestKey, requestId)) return
if (!get().previewTabsBySession[sessionId]?.some((tab) => tab.id === tabId)) return
set((state) => {
const tabs = state.previewTabsBySession[sessionId] ?? []
const message = error instanceof Error ? error.message : 'Failed to load workspace preview'
return {
previewTabsBySession: {
...state.previewTabsBySession,
[sessionId]: upsertPreviewTab(tabs, tabId, (current) => ({
...current,
state: 'error',
error: message,
})),
},
loading: {
...state.loading,
previewByTabId: {
...state.loading.previewByTabId,
[requestKey]: false,
},
},
errors: {
...state.errors,
previewByTabId: {
...state.errors.previewByTabId,
[requestKey]: message,
},
},
}
})
}
},
closePreview: (sessionId, tabId) => {
get().closePreviewTabs(sessionId, tabId, 'current')
},
closePreviewTabs: (sessionId, tabId, scope) => {
set((state) => {
const tabs = state.previewTabsBySession[sessionId] ?? []
const index = tabs.findIndex((tab) => tab.id === tabId)
if (index < 0) {
const requestKey = makePreviewKey(sessionId, tabId)
invalidateRequest(previewRequestIds, requestKey)
return {
loading: {
...state.loading,
previewByTabId: removeRecordKey(state.loading.previewByTabId, requestKey),
},
errors: {
...state.errors,
previewByTabId: removeRecordKey(state.errors.previewByTabId, requestKey),
},
}
}
let nextTabs: WorkspacePreviewTab[]
switch (scope) {
case 'others':
nextTabs = [tabs[index]!]
break
case 'left':
nextTabs = tabs.slice(index)
break
case 'right':
nextTabs = tabs.slice(0, index + 1)
break
case 'all':
nextTabs = []
break
case 'current':
default:
nextTabs = tabs.filter((tab) => tab.id !== tabId)
break
}
const nextTabIds = new Set(nextTabs.map((tab) => tab.id))
const closingTabIds = tabs.map((tab) => tab.id).filter((id) => !nextTabIds.has(id))
const requestKeys = closingTabIds.map((id) => makePreviewKey(sessionId, id))
for (const key of requestKeys) {
invalidateRequest(previewRequestIds, key)
}
const activeTabId = state.activePreviewTabIdBySession[sessionId] ?? null
let nextActiveTabId = activeTabId
if (scope === 'all' || nextTabs.length === 0) {
nextActiveTabId = null
} else if (!activeTabId || !nextTabIds.has(activeTabId)) {
const targetTab = nextTabs.find((tab) => tab.id === tabId)
nextActiveTabId = targetTab?.id ?? nextTabs[Math.min(index, nextTabs.length - 1)]?.id ?? null
} else if (scope === 'others') {
nextActiveTabId = tabId
} else if (activeTabId === tabId && scope === 'current') {
if (nextTabs.length === 0) {
nextActiveTabId = null
} else if (index >= nextTabs.length) {
nextActiveTabId = nextTabs[nextTabs.length - 1]!.id
} else {
nextActiveTabId = nextTabs[index]!.id
}
}
return {
previewTabsBySession: {
...state.previewTabsBySession,
[sessionId]: nextTabs.length > 0 ? nextTabs : undefined,
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
[sessionId]: nextActiveTabId,
},
loading: {
...state.loading,
previewByTabId: removeRecordKeys(state.loading.previewByTabId, requestKeys),
},
errors: {
...state.errors,
previewByTabId: removeRecordKeys(state.errors.previewByTabId, requestKeys),
},
}
})
},
clearSession: (sessionId) => {
invalidateRequest(statusRequestIds, sessionId)
invalidateSessionScopedRequests(treeRequestIds, sessionId)
invalidateSessionScopedRequests(previewRequestIds, sessionId)
set((state) => ({
panelBySession: removeRecordKey(state.panelBySession, sessionId),
statusBySession: removeRecordKey(state.statusBySession, sessionId),
expandedPathsBySession: removeRecordKey(state.expandedPathsBySession, sessionId),
treeBySessionPath: removeRecordKey(state.treeBySessionPath, sessionId),
previewTabsBySession: removeRecordKey(state.previewTabsBySession, sessionId),
activePreviewTabIdBySession: removeRecordKey(state.activePreviewTabIdBySession, sessionId),
loading: {
statusBySession: removeRecordKey(state.loading.statusBySession, sessionId),
treeBySessionPath: stripSessionKeys(state.loading.treeBySessionPath, sessionId),
previewByTabId: stripSessionKeys(state.loading.previewByTabId, sessionId),
},
errors: {
statusBySession: removeRecordKey(state.errors.statusBySession, sessionId),
treeBySessionPath: stripSessionKeys(state.errors.treeBySessionPath, sessionId),
previewByTabId: stripSessionKeys(state.errors.previewByTabId, sessionId),
},
}))
},
resetSessionUi: (sessionId) => {
get().clearSession(sessionId)
},
}))

View File

@ -31,13 +31,22 @@ export type AttachmentRef = {
path?: string
data?: string
mimeType?: string
lineStart?: number
lineEnd?: number
note?: string
quote?: string
}
export type UIAttachment = {
type: 'file' | 'image'
name: string
path?: string
data?: string
mimeType?: string
lineStart?: number
lineEnd?: number
note?: string
quote?: string
}
// ─── Server → Client ──────────────────────────────────────────────
@ -157,7 +166,7 @@ export type TaskSummaryItem = {
}
export type UIMessage =
| { id: string; type: 'user_text'; content: string; timestamp: number; attachments?: UIAttachment[]; pending?: boolean }
| { id: string; type: 'user_text'; content: string; modelContent?: string; timestamp: number; attachments?: UIAttachment[]; pending?: boolean }
| { id: string; type: 'assistant_text'; content: string; timestamp: number; model?: string }
| { id: string; type: 'thinking'; content: string; timestamp: number }
| { id: string; type: 'tool_use'; toolName: string; toolUseId: string; input: unknown; timestamp: number; parentToolUseId?: string }

View File

@ -4,6 +4,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
import * as fs from 'node:fs/promises'
import { execFileSync } from 'node:child_process'
import * as path from 'node:path'
import * as os from 'node:os'
import { SessionService } from '../services/sessionService.js'
@ -75,6 +76,34 @@ async function writeSkill(
)
}
function git(cwd: string, ...args: string[]): string {
return execFileSync('git', args, {
cwd,
encoding: 'utf8',
})
}
async function createWorkspaceApiGitRepo(baseDir: string): Promise<string> {
const workDir = path.join(
baseDir,
`workspace-api-${Date.now()}-${Math.random().toString(36).slice(2)}`,
)
await fs.mkdir(path.join(workDir, 'src'), { recursive: true })
git(workDir, 'init')
git(workDir, 'config', 'user.email', 'sessions-api@example.com')
git(workDir, 'config', 'user.name', 'Sessions API')
await fs.writeFile(path.join(workDir, 'tracked.txt'), 'before\n')
await fs.writeFile(path.join(workDir, 'src', 'app.ts'), 'export const answer = 42\n')
git(workDir, 'add', 'tracked.txt', 'src/app.ts')
git(workDir, 'commit', '-m', 'initial')
await fs.writeFile(path.join(workDir, 'tracked.txt'), 'before\nafter\n')
return workDir
}
// Sample entries matching real CLI format
function makeSnapshotEntry(): Record<string, unknown> {
return {
@ -136,6 +165,31 @@ function makeAssistantEntry(content: string, parentUuid?: string): Record<string
}
}
function makeAssistantToolUseEntry(
toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }>,
parentUuid?: string,
): Record<string, unknown> {
return {
parentUuid: parentUuid || null,
isSidechain: false,
type: 'assistant',
message: {
model: 'claude-opus-4-7',
id: `msg_${crypto.randomUUID().slice(0, 20)}`,
type: 'message',
role: 'assistant',
content: toolUses.map((toolUse) => ({
type: 'tool_use',
id: toolUse.id,
name: toolUse.name,
input: toolUse.input,
})),
},
uuid: crypto.randomUUID(),
timestamp: '2026-01-01T00:02:00.000Z',
}
}
function makeMetaUserEntry(): Record<string, unknown> {
return {
parentUuid: null,
@ -930,6 +984,301 @@ describe('Sessions API', () => {
)
})
it('GET /api/sessions/:id/workspace/status|tree|file|diff should return workspace data', async () => {
const workDir = await createWorkspaceApiGitRepo(tmpDir)
const { sessionId } = await service.createSession(workDir)
const statusRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/status`)
expect(statusRes.status).toBe(200)
const statusBody = await statusRes.json() as {
state: string
workDir: string
changedFiles: Array<{ path: string; status: string }>
isGitRepo: boolean
}
expect(statusBody.state).toBe('ok')
expect(statusBody.workDir).toBe(workDir)
expect(statusBody.isGitRepo).toBe(true)
expect(statusBody.changedFiles).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: 'tracked.txt', status: 'modified' }),
]),
)
const treeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/tree`)
expect(treeRes.status).toBe(200)
const treeBody = await treeRes.json() as {
state: string
path: string
entries: Array<{ name: string; path: string; isDirectory: boolean }>
}
expect(treeBody).toMatchObject({
state: 'ok',
path: '',
})
expect(treeBody.entries).toEqual([
{ name: 'src', path: 'src', isDirectory: true },
{ name: 'tracked.txt', path: 'tracked.txt', isDirectory: false },
])
const fileRes = await fetch(
`${baseUrl}/api/sessions/${sessionId}/workspace/file?path=${encodeURIComponent('src/app.ts')}`,
)
expect(fileRes.status).toBe(200)
const fileBody = await fileRes.json() as {
state: string
path: string
content?: string
language: string
size: number
}
expect(fileBody).toMatchObject({
state: 'ok',
path: 'src/app.ts',
language: 'typescript',
size: 25,
content: 'export const answer = 42\n',
})
const diffRes = await fetch(
`${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('tracked.txt')}`,
)
expect(diffRes.status).toBe(200)
const diffBody = await diffRes.json() as {
state: string
path: string
diff?: string
}
expect(diffBody.state).toBe('ok')
expect(diffBody.path).toBe('tracked.txt')
expect(diffBody.diff).toContain('tracked.txt')
})
it('GET /api/sessions/:id/workspace/* should surface transcript changes for a non-git tmp session', async () => {
const sessionId = 'bbbbbbbb-cccc-dddd-eeee-ffffffffffff'
const workDir = await fs.mkdtemp(path.join(tmpDir, 'workspace-api-non-git-'))
const srcDir = path.join(workDir, 'src')
const notesDir = path.join(workDir, 'notes')
const assetsDir = path.join(workDir, 'assets')
await fs.mkdir(srcDir, { recursive: true })
await fs.mkdir(notesDir, { recursive: true })
await fs.mkdir(assetsDir, { recursive: true })
await fs.writeFile(path.join(workDir, 'README.md'), '# Temporary project\n')
await fs.writeFile(path.join(srcDir, 'app.ts'), 'export const answer = 2\n')
await fs.writeFile(path.join(notesDir, 'todo.md'), '- ship workspace panel\n')
await fs.writeFile(
path.join(assetsDir, 'pixel.png'),
Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
'base64',
),
)
await writeSessionFile(sanitizePath(workDir), sessionId, [
makeSnapshotEntry(),
makeSessionMetaEntry(workDir),
makeUserEntry('Update this temporary project'),
makeAssistantToolUseEntry([
{
id: 'toolu-edit-app',
name: 'Edit',
input: {
file_path: path.join(workDir, 'src', 'app.ts'),
old_string: 'export const answer = 1\n',
new_string: 'export const answer = 2\n',
},
},
{
id: 'toolu-write-todo',
name: 'Write',
input: {
file_path: path.join(workDir, 'notes', 'todo.md'),
content: '- ship workspace panel\n',
},
},
]),
])
const statusRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/status`)
expect(statusRes.status).toBe(200)
const statusBody = await statusRes.json() as {
state: string
workDir: string
repoName: string | null
branch: string | null
isGitRepo: boolean
changedFiles: Array<{
path: string
status: string
additions: number
deletions: number
}>
}
expect(statusBody).toMatchObject({
state: 'ok',
workDir,
repoName: path.basename(workDir),
branch: null,
isGitRepo: false,
})
expect(statusBody.changedFiles).toEqual([
expect.objectContaining({
path: 'notes/todo.md',
status: 'added',
additions: 1,
deletions: 0,
}),
expect.objectContaining({
path: 'src/app.ts',
status: 'modified',
additions: 1,
deletions: 1,
}),
])
const treeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/tree`)
expect(treeRes.status).toBe(200)
const treeBody = await treeRes.json() as {
state: string
path: string
entries: Array<{ name: string; path: string; isDirectory: boolean }>
}
expect(treeBody).toMatchObject({ state: 'ok', path: '' })
expect(treeBody.entries).toEqual([
{ name: 'assets', path: 'assets', isDirectory: true },
{ name: 'notes', path: 'notes', isDirectory: true },
{ name: 'src', path: 'src', isDirectory: true },
{ name: 'README.md', path: 'README.md', isDirectory: false },
])
const srcTreeRes = await fetch(
`${baseUrl}/api/sessions/${sessionId}/workspace/tree?path=${encodeURIComponent('src')}`,
)
expect(srcTreeRes.status).toBe(200)
expect(await srcTreeRes.json()).toMatchObject({
state: 'ok',
path: 'src',
entries: [{ name: 'app.ts', path: 'src/app.ts', isDirectory: false }],
})
const fileRes = await fetch(
`${baseUrl}/api/sessions/${sessionId}/workspace/file?path=${encodeURIComponent('src/app.ts')}`,
)
expect(fileRes.status).toBe(200)
expect(await fileRes.json()).toMatchObject({
state: 'ok',
path: 'src/app.ts',
previewType: 'text',
language: 'typescript',
content: 'export const answer = 2\n',
})
const imageRes = await fetch(
`${baseUrl}/api/sessions/${sessionId}/workspace/file?path=${encodeURIComponent('assets/pixel.png')}`,
)
expect(imageRes.status).toBe(200)
const imageBody = await imageRes.json() as {
state: string
path: string
previewType: string
mimeType: string
dataUrl: string
}
expect(imageBody).toMatchObject({
state: 'ok',
path: 'assets/pixel.png',
previewType: 'image',
mimeType: 'image/png',
})
expect(imageBody.dataUrl).toStartWith('data:image/png;base64,')
const appDiffRes = await fetch(
`${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('src/app.ts')}`,
)
expect(appDiffRes.status).toBe(200)
const appDiffBody = await appDiffRes.json() as { state: string; path: string; diff?: string }
expect(appDiffBody).toMatchObject({ state: 'ok', path: 'src/app.ts' })
expect(appDiffBody.diff).toContain('diff --session a/src/app.ts b/src/app.ts')
expect(appDiffBody.diff).toContain('-export const answer = 1')
expect(appDiffBody.diff).toContain('+export const answer = 2')
const todoDiffRes = await fetch(
`${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('notes/todo.md')}`,
)
expect(todoDiffRes.status).toBe(200)
const todoDiffBody = await todoDiffRes.json() as { state: string; path: string; diff?: string }
expect(todoDiffBody).toMatchObject({ state: 'ok', path: 'notes/todo.md' })
expect(todoDiffBody.diff).toContain('--- /dev/null')
expect(todoDiffBody.diff).toContain('+++ b/notes/todo.md')
expect(todoDiffBody.diff).toContain('+- ship workspace panel')
})
it('GET /api/sessions/:id/workspace/file and diff should require a path query', async () => {
const workDir = await createWorkspaceApiGitRepo(tmpDir)
const { sessionId } = await service.createSession(workDir)
for (const route of ['file', 'diff']) {
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/${route}`)
expect(res.status).toBe(400)
expect(await res.json()).toMatchObject({
error: 'BAD_REQUEST',
})
}
})
it('GET /api/sessions/:id/workspace/file and tree should reject traversal with 403', async () => {
const workDir = await createWorkspaceApiGitRepo(tmpDir)
const { sessionId } = await service.createSession(workDir)
for (const route of ['file', 'tree']) {
const res = await fetch(
`${baseUrl}/api/sessions/${sessionId}/workspace/${route}?path=${encodeURIComponent('../outside.txt')}`,
)
expect(res.status).toBe(403)
expect(await res.json()).toMatchObject({
error: 'FORBIDDEN',
})
}
})
it('GET /api/sessions/:id/workspace/diff should reject traversal with 403', async () => {
const workDir = await createWorkspaceApiGitRepo(tmpDir)
const { sessionId } = await service.createSession(workDir)
const res = await fetch(
`${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('../outside.txt')}`,
)
expect(res.status).toBe(403)
expect(await res.json()).toMatchObject({
error: 'FORBIDDEN',
})
})
it('GET /api/sessions/:id/workspace/status should 404 for unknown sessions', async () => {
const res = await fetch(
`${baseUrl}/api/sessions/00000000-0000-0000-0000-000000000000/workspace/status`,
)
expect(res.status).toBe(404)
expect(await res.json()).toMatchObject({
error: 'NOT_FOUND',
})
})
it('non-GET workspace routes should return 405', async () => {
const workDir = await createWorkspaceApiGitRepo(tmpDir)
const { sessionId } = await service.createSession(workDir)
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/status`, {
method: 'POST',
})
expect(res.status).toBe(405)
expect(await res.json()).toMatchObject({
error: 'METHOD_NOT_ALLOWED',
})
})
it('POST /api/sessions/:id/rewind should preview and trim the active conversation chain', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const firstUserId = crypto.randomUUID()
@ -1188,6 +1537,65 @@ describe('Sessions API', () => {
expect(remainingMessages).toHaveLength(0)
})
it('POST /api/sessions/:id/rewind should resolve checkpoint paths from the target prompt cwd', async () => {
const sessionId = 'bbbbbbbb-bbbb-cccc-dddd-ffffffffffff'
const parentDir = path.join(tmpDir, 'nested-cwd-parent')
const workDir = path.join(parentDir, 'testbb')
const targetFile = path.join(workDir, 'vite.config.js')
const userId = crypto.randomUUID()
const assistantId = crypto.randomUUID()
const laterUserId = crypto.randomUUID()
const backupName = 'nested-cwd@v1'
await fs.mkdir(workDir, { recursive: true })
await fs.writeFile(targetFile, "export default 'after'\n", 'utf-8')
await writeFileHistoryBackup(sessionId, backupName, "export default 'before'\n")
await writeSessionFile(sanitizePath(parentDir), sessionId, [
makeFileHistorySnapshotEntry(userId, {
'testbb/vite.config.js': {
backupFileName: backupName,
version: 1,
backupTime: '2026-01-01T00:00:00.000Z',
},
}),
{
...makeUserEntry('create a nested project', userId),
cwd: parentDir,
sessionId,
},
{
...makeAssistantEntry('DONE', userId),
uuid: assistantId,
},
{
...makeUserEntry('latest tool result after cd', laterUserId),
cwd: workDir,
sessionId,
},
])
const previewRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userMessageIndex: 0, dryRun: true }),
})
expect(previewRes.status).toBe(200)
const preview = await previewRes.json() as {
code: { available: boolean; filesChanged: string[] }
}
expect(preview.code.available).toBe(true)
expect(preview.code.filesChanged).toEqual([targetFile])
const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userMessageIndex: 0 }),
})
expect(executeRes.status).toBe(200)
expect(await fs.readFile(targetFile, 'utf-8')).toBe("export default 'before'\n")
})
it('POST /api/sessions/:id/rewind should restore multiple files and remove created files', async () => {
const sessionId = 'cccccccc-bbbb-cccc-dddd-eeeeeeeeeeee'
const workDir = path.join(tmpDir, 'multi-file-fixture')
@ -1318,6 +1726,163 @@ describe('Sessions API', () => {
expect(remainingMessages[0]?.id).toBe(firstUserId)
})
it('POST /api/sessions/:id/rewind should keep first-turn file state when undoing only the latest turn', async () => {
const sessionId = 'dddddddd-bbbb-cccc-dddd-ffffffffffff'
const workDir = path.join(tmpDir, 'two-turns-separate-files')
const firstTurnFile = path.join(workDir, 'src', 'first.js')
const secondTurnFile = path.join(workDir, 'src', 'second.js')
const firstUserId = crypto.randomUUID()
const secondUserId = crypto.randomUUID()
const firstBaseBackup = 'separate-first@v1'
const firstAfterTurnBackup = 'separate-first@v2'
const secondBaseBackup = 'separate-second@v1'
await fs.mkdir(path.dirname(firstTurnFile), { recursive: true })
await fs.writeFile(firstTurnFile, "export const FIRST = 'v1'\n", 'utf-8')
await fs.writeFile(secondTurnFile, "export const SECOND = 'v2'\n", 'utf-8')
await writeFileHistoryBackup(sessionId, firstBaseBackup, "export const FIRST = 'base'\n")
await writeFileHistoryBackup(sessionId, firstAfterTurnBackup, "export const FIRST = 'v1'\n")
await writeFileHistoryBackup(sessionId, secondBaseBackup, "export const SECOND = 'base'\n")
await writeSessionFile('-tmp-api-two-turns-separate-files', sessionId, [
makeSessionMetaEntry(workDir),
makeFileHistorySnapshotEntry(firstUserId, {
'src/first.js': {
backupFileName: firstBaseBackup,
version: 1,
backupTime: '2026-01-01T00:00:00.000Z',
},
}),
{
...makeUserEntry('make first file v1', firstUserId),
cwd: workDir,
sessionId,
},
makeAssistantEntry('DONE first', firstUserId),
makeFileHistorySnapshotEntry(secondUserId, {
'src/first.js': {
backupFileName: firstAfterTurnBackup,
version: 2,
backupTime: '2026-01-01T00:00:00.000Z',
},
'src/second.js': {
backupFileName: secondBaseBackup,
version: 1,
backupTime: '2026-01-01T00:00:00.000Z',
},
}),
{
...makeUserEntry('make second file v2', secondUserId),
cwd: workDir,
sessionId,
},
makeAssistantEntry('DONE second', secondUserId),
])
const previewRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userMessageIndex: 1, dryRun: true }),
})
expect(previewRes.status).toBe(200)
const preview = await previewRes.json() as {
code: { available: boolean; filesChanged: string[] }
}
expect(preview.code.available).toBe(true)
expect(preview.code.filesChanged).toEqual([secondTurnFile])
const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userMessageIndex: 1 }),
})
expect(executeRes.status).toBe(200)
expect(await fs.readFile(firstTurnFile, 'utf-8')).toBe("export const FIRST = 'v1'\n")
expect(await fs.readFile(secondTurnFile, 'utf-8')).toBe("export const SECOND = 'base'\n")
const remainingMessages = await service.getSessionMessages(sessionId)
expect(remainingMessages).toHaveLength(2)
expect(remainingMessages[0]?.id).toBe(firstUserId)
})
it('POST /api/sessions/:id/rewind should include files created after the first turn', async () => {
const sessionId = 'eeeeeeee-bbbb-cccc-dddd-eeeeeeeeeeee'
const workDir = path.join(tmpDir, 'created-on-second-turn')
const firstFile = path.join(workDir, 'src', 'step.js')
const createdFile = path.join(workDir, 'notes', 'generated.txt')
const firstUserId = crypto.randomUUID()
const secondUserId = crypto.randomUUID()
const backupV1 = 'second-created-step@v1'
const backupV2 = 'second-created-step@v2'
await fs.mkdir(path.dirname(firstFile), { recursive: true })
await fs.mkdir(path.dirname(createdFile), { recursive: true })
await fs.writeFile(firstFile, "export const STEP = 'v2'\n", 'utf-8')
await fs.writeFile(createdFile, 'generated\n', 'utf-8')
await writeFileHistoryBackup(sessionId, backupV1, "export const STEP = 'base'\n")
await writeFileHistoryBackup(sessionId, backupV2, "export const STEP = 'v1'\n")
await writeSessionFile('-tmp-api-second-turn-created', sessionId, [
makeSessionMetaEntry(workDir),
makeFileHistorySnapshotEntry(firstUserId, {
'src/step.js': {
backupFileName: backupV1,
version: 1,
backupTime: '2026-01-01T00:00:00.000Z',
},
}),
{
...makeUserEntry('make v1', firstUserId),
cwd: workDir,
sessionId,
},
makeAssistantEntry('DONE', firstUserId),
makeFileHistorySnapshotEntry(secondUserId, {
'src/step.js': {
backupFileName: backupV2,
version: 2,
backupTime: '2026-01-01T00:00:00.000Z',
},
'notes/generated.txt': {
backupFileName: null,
version: 2,
backupTime: '2026-01-01T00:00:00.000Z',
},
}),
{
...makeUserEntry('make v2 and create file', secondUserId),
cwd: workDir,
sessionId,
},
makeAssistantEntry('DONE', secondUserId),
])
const previewRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userMessageIndex: 1, dryRun: true }),
})
expect(previewRes.status).toBe(200)
const preview = await previewRes.json() as {
code: { available: boolean; filesChanged: string[]; insertions: number }
}
expect(preview.code.filesChanged.sort()).toEqual([
createdFile,
firstFile,
].sort())
expect(preview.code.insertions).toBe(2)
const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userMessageIndex: 1 }),
})
expect(executeRes.status).toBe(200)
expect(await fs.readFile(firstFile, 'utf-8')).toBe("export const STEP = 'v1'\n")
await expect(fs.stat(createdFile)).rejects.toMatchObject({ code: 'ENOENT' })
})
// --------------------------------------------------------------------------
// Conversations API via /api/sessions/:id/chat
// --------------------------------------------------------------------------

View File

@ -0,0 +1,461 @@
import { afterEach, describe, expect, it } from 'bun:test'
import * as fs from 'node:fs/promises'
import { execFileSync } from 'node:child_process'
import * as os from 'node:os'
import * as path from 'node:path'
import { WorkspaceService } from '../services/workspaceService.js'
const cleanupDirs = new Set<string>()
const ONE_MIB = 1024 * 1024
function trackDir(dir: string): string {
cleanupDirs.add(dir)
return dir
}
async function makeTempDir(prefix: string): Promise<string> {
return trackDir(await fs.mkdtemp(path.join(os.tmpdir(), prefix)))
}
function git(cwd: string, ...args: string[]): string {
return execFileSync('git', args, {
cwd,
encoding: 'utf8',
})
}
async function createGitWorkspace(): Promise<string> {
const repoDir = await makeTempDir('workspace-service-git-')
git(repoDir, 'init')
git(repoDir, 'config', 'user.email', 'workspace-service@example.com')
git(repoDir, 'config', 'user.name', 'Workspace Service')
await fs.writeFile(path.join(repoDir, 'tracked.txt'), 'before\n')
await fs.writeFile(path.join(repoDir, 'deleted.txt'), 'delete me\n')
await fs.writeFile(path.join(repoDir, 'clean.txt'), 'clean\n')
git(repoDir, 'add', 'tracked.txt', 'deleted.txt', 'clean.txt')
git(repoDir, 'commit', '-m', 'initial')
await fs.writeFile(path.join(repoDir, 'tracked.txt'), 'before\nafter\n')
await fs.writeFile(path.join(repoDir, 'new.txt'), 'new file\n')
git(repoDir, 'add', 'new.txt')
await fs.unlink(path.join(repoDir, 'deleted.txt'))
await fs.writeFile(path.join(repoDir, 'untracked.txt'), 'still untracked\n')
return repoDir
}
async function createNestedGitWorkspace(): Promise<{
repoDir: string
workDir: string
}> {
const repoDir = await makeTempDir('workspace-service-nested-git-')
const workDir = path.join(repoDir, 'subdir')
git(repoDir, 'init')
git(repoDir, 'config', 'user.email', 'workspace-service@example.com')
git(repoDir, 'config', 'user.name', 'Workspace Service')
await fs.mkdir(workDir)
await fs.writeFile(path.join(repoDir, 'root.txt'), 'root original\n')
await fs.writeFile(path.join(workDir, 'sub.txt'), 'sub original\n')
git(repoDir, 'add', 'root.txt', 'subdir/sub.txt')
git(repoDir, 'commit', '-m', 'initial')
await fs.writeFile(path.join(repoDir, 'root.txt'), 'root original\nroot changed\n')
await fs.writeFile(path.join(workDir, 'sub.txt'), 'sub original\nsub changed\n')
return { repoDir, workDir }
}
afterEach(async () => {
for (const dir of cleanupDirs) {
await fs.rm(dir, { recursive: true, force: true })
}
cleanupDirs.clear()
})
describe('WorkspaceService', () => {
it('returns git status for modified, added, deleted, and untracked files', async () => {
const repoDir = await createGitWorkspace()
const service = new WorkspaceService(async (sessionId) => sessionId === 'session-1' ? repoDir : null)
const result = await service.getStatus('session-1')
expect(result.state).toBe('ok')
expect(result.workDir).toBe(repoDir)
expect(result.isGitRepo).toBe(true)
expect(result.repoName).toBe(path.basename(repoDir))
expect(result.branch).toBeTruthy()
const files = new Map(result.changedFiles.map((file) => [file.path, file]))
expect(Array.from(files.keys()).sort()).toEqual([
'deleted.txt',
'new.txt',
'tracked.txt',
'untracked.txt',
])
expect(files.get('tracked.txt')?.status).toBe('modified')
expect(files.get('tracked.txt')?.additions).toBeGreaterThan(0)
expect(files.get('new.txt')?.status).toBe('added')
expect(files.get('new.txt')?.additions).toBeGreaterThan(0)
expect(files.get('deleted.txt')?.status).toBe('deleted')
expect(files.get('deleted.txt')?.deletions).toBeGreaterThan(0)
expect(files.get('untracked.txt')).toMatchObject({
status: 'untracked',
additions: 1,
deletions: 0,
})
})
it('scopes git status and diff paths to a nested workDir inside a repo', async () => {
const { repoDir, workDir } = await createNestedGitWorkspace()
const service = new WorkspaceService(async (sessionId) => sessionId === 'session-1' ? workDir : null)
const status = await service.getStatus('session-1')
expect(status.state).toBe('ok')
expect(status.workDir).toBe(workDir)
expect(status.repoName).toBe(path.basename(repoDir))
expect(status.changedFiles).toHaveLength(1)
expect(status.changedFiles[0]).toMatchObject({
path: 'sub.txt',
status: 'modified',
})
expect(status.changedFiles[0]?.additions).toBeGreaterThan(0)
expect(status.changedFiles[0]?.deletions).toBeGreaterThanOrEqual(0)
expect(status.changedFiles.some((file) => file.path === 'root.txt')).toBe(false)
const diff = await service.getDiff('session-1', 'sub.txt')
expect(diff.state).toBe('ok')
expect(diff.diff).toContain('subdir/sub.txt')
expect(diff.diff?.length).toBeGreaterThan(0)
})
it('returns explicit non-git and missing-workdir states', async () => {
const nonGitDir = await makeTempDir('workspace-service-non-git-')
const missingDir = path.join(await makeTempDir('workspace-service-missing-parent-'), 'missing')
const service = new WorkspaceService(async (sessionId) => {
if (sessionId === 'non-git') return nonGitDir
if (sessionId === 'missing') return missingDir
return null
})
await expect(service.getStatus('unknown')).rejects.toThrow('Session not found: unknown')
await expect(service.getStatus('non-git')).resolves.toMatchObject({
state: 'ok',
workDir: nonGitDir,
repoName: path.basename(nonGitDir),
isGitRepo: false,
changedFiles: [],
})
await expect(service.getStatus('missing')).resolves.toMatchObject({
state: 'missing_workdir',
workDir: missingDir,
isGitRepo: false,
changedFiles: [],
})
})
it('reports session tool edits without requiring a git repository', async () => {
const nonGitDir = await makeTempDir('workspace-service-session-changes-')
await fs.mkdir(path.join(nonGitDir, 'src'))
await fs.writeFile(path.join(nonGitDir, 'src/App.jsx'), 'export default function App() { return <main>New</main> }\n')
const service = new WorkspaceService(
async () => nonGitDir,
async () => [{
id: 'assistant-1',
type: 'tool_use',
timestamp: new Date().toISOString(),
content: [{
type: 'tool_use',
name: 'Edit',
input: {
file_path: 'src/App.jsx',
old_string: 'export default function App() { return <main>Old</main> }\n',
new_string: 'export default function App() { return <main>New</main> }\n',
},
}],
}],
)
const status = await service.getStatus('session-1')
expect(status).toMatchObject({
state: 'ok',
workDir: nonGitDir,
isGitRepo: false,
changedFiles: [{
path: 'src/App.jsx',
status: 'modified',
additions: 1,
deletions: 1,
}],
})
const diff = await service.getDiff('session-1', 'src/App.jsx')
expect(diff.state).toBe('ok')
expect(diff.diff).toContain('diff --session a/src/App.jsx b/src/App.jsx')
expect(diff.diff).toContain('-export default function App() { return <main>Old</main> }')
expect(diff.diff).toContain('+export default function App() { return <main>New</main> }')
})
it('rejects traversal attempts for file, diff, and tree access', async () => {
const repoDir = await createGitWorkspace()
const service = new WorkspaceService(async () => repoDir)
await expect(service.readFile('session-1', '../outside.txt')).rejects.toThrow(/outside workspace/)
await expect(service.getDiff('session-1', '../outside.txt')).resolves.toMatchObject({
state: 'error',
path: '../outside.txt',
})
await expect(service.readTree('session-1', '../outside')).rejects.toThrow(/outside workspace/)
})
it('rejects symlink targets that escape the workspace root', async () => {
const workDir = await makeTempDir('workspace-service-symlink-')
const outsideDir = await makeTempDir('workspace-service-symlink-outside-')
const outsideFile = path.join(outsideDir, 'secret.txt')
await fs.writeFile(outsideFile, 'top secret\n')
await fs.symlink(outsideFile, path.join(workDir, 'escape.txt'))
const service = new WorkspaceService(async () => workDir)
await expect(service.readFile('session-1', 'escape.txt')).rejects.toThrow(/outside workspace/)
})
it('returns error for an untracked symlink that escapes the workspace root', async () => {
const repoDir = await makeTempDir('workspace-service-symlink-git-')
const outsideDir = await makeTempDir('workspace-service-symlink-git-outside-')
const outsideFile = path.join(outsideDir, 'secret.txt')
git(repoDir, 'init')
git(repoDir, 'config', 'user.email', 'workspace-service@example.com')
git(repoDir, 'config', 'user.name', 'Workspace Service')
await fs.writeFile(path.join(repoDir, 'tracked.txt'), 'tracked\n')
git(repoDir, 'add', 'tracked.txt')
git(repoDir, 'commit', '-m', 'initial')
await fs.writeFile(outsideFile, 'top secret\n')
await fs.symlink(outsideFile, path.join(repoDir, 'escape.txt'))
const service = new WorkspaceService(async () => repoDir)
const status = await service.getStatus('session-1')
expect(status.state).toBe('error')
expect(status.error).toMatch(/outside workspace/)
await expect(service.getDiff('session-1', 'escape.txt')).resolves.toMatchObject({
state: 'error',
path: 'escape.txt',
})
const diffOutcome = await service.getDiff('session-1', 'escape.txt')
expect(diffOutcome.error).toMatch(/outside workspace/)
})
it('returns explicit readFile states for text, binary, large, and missing targets', async () => {
const workDir = await makeTempDir('workspace-service-files-')
const service = new WorkspaceService(async () => workDir)
await fs.writeFile(path.join(workDir, 'note.ts'), 'export const answer = 42\n')
await fs.writeFile(path.join(workDir, 'binary.bin'), Buffer.from([0, 1, 2, 3]))
await fs.writeFile(path.join(workDir, 'image.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00]))
await fs.writeFile(
path.join(workDir, 'large-image.png'),
Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47]), Buffer.alloc(ONE_MIB + 1, 0xff)]),
)
await fs.writeFile(path.join(workDir, 'large.txt'), Buffer.alloc(ONE_MIB + 1, 'a'))
await fs.mkdir(path.join(workDir, 'folder'))
await expect(service.readFile('session-1', 'note.ts')).resolves.toMatchObject({
state: 'ok',
language: 'typescript',
size: 25,
content: 'export const answer = 42\n',
})
await expect(service.readFile('session-1', 'binary.bin')).resolves.toMatchObject({
state: 'binary',
language: 'binary',
size: 4,
})
await expect(service.readFile('session-1', 'image.png')).resolves.toMatchObject({
state: 'ok',
previewType: 'image',
language: 'image',
mimeType: 'image/png',
dataUrl: 'data:image/png;base64,iVBORwA=',
size: 5,
})
const largeImage = await service.readFile('session-1', 'large-image.png')
expect(largeImage).toMatchObject({
state: 'ok',
previewType: 'image',
language: 'image',
mimeType: 'image/png',
size: ONE_MIB + 5,
})
expect(largeImage.dataUrl).toStartWith('data:image/png;base64,')
await expect(service.readFile('session-1', 'large.txt')).resolves.toMatchObject({
state: 'ok',
previewType: 'text',
language: 'text',
size: ONE_MIB + 1,
readBytes: ONE_MIB,
truncated: true,
content: 'a'.repeat(ONE_MIB),
})
await expect(service.readFile('session-1', 'missing.txt')).resolves.toMatchObject({
state: 'missing',
})
await expect(service.readFile('session-1', 'folder')).resolves.toMatchObject({
state: 'missing',
})
})
it('lists a single directory level with dotfiles excluded and directories first', async () => {
const workDir = await makeTempDir('workspace-service-tree-')
const service = new WorkspaceService(async () => workDir)
await fs.mkdir(path.join(workDir, 'b-dir'))
await fs.mkdir(path.join(workDir, 'a-dir'))
await fs.mkdir(path.join(workDir, 'a-dir', 'inner'))
await fs.writeFile(path.join(workDir, 'a-dir', 'note.txt'), 'nested\n')
await fs.writeFile(path.join(workDir, 'z-file.txt'), 'root file\n')
await fs.writeFile(path.join(workDir, '.hidden.txt'), 'ignore\n')
await expect(service.readTree('session-1')).resolves.toMatchObject({
state: 'ok',
path: '',
entries: [
{ name: 'a-dir', path: 'a-dir', isDirectory: true },
{ name: 'b-dir', path: 'b-dir', isDirectory: true },
{ name: 'z-file.txt', path: 'z-file.txt', isDirectory: false },
],
})
await expect(service.readTree('session-1', 'a-dir')).resolves.toMatchObject({
state: 'ok',
path: 'a-dir',
entries: [
{ name: 'inner', path: 'a-dir/inner', isDirectory: true },
{ name: 'note.txt', path: 'a-dir/note.txt', isDirectory: false },
],
})
})
it('returns diffs for modified, added, deleted, and untracked files', async () => {
const repoDir = await createGitWorkspace()
const service = new WorkspaceService(async (sessionId) => sessionId === 'session-1' ? repoDir : null)
const modified = await service.getDiff('session-1', 'tracked.txt')
expect(modified.state).toBe('ok')
expect(modified.diff).toContain('tracked.txt')
expect(modified.diff.length).toBeGreaterThan(0)
const added = await service.getDiff('session-1', 'new.txt')
expect(added.state).toBe('ok')
expect(added.diff).toContain('new.txt')
expect(added.diff.length).toBeGreaterThan(0)
const deleted = await service.getDiff('session-1', 'deleted.txt')
expect(deleted.state).toBe('ok')
expect(deleted.diff).toContain('deleted.txt')
expect(deleted.diff.length).toBeGreaterThan(0)
const untracked = await service.getDiff('session-1', 'untracked.txt')
expect(untracked.state).toBe('ok')
expect(untracked.diff).toContain('untracked.txt')
expect(untracked.diff.length).toBeGreaterThan(0)
await expect(service.getDiff('session-1', 'clean.txt')).resolves.toMatchObject({
state: 'missing',
path: 'clean.txt',
})
const nonGitDir = await makeTempDir('workspace-service-diff-non-git-')
const nonGitService = new WorkspaceService(async () => nonGitDir)
await expect(nonGitService.getDiff('session-1', 'whatever.txt')).resolves.toMatchObject({
state: 'not_git_repo',
path: 'whatever.txt',
})
})
it('returns explicit error state when git status fails instead of ok-empty', async () => {
const repoDir = await createGitWorkspace()
const service = new WorkspaceService(async () => repoDir) as WorkspaceService & {
runGit: (workDir: string, args: string[]) => Promise<{
stdout: string
stderr: string
code: number
}>
}
service.runGit = async (workDir, args) => {
if (args[0] === 'rev-parse' && args[1] === '--show-toplevel') {
return { stdout: `${workDir}\n`, stderr: '', code: 0 }
}
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') {
return { stdout: 'main\n', stderr: '', code: 0 }
}
if (args[0] === 'status') {
return { stdout: '', stderr: 'fatal: synthetic git failure', code: 1 }
}
return { stdout: '', stderr: 'unexpected call', code: 1 }
}
await expect(service.getStatus('session-1')).resolves.toMatchObject({
state: 'error',
isGitRepo: true,
})
const result = await service.getStatus('session-1')
expect(result.state).toBe('error')
expect(result.changedFiles).toEqual([])
expect(result.error).toContain('Failed to read git status')
expect(result.error).toContain('synthetic git failure')
})
it('reads tracked diff stats in one bulk git call', async () => {
const repoDir = await makeTempDir('workspace-service-bulk-stats-')
await fs.writeFile(path.join(repoDir, 'a.txt'), 'a\n')
await fs.writeFile(path.join(repoDir, 'b.txt'), 'b\n')
const diffStatCalls: string[][] = []
const service = new WorkspaceService(async () => repoDir) as WorkspaceService & {
runGit: (workDir: string, args: string[]) => Promise<{
stdout: string
stderr: string
code: number
}>
}
service.runGit = async (_workDir, args) => {
if (args[0] === 'rev-parse' && args[1] === '--show-toplevel') {
return { stdout: `${repoDir}\n`, stderr: '', code: 0 }
}
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') {
return { stdout: 'main\n', stderr: '', code: 0 }
}
if (args[0] === 'status') {
return { stdout: ' M a.txt\0 M b.txt\0', stderr: '', code: 0 }
}
if (args[0] === 'diff' && args.includes('--numstat')) {
diffStatCalls.push(args)
return { stdout: '1\t0\ta.txt\n2\t3\tb.txt\n', stderr: '', code: 0 }
}
return { stdout: '', stderr: `unexpected git call: ${args.join(' ')}`, code: 1 }
}
const result = await service.getStatus('session-1')
expect(result.state).toBe('ok')
expect(diffStatCalls).toHaveLength(1)
expect(result.changedFiles).toEqual([
{ path: 'a.txt', oldPath: undefined, status: 'modified', additions: 1, deletions: 0 },
{ path: 'b.txt', oldPath: undefined, status: 'modified', additions: 2, deletions: 3 },
])
})
})

View File

@ -18,12 +18,18 @@ import { ApiError, errorResponse } from '../middleware/errorHandler.js'
import { getSlashCommands } from '../ws/handler.js'
import { getCommandName } from '../../commands.js'
import { getSkillDirCommands } from '../../skills/loadSkillsDir.js'
import { WorkspaceService } from '../services/workspaceService.js'
import {
executeSessionRewind,
previewSessionRewind,
type RewindTargetSelector,
} from '../services/sessionRewindService.js'
const workspaceService = new WorkspaceService(async (sessionId) => (
conversationService.getSessionWorkDir(sessionId) ||
await sessionService.getSessionWorkDir(sessionId)
), async (sessionId) => sessionService.getSessionMessages(sessionId))
export async function handleSessionsApi(
req: Request,
url: URL,
@ -109,6 +115,16 @@ export async function handleSessionsApi(
return await getSessionInspection(sessionId, url)
}
if (subResource === 'workspace') {
if (req.method !== 'GET') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
return await handleSessionWorkspaceRoute(sessionId, url, segments[4])
}
// Route to conversations handler if sub-resource is 'chat'
if (subResource === 'chat') {
// This is handled by the conversations API, but in case the router
@ -174,6 +190,36 @@ async function getSessionMessages(sessionId: string): Promise<Response> {
return Response.json({ messages })
}
async function handleSessionWorkspaceRoute(
sessionId: string,
url: URL,
workspaceResource?: string,
): Promise<Response> {
await requireSessionWorkspace(sessionId)
switch (workspaceResource) {
case 'status':
return Response.json(await workspaceService.getStatus(sessionId))
case 'tree':
return await runWorkspaceRequest(() => workspaceService.readTree(
sessionId,
url.searchParams.get('path') || '',
))
case 'file':
return await runWorkspaceRequest(() => workspaceService.readFile(
sessionId,
requireWorkspacePath(url, 'file'),
))
case 'diff':
return await runWorkspaceDiffRequest(() => workspaceService.getDiff(
sessionId,
requireWorkspacePath(url, 'diff'),
))
default:
throw ApiError.notFound(`Unknown workspace resource: ${workspaceResource || 'workspace'}`)
}
}
async function createSession(req: Request): Promise<Response> {
let body: { workDir?: string }
try {
@ -190,6 +236,61 @@ async function createSession(req: Request): Promise<Response> {
return Response.json(result, { status: 201 })
}
async function requireSessionWorkspace(sessionId: string): Promise<string> {
const workDir =
conversationService.getSessionWorkDir(sessionId) ||
await sessionService.getSessionWorkDir(sessionId)
if (!workDir) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
return workDir
}
function requireWorkspacePath(url: URL, route: 'file' | 'diff'): string {
const filePath = url.searchParams.get('path')
if (!filePath) {
throw ApiError.badRequest(`path query parameter is required for workspace ${route}`)
}
return filePath
}
async function runWorkspaceRequest<T>(operation: () => Promise<T>): Promise<Response> {
try {
return Response.json(await operation())
} catch (error) {
if (isOutsideWorkspaceError(error)) {
throw new ApiError(403, error.message, 'FORBIDDEN')
}
if (isSessionNotFoundError(error)) {
throw ApiError.notFound(error.message)
}
throw error
}
}
async function runWorkspaceDiffRequest<T extends { state?: string; error?: string }>(
operation: () => Promise<T>,
): Promise<Response> {
const result = await runWorkspaceRequest(operation)
const body = await result.clone().json() as T
if (body.state === 'error' && typeof body.error === 'string' && body.error.includes('outside workspace')) {
throw new ApiError(403, body.error, 'FORBIDDEN')
}
return result
}
function isOutsideWorkspaceError(error: unknown): error is Error {
return error instanceof Error && error.message.includes('outside workspace')
}
function isSessionNotFoundError(error: unknown): error is Error {
return error instanceof Error && error.message.startsWith('Session not found:')
}
async function deleteSession(sessionId: string): Promise<Response> {
await sessionService.deleteSession(sessionId)
return Response.json({ ok: true })

View File

@ -15,6 +15,7 @@ import { handleProxyRequest } from './proxy/handler.js'
import { ProviderService } from './services/providerService.js'
import { handleHahaOAuthCallback } from './api/haha-oauth.js'
import { ensureDesktopCliLauncherInstalled } from './services/desktopCliLauncherService.js'
import { enableConfigs } from '../utils/config.js'
function readArgValue(flag: string): string | undefined {
const args = process.argv.slice(2)
@ -46,6 +47,7 @@ const PORT = SERVER_OPTIONS.port
const HOST = SERVER_OPTIONS.host
export function startServer(port = PORT, host = HOST) {
enableConfigs()
ProviderService.setServerPort(port)
const localConnectHost =
host === '0.0.0.0' || host === '127.0.0.1' || host === 'localhost'

View File

@ -215,6 +215,19 @@ function getBackupFileNameFirstVersion(
return undefined
}
function getBackupFileNameForTarget(
trackingPath: string,
snapshots: FileHistorySnapshot[],
targetSnapshot: FileHistorySnapshot,
): string | null | undefined {
const targetBackup = targetSnapshot.trackedFileBackups[trackingPath]
if (targetBackup && 'backupFileName' in targetBackup) {
return targetBackup.backupFileName
}
return getBackupFileNameFirstVersion(trackingPath, snapshots)
}
async function readFileOrNull(filePath: string): Promise<string | null> {
try {
return await readFile(filePath, 'utf-8')
@ -223,6 +236,12 @@ async function readFileOrNull(filePath: string): Promise<string | null> {
}
}
function countInsertedLines(content: string): number {
return diffLines('', content).reduce((total, change) => (
change.added ? total + (change.count || 0) : total
), 0)
}
async function hasFileChanged(
filePath: string,
backupFilePath: string,
@ -265,7 +284,7 @@ async function restoreBackupFile(
async function buildCodePreview(
sessionId: string,
workDir: string,
checkpointBaseDir: string,
targetUserMessageId: string,
): Promise<{
snapshots: FileHistorySnapshot[] | null
@ -305,21 +324,21 @@ async function buildCodePreview(
let deletions = 0
for (const trackingPath of trackedPaths) {
const targetBackup = targetSnapshot.trackedFileBackups[trackingPath]
const backupFileName =
targetBackup?.backupFileName ??
getBackupFileNameFirstVersion(trackingPath, snapshots)
const backupFileName = getBackupFileNameForTarget(
trackingPath,
snapshots,
targetSnapshot,
)
if (backupFileName === undefined) continue
const absolutePath = expandTrackingPath(workDir, trackingPath)
const absolutePath = expandTrackingPath(checkpointBaseDir, trackingPath)
if (backupFileName === null) {
try {
await stat(absolutePath)
const currentContent = await readFileOrNull(absolutePath)
if (currentContent !== null) {
filesChanged.push(absolutePath)
} catch {
// File is already absent.
insertions += countInsertedLines(currentContent)
}
continue
}
@ -365,9 +384,12 @@ export async function previewSessionRewind(
: null) ||
(await sessionService.getSessionWorkDir(sessionId)) ||
process.cwd()
const checkpointBaseDir =
(await sessionService.getSessionMessageCwd(sessionId, target.targetUserMessageId)) ||
workDir
const { preview } = await buildCodePreview(
sessionId,
workDir,
checkpointBaseDir,
target.targetUserMessageId,
)
@ -395,9 +417,12 @@ export async function executeSessionRewind(
: null) ||
(await sessionService.getSessionWorkDir(sessionId)) ||
process.cwd()
const checkpointBaseDir =
(await sessionService.getSessionMessageCwd(sessionId, target.targetUserMessageId)) ||
workDir
const { snapshots, preview } = await buildCodePreview(
sessionId,
workDir,
checkpointBaseDir,
target.targetUserMessageId,
)
@ -412,14 +437,15 @@ export async function executeSessionRewind(
}
for (const trackingPath of collectTrackedPaths(snapshots)) {
const targetBackup = targetSnapshot.trackedFileBackups[trackingPath]
const backupFileName =
targetBackup?.backupFileName ??
getBackupFileNameFirstVersion(trackingPath, snapshots)
const backupFileName = getBackupFileNameForTarget(
trackingPath,
snapshots,
targetSnapshot,
)
if (backupFileName === undefined) continue
const absolutePath = expandTrackingPath(workDir, trackingPath)
const absolutePath = expandTrackingPath(checkpointBaseDir, trackingPath)
if (backupFileName === null) {
try {

View File

@ -1224,6 +1224,18 @@ export class SessionService {
return this.resolveWorkDirFromEntries(entries, found.projectDir)
}
async getSessionMessageCwd(
sessionId: string,
messageId: string,
): Promise<string | null> {
const found = await this.findSessionFile(sessionId)
if (!found) return null
const entries = await this.readJsonlFile(found.filePath)
const entry = entries.find((candidate) => candidate.uuid === messageId)
return typeof entry?.cwd === 'string' && entry.cwd.trim() ? entry.cwd : null
}
/**
* Inspect how a session should be launched.
* Placeholder desktop-created sessions have zero transcript messages.

File diff suppressed because it is too large Load Diff