feat: make session workspace changes inspectable

Expose a workspace inspector for desktop sessions so users can browse the active project, preview files, inspect session-derived changes, and review diffs even when the work directory is not a git repository.

Constraint: The workspace view must work for temporary and non-git project directories.
Rejected: Rely only on git status | non-git sessions would lose the changed-files surface.
Confidence: medium
Scope-risk: moderate
Directive: Keep undo semantics separate from workspace browsing; checkpoint-backed rewind should remain the source of truth for rollback.
Tested: bun test src/server/__tests__/sessions.test.ts src/server/__tests__/workspace-service.test.ts
Tested: cd desktop && bun run test -- src/components/workspace/WorkspacePanel.test.tsx src/stores/workspacePanelStore.test.ts
Tested: cd desktop && bun run lint
Tested: git diff --check
Not-tested: Full desktop packaged app build after this commit
This commit is contained in:
程序员阿江(Relakkes) 2026-04-29 17:28:53 +08:00
parent 48ae305cbd
commit b811ffb8f1
21 changed files with 6064 additions and 161 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

@ -139,6 +139,80 @@ 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
error?: string
}
export type WorkspaceTreeEntry = {
name: string
path: string
isDirectory: boolean
}
export type WorkspaceTreeResult = {
state: 'ok' | 'missing' | 'error'
path: string
entries: WorkspaceTreeEntry[]
error?: string
}
export type WorkspaceDiffResult = {
state: 'ok' | 'missing' | 'not_git_repo' | 'error'
path: string
diff?: string
error?: string
}
function buildWorkspacePath(
sessionId: string,
resource: 'status' | 'tree' | 'file' | 'diff',
workspacePath?: string,
) {
const query = new URLSearchParams()
if (typeof workspacePath === 'string' && workspacePath.length > 0) {
query.set('path', workspacePath)
}
const qs = query.toString()
return `/api/sessions/${sessionId}/workspace/${resource}${qs ? `?${qs}` : ''}`
}
export const sessionsApi = {
list(params?: { project?: string; limit?: number; offset?: number }) {
const query = new URLSearchParams()
@ -187,6 +261,22 @@ export const sessionsApi = {
})
},
getWorkspaceStatus(sessionId: string) {
return api.get<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

@ -36,9 +36,10 @@ type Attachment = {
type ChatInputProps = {
variant?: 'default' | 'hero'
compact?: boolean
}
export function ChatInput({ variant = 'default' }: ChatInputProps) {
export function ChatInput({ variant = 'default', compact = false }: ChatInputProps) {
const t = useTranslation()
const [input, setInput] = useState('')
const [attachments, setAttachments] = useState<Attachment[]>([])
@ -72,7 +73,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
const isActive = chatState !== 'idle'
const isWorkspaceMissing = activeSession?.workDirExists === false
const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || (!isMemberSession && attachments.length > 0))
const isHeroComposer = variant === 'hero' && !isMemberSession
const isHeroComposer = variant === 'hero' && !isMemberSession && !compact
const resolvedWorkDir = activeSession?.workDir || gitInfo?.workDir || undefined
useEffect(() => {
@ -517,12 +518,30 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
const slashCommandsLabel = isHeroComposer ? t('empty.slashCommands') : t('chat.slashCommands')
return (
<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}
>
@ -637,14 +656,18 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
placeholder={composerPlaceholder}
disabled={isWorkspaceMissing}
rows={1}
className="w-full resize-none bg-transparent py-2 pb-12 text-sm leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] disabled:opacity-50"
className={`w-full resize-none bg-transparent text-sm leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] disabled:opacity-50 ${
compact ? 'py-1.5 pb-11' : 'py-2 pb-12'
}`}
/>
)}
<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 +702,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 +734,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 +743,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

@ -106,6 +106,7 @@ export function buildRenderModel(messages: UIMessage[]): RenderModel {
type MessageListProps = {
sessionId?: string | null
compact?: boolean
}
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 48
@ -117,7 +118,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) =>
@ -291,9 +292,9 @@ export function MessageList({ sessionId }: MessageListProps = {}) {
<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 (

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

@ -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,6 +1,7 @@
import { forwardRef, useRef, useState, useEffect, useCallback } from 'react'
import { useTabStore, type Tab } from '../../stores/tabStore'
import { useChatStore } from '../../stores/chatStore'
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
import { useTranslation } from '../../i18n'
import { WindowControls, showWindowControls } from './WindowControls'
@ -14,6 +15,11 @@ export function TabBar() {
const setActiveTab = useTabStore((s) => s.setActiveTab)
const closeTab = useTabStore((s) => s.closeTab)
const disconnectSession = useChatStore((s) => s.disconnectSession)
const activeTab = tabs.find((tab) => tab.sessionId === activeTabId) ?? null
const isActiveSessionTab = activeTab?.type === 'session'
const isWorkspacePanelOpen = useWorkspacePanelStore((state) =>
activeTabId && isActiveSessionTab ? state.isPanelOpen(activeTabId) : false,
)
const moveTab = useTabStore((s) => s.moveTab)
const scrollRef = useRef<HTMLDivElement>(null)
@ -74,12 +80,19 @@ export function TabBar() {
el.scrollBy({ left: direction === 'left' ? -TAB_WIDTH : TAB_WIDTH, behavior: 'smooth' })
}
const closeTabWithCleanup = useCallback((tab: Tab) => {
if (tab.type === 'session') {
useWorkspacePanelStore.getState().clearSession(tab.sessionId)
}
closeTab(tab.sessionId)
}, [closeTab])
const handleClose = (sessionId: string) => {
// Special tabs can always be closed directly
const tab = tabs.find((t) => t.sessionId === sessionId)
if (!tab) return
if (tab.type !== 'session') {
closeTab(sessionId)
closeTabWithCleanup(tab)
return
}
@ -92,7 +105,7 @@ export function TabBar() {
}
disconnectSession(sessionId)
closeTab(sessionId)
closeTabWithCleanup(tab)
}
const handleContextMenu = (e: React.MouseEvent, sessionId: string) => {
@ -105,7 +118,7 @@ export function TabBar() {
const otherTabs = tabs.filter((t) => t.sessionId !== sessionId)
for (const tab of otherTabs) {
if (tab.type === 'session') disconnectSession(tab.sessionId)
closeTab(tab.sessionId)
closeTabWithCleanup(tab)
}
}
@ -115,7 +128,7 @@ export function TabBar() {
const leftTabs = tabs.slice(0, idx)
for (const tab of leftTabs) {
if (tab.type === 'session') disconnectSession(tab.sessionId)
closeTab(tab.sessionId)
closeTabWithCleanup(tab)
}
}
@ -125,7 +138,7 @@ export function TabBar() {
const rightTabs = tabs.slice(idx + 1)
for (const tab of rightTabs) {
if (tab.type === 'session') disconnectSession(tab.sessionId)
closeTab(tab.sessionId)
closeTabWithCleanup(tab)
}
}
@ -133,7 +146,7 @@ export function TabBar() {
setContextMenu(null)
for (const tab of tabs) {
if (tab.type === 'session') disconnectSession(tab.sessionId)
closeTab(tab.sessionId)
closeTabWithCleanup(tab)
}
}
@ -265,6 +278,21 @@ export function TabBar() {
))}
</div>
<div className="flex shrink-0 items-center gap-0.5 px-1.5">
<ToolbarIconButton
icon="terminal"
label={t('tabs.openTerminal')}
onClick={() => useTabStore.getState().openTerminalTab()}
/>
{isActiveSessionTab && activeTabId && (
<ToolbarIconButton
icon={isWorkspacePanelOpen ? 'folder_open' : 'folder'}
label={t(isWorkspacePanelOpen ? 'tabs.hideWorkspace' : 'tabs.showWorkspace')}
onClick={() => useWorkspacePanelStore.getState().togglePanel(activeTabId)}
/>
)}
</div>
{isTauri && (
<div
data-testid="tab-bar-drag-gutter"
@ -331,7 +359,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 +372,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 +456,25 @@ const TabItem = forwardRef<HTMLDivElement, {
)
})
TabItem.displayName = 'TabItem'
function ToolbarIconButton({
icon,
label,
onClick,
}: {
icon: string
label: string
onClick: () => void
}) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className="inline-flex h-7 w-7 items-center justify-center rounded-[var(--radius-sm)] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
<span className="material-symbols-outlined text-[16px]">{icon}</span>
</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

@ -0,0 +1,615 @@
// @vitest-environment jsdom
// @ts-expect-error jsdom is installed in this workspace without local type declarations
import { JSDOM } from 'jsdom'
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
if (typeof document === 'undefined') {
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
url: 'http://localhost/',
})
const { window } = dom
Object.assign(globalThis, {
window,
document: window.document,
navigator: window.navigator,
localStorage: window.localStorage,
HTMLElement: window.HTMLElement,
HTMLButtonElement: window.HTMLButtonElement,
MutationObserver: window.MutationObserver,
Node: window.Node,
Event: window.Event,
MouseEvent: window.MouseEvent,
KeyboardEvent: window.KeyboardEvent,
getComputedStyle: window.getComputedStyle.bind(window),
IS_REACT_ACT_ENVIRONMENT: true,
})
}
type WorkspaceApiMocks = {
getWorkspaceStatusMock: ReturnType<typeof vi.fn>
getWorkspaceTreeMock: ReturnType<typeof vi.fn>
getWorkspaceFileMock: ReturnType<typeof vi.fn>
getWorkspaceDiffMock: ReturnType<typeof vi.fn>
}
var mocks: WorkspaceApiMocks | undefined
function getMocks() {
if (!mocks) {
throw new Error('Workspace API mocks were not initialized')
}
return mocks
}
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 }
}
async function setWorkspaceState(
updater:
| ReturnType<typeof useWorkspacePanelStore.getInitialState>
| Parameters<typeof useWorkspacePanelStore.setState>[0],
) {
await act(() => {
useWorkspacePanelStore.setState(updater as Parameters<typeof useWorkspacePanelStore.setState>[0], true)
})
}
async function setSettingsState(
updater:
| ReturnType<typeof useSettingsStore.getInitialState>
| Parameters<typeof useSettingsStore.setState>[0],
) {
await act(() => {
useSettingsStore.setState(updater as Parameters<typeof useSettingsStore.setState>[0], true)
})
}
async function renderPanel(sessionId: string) {
let view!: ReturnType<typeof render>
await act(() => {
view = render(<WorkspacePanel sessionId={sessionId} />)
})
return view
}
async function clickElement(element: Element) {
await act(() => {
fireEvent.click(element)
})
}
vi.mock('../../api/sessions', () => ({
sessionsApi: (() => {
if (!mocks) {
mocks = {
getWorkspaceStatusMock: vi.fn(),
getWorkspaceTreeMock: vi.fn(),
getWorkspaceFileMock: vi.fn(),
getWorkspaceDiffMock: vi.fn(),
}
}
return {
getWorkspaceStatus: mocks.getWorkspaceStatusMock,
getWorkspaceTree: mocks.getWorkspaceTreeMock,
getWorkspaceFile: mocks.getWorkspaceFileMock,
getWorkspaceDiff: mocks.getWorkspaceDiffMock,
}
})(),
}))
import { useSettingsStore } from '../../stores/settingsStore'
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
import { WorkspacePanel } from './WorkspacePanel'
describe('WorkspacePanel', () => {
const workspaceInitialState = useWorkspacePanelStore.getInitialState()
const settingsInitialState = useSettingsStore.getInitialState()
beforeEach(async () => {
vi.clearAllMocks()
await setWorkspaceState(workspaceInitialState)
await setSettingsState({ ...settingsInitialState, locale: 'en' })
})
afterEach(async () => {
cleanup()
await setWorkspaceState(workspaceInitialState)
await setSettingsState(settingsInitialState)
vi.restoreAllMocks()
})
it('stays hidden when the panel is closed', async () => {
const view = await renderPanel('session-hidden')
expect(view.queryByTestId('workspace-panel')).toBeNull()
})
it('loads changed status on open and opens a diff preview from the changed view', async () => {
const statusRequest = deferred<{
state: 'ok'
workDir: string
repoName: string
branch: string
isGitRepo: true
changedFiles: Array<{
path: string
status: 'modified'
additions: number
deletions: number
}>
}>()
const diffRequest = deferred<{
state: 'ok'
path: string
diff: string
}>()
getMocks().getWorkspaceStatusMock.mockReturnValue(statusRequest.promise)
getMocks().getWorkspaceDiffMock.mockReturnValue(diffRequest.promise)
await act(() => {
useWorkspacePanelStore.getState().openPanel('session-changed')
})
const view = await renderPanel('session-changed')
expect(view.getByTestId('workspace-panel').style.maxWidth).toBe('calc(100% - 348px)')
await waitFor(() => {
expect(getMocks().getWorkspaceStatusMock).toHaveBeenCalledWith('session-changed')
})
await act(async () => {
statusRequest.resolve({
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [
{
path: 'src/app.ts',
status: 'modified',
additions: 4,
deletions: 1,
},
],
})
await statusRequest.promise
})
expect(view.getByPlaceholderText('Filter files...')).toBeTruthy()
await clickElement(await view.findByText('src/app.ts'))
await waitFor(() => {
expect(getMocks().getWorkspaceDiffMock).toHaveBeenCalledWith('session-changed', 'src/app.ts')
})
await act(async () => {
diffRequest.resolve({
state: 'ok',
path: 'src/app.ts',
diff: '@@ -1 +1 @@\n-console.log("old")\n+console.log("new")',
})
await diffRequest.promise
})
await waitFor(() => {
expect(view.getByTestId('workspace-code').textContent).toContain('console.log("new")')
})
expect(view.getAllByText('Diff').length).toBeGreaterThan(0)
})
it('renders transcript-derived changed files for non-git sessions', async () => {
getMocks().getWorkspaceStatusMock.mockResolvedValue({
state: 'ok',
workDir: '/tmp/non-git-session',
repoName: 'non-git-session',
branch: null,
isGitRepo: false,
changedFiles: [
{
path: 'src/app.ts',
status: 'modified',
additions: 1,
deletions: 1,
},
],
})
getMocks().getWorkspaceDiffMock.mockResolvedValue({
state: 'ok',
path: 'src/app.ts',
diff: 'diff --session a/src/app.ts b/src/app.ts\n-export const answer = 1\n+export const answer = 2',
})
await act(() => {
useWorkspacePanelStore.getState().openPanel('session-non-git')
})
const view = await renderPanel('session-non-git')
await waitFor(() => {
expect(view.getByText('src/app.ts')).toBeTruthy()
})
expect(view.queryByText('No matching files')).toBeNull()
await clickElement(view.getByText('src/app.ts'))
await waitFor(() => {
expect(getMocks().getWorkspaceDiffMock).toHaveBeenCalledWith('session-non-git', 'src/app.ts')
})
await waitFor(() => {
expect(view.getByTestId('workspace-code').textContent).toContain('export const answer = 2')
})
})
it('lazy loads the root tree, expands directories, and opens file previews from the all-files view', async () => {
const statusRequest = deferred<{
state: 'ok'
workDir: string
repoName: string
branch: string
isGitRepo: true
changedFiles: []
}>()
const rootTreeRequest = deferred<{
state: 'ok'
path: ''
entries: Array<{ name: string; path: string; isDirectory: boolean }>
}>()
const childTreeRequest = deferred<{
state: 'ok'
path: 'src'
entries: Array<{ name: string; path: string; isDirectory: boolean }>
}>()
const fileRequest = deferred<{
state: 'ok'
path: string
content: string
language: string
size: number
}>()
getMocks().getWorkspaceStatusMock.mockReturnValue(statusRequest.promise)
getMocks().getWorkspaceTreeMock
.mockReturnValueOnce(rootTreeRequest.promise)
.mockReturnValueOnce(childTreeRequest.promise)
getMocks().getWorkspaceFileMock.mockReturnValue(fileRequest.promise)
await act(() => {
useWorkspacePanelStore.getState().openPanel('session-tree')
})
const view = await renderPanel('session-tree')
expect(view.getByRole('button', { name: 'Changed files' })).toBeTruthy()
await clickElement(view.getByRole('button', { name: 'Changed files' }))
await clickElement(view.getByRole('menuitem', { name: 'All files' }))
await waitFor(() => {
expect(getMocks().getWorkspaceTreeMock).toHaveBeenCalledWith('session-tree', '')
})
await act(async () => {
statusRequest.resolve({
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
})
rootTreeRequest.resolve({
state: 'ok',
path: '',
entries: [
{ name: 'src', path: 'src', isDirectory: true },
{ name: 'README.md', path: 'README.md', isDirectory: false },
],
})
await Promise.all([statusRequest.promise, rootTreeRequest.promise])
})
const folderLabel = await view.findByText('src')
const folderButton = folderLabel.closest('button')
if (!folderButton) {
throw new Error('Expected src label to be rendered inside a folder button')
}
expect(folderButton.getAttribute('aria-expanded')).toBe('false')
await clickElement(folderButton)
await waitFor(() => {
expect(getMocks().getWorkspaceTreeMock).toHaveBeenCalledWith('session-tree', 'src')
})
await act(async () => {
childTreeRequest.resolve({
state: 'ok',
path: 'src',
entries: [{ name: 'index.ts', path: 'src/index.ts', isDirectory: false }],
})
await childTreeRequest.promise
})
await waitFor(() => {
expect(folderButton.getAttribute('aria-expanded')).toBe('true')
})
await clickElement(await view.findByText('index.ts'))
await waitFor(() => {
expect(getMocks().getWorkspaceFileMock).toHaveBeenCalledWith('session-tree', 'src/index.ts')
})
await act(async () => {
fileRequest.resolve({
state: 'ok',
path: 'src/index.ts',
content: 'export const ready = true',
language: 'typescript',
size: 25,
})
await fileRequest.promise
})
await waitFor(() => {
expect(view.getByTestId('workspace-code').textContent).toContain('export const ready = true')
})
expect(view.getAllByText('File').length).toBeGreaterThan(0)
})
it('renders multiple preview tabs and closes only the exact requested tab', async () => {
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-tabs': {
isOpen: true,
activeView: 'changed',
},
},
statusBySession: {
...state.statusBySession,
'session-tabs': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
previewTabsBySession: {
...state.previewTabsBySession,
'session-tabs': [
{
id: 'file:src/a.ts',
path: 'src/a.ts',
kind: 'file',
title: 'a.ts',
language: 'typescript',
content: 'export const a = 1',
state: 'ok',
size: 18,
},
{
id: 'diff:src/a.ts',
path: 'src/a.ts',
kind: 'diff',
title: 'a.ts',
diff: '@@ -1 +1 @@',
state: 'ok',
},
{
id: 'file:src/b.ts',
path: 'src/b.ts',
kind: 'file',
title: 'b.ts',
language: 'typescript',
content: 'export const b = 1',
state: 'ok',
size: 18,
},
],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
'session-tabs': 'diff:src/a.ts',
},
}))
const view = await renderPanel('session-tabs')
expect(view.getByRole('tablist', { name: 'Preview tabs' })).toBeTruthy()
expect(view.getAllByRole('tab', { name: /a\.ts/ })).toHaveLength(2)
expect(view.getAllByText('a.ts').length).toBeGreaterThanOrEqual(2)
expect(view.getAllByText('b.ts').length).toBeGreaterThanOrEqual(1)
await clickElement(view.getByLabelText('Close tab a.ts Diff'))
expect(view.queryByLabelText('Close tab a.ts Diff')).toBeNull()
expect(view.getByLabelText('Close tab a.ts File')).toBeTruthy()
expect(view.getAllByText('b.ts').length).toBeGreaterThanOrEqual(1)
})
it('caps rendered preview lines to keep large diffs responsive', async () => {
const longDiff = Array.from({ length: 650 }, (_, index) => `+line ${index + 1}`).join('\n')
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-large-preview': {
isOpen: true,
activeView: 'changed',
},
},
statusBySession: {
...state.statusBySession,
'session-large-preview': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
previewTabsBySession: {
...state.previewTabsBySession,
'session-large-preview': [{
id: 'diff:large.ts',
path: 'large.ts',
kind: 'diff',
title: 'large.ts',
diff: longDiff,
state: 'ok',
}],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
'session-large-preview': 'diff:large.ts',
},
}))
const view = await renderPanel('session-large-preview')
const highlightedCode = view.getByTestId('workspace-code').textContent ?? ''
expect(highlightedCode).toContain('+line 1')
expect(highlightedCode).toContain('+line 420')
expect(highlightedCode).not.toContain('+line 421')
expect(view.getByText('Showing first 420 lines. Open in your editor for the full file.')).toBeTruthy()
})
it('renders image previews from workspace files', async () => {
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-image-preview': {
isOpen: true,
activeView: 'all',
},
},
treeBySessionPath: {
...state.treeBySessionPath,
'session-image-preview': {
'': {
state: 'ok',
path: '',
entries: [{ name: 'logo.png', path: 'logo.png', isDirectory: false }],
},
},
},
}))
getMocks().getWorkspaceFileMock.mockResolvedValue({
state: 'ok',
path: 'logo.png',
previewType: 'image',
dataUrl: 'data:image/png;base64,iVBORw0KGgo=',
mimeType: 'image/png',
language: 'image',
size: 8,
})
const view = await renderPanel('session-image-preview')
await clickElement(await view.findByText('logo.png'))
const image = await view.findByRole('img', { name: 'logo.png' })
expect(image.getAttribute('src')).toBe('data:image/png;base64,iVBORw0KGgo=')
})
it('uses the localized view menu label', async () => {
await setSettingsState({ ...settingsInitialState, locale: 'zh' })
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-zh': {
isOpen: true,
activeView: 'changed',
},
},
statusBySession: {
...state.statusBySession,
'session-zh': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
}))
const view = await renderPanel('session-zh')
expect(view.getByRole('button', { name: '已更改文件' })).toBeTruthy()
})
it('shows explicit empty and error states in the changed view', async () => {
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-empty': {
isOpen: true,
activeView: 'changed',
},
},
statusBySession: {
...state.statusBySession,
'session-empty': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
}))
const view = await renderPanel('session-empty')
expect(view.getByText('No changes')).toBeTruthy()
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-error': {
isOpen: true,
activeView: 'changed',
},
},
errors: {
...state.errors,
statusBySession: {
...state.errors.statusBySession,
'session-error': 'status failed',
},
},
}))
await act(() => {
view.rerender(<WorkspacePanel sessionId="session-error" />)
})
expect(view.getByText('status failed')).toBeTruthy()
})
})

File diff suppressed because it is too large Load Diff

View File

@ -41,6 +41,34 @@ export const en = {
'titlebar.terminal': 'Terminal',
'titlebar.history': 'History',
// ─── Workspace Panel ───────────────────────────────
'workspace.changedFiles': 'Changed files',
'workspace.allFiles': 'All files',
'workspace.viewTabs': 'Workspace views',
'workspace.previewTabs': 'Preview tabs',
'workspace.filterPlaceholder': 'Filter files...',
'workspace.clearFilter': 'Clear file filter',
'workspace.refresh': 'Refresh workspace',
'workspace.closePanel': 'Close workspace panel',
'workspace.resizePanel': 'Resize workspace panel',
'workspace.closeTab': 'Close tab',
'workspace.preview': 'Preview',
'workspace.previewEmpty': 'Select a file to preview.',
'workspace.notGitRepo': 'Not a git repository.',
'workspace.missingWorkdir': 'Working directory is missing.',
'workspace.loadError': 'Failed to load workspace data.',
'workspace.noChanges': 'No changes',
'workspace.noFiles': 'No files',
'workspace.noMatchingFiles': 'No matching files',
'workspace.previewKind.diff': 'Diff',
'workspace.previewKind.file': 'File',
'workspace.previewState.loading': 'Loading preview...',
'workspace.previewState.binary': 'Binary file preview is unavailable.',
'workspace.previewState.tooLarge': 'File is too large to preview.',
'workspace.previewState.missing': 'File not found.',
'workspace.imagePreviewUnavailable': 'Image preview is unavailable.',
'workspace.previewLineLimit': 'Showing first {count} lines. Open in your editor for the full file.',
// ─── Status Bar ──────────────────────────────────────
'status.connected': 'Connected',
'status.connecting': 'Connecting...',
@ -962,6 +990,9 @@ export const en = {
'tabs.closeConfirmMessage': 'This session is still running. What would you like to do?',
'tabs.closeConfirmKeep': 'Keep Running',
'tabs.closeConfirmStop': 'Stop & Close',
'tabs.openTerminal': 'Open Terminal',
'tabs.showWorkspace': 'Show Workspace',
'tabs.hideWorkspace': 'Hide Workspace',
} as const
export type TranslationKey = keyof typeof en

View File

@ -43,6 +43,34 @@ 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} 行。完整内容请在编辑器中打开。',
// ─── Status Bar ──────────────────────────────────────
'status.connected': '已连接',
'status.connecting': '连接中...',
@ -964,4 +992,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,179 @@ describe('ActiveSession task polling', () => {
unmount()
useCLITaskStore.setState(originalCliTaskState)
})
it('renders the workspace panel to the right of chat and supports resizing', () => {
const sessionId = 'workspace-session'
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'Workspace Session',
createdAt: '2026-04-10T00:00:00.000Z',
modifiedAt: '2026-04-10T00:00:00.000Z',
messageCount: 1,
projectPath: '',
workDir: '/tmp/project',
workDirExists: true,
}],
activeSessionId: sessionId,
isLoading: false,
error: null,
})
useTabStore.setState({
tabs: [{ sessionId, title: 'Workspace Session', type: 'session', status: 'idle' }],
activeTabId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [{ id: 'msg-1', type: 'assistant_text', content: 'hello', timestamp: 1 }],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
useWorkspacePanelStore.getState().openPanel(sessionId)
render(<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('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_COMPACT_CLASS =
'w-[clamp(340px,23vw,420px)] min-w-[340px] max-w-[420px] shrink-0 border-r border-[var(--color-border)] bg-[var(--color-surface)]'
function WorkspaceResizeHandle() {
const t = useTranslation()
const width = useWorkspacePanelStore((state) => state.width)
const setWidth = useWorkspacePanelStore((state) => state.setWidth)
const [dragState, setDragState] = useState<{ startX: number; startWidth: number } | null>(null)
const dragStateRef = useRef(dragState)
useEffect(() => {
dragStateRef.current = dragState
}, [dragState])
useEffect(() => {
if (!dragState) return
const handlePointerMove = (event: PointerEvent) => {
const current = dragStateRef.current
if (!current) return
setWidth(current.startWidth + current.startX - event.clientX)
}
const handlePointerUp = () => {
setDragState(null)
}
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointerup', handlePointerUp)
window.addEventListener('pointercancel', handlePointerUp)
return () => {
document.body.style.cursor = ''
document.body.style.userSelect = ''
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', handlePointerUp)
window.removeEventListener('pointercancel', handlePointerUp)
}
}, [dragState, setWidth])
return (
<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_COMPACT_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

@ -0,0 +1,520 @@
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('captures workspace status request errors', async () => {
mocks.getWorkspaceStatusMock.mockRejectedValue(new Error('status failed'))
await useWorkspacePanelStore.getState().loadStatus('session-err')
expect(useWorkspacePanelStore.getState().statusBySession['session-err']).toBeUndefined()
expect(useWorkspacePanelStore.getState().loading.statusBySession['session-err']).toBe(false)
expect(useWorkspacePanelStore.getState().errors.statusBySession['session-err']).toBe('status failed')
})
it('lazily loads tree nodes when expanding and reuses cached results', async () => {
mocks.getWorkspaceTreeMock.mockResolvedValue({
state: 'ok',
path: '',
entries: [
{ name: 'src', path: 'src', isDirectory: true },
{ name: 'README.md', path: 'README.md', isDirectory: false },
],
})
await useWorkspacePanelStore.getState().toggleTreeNode('session-tree', '')
expect(mocks.getWorkspaceTreeMock).toHaveBeenCalledTimes(1)
expect(mocks.getWorkspaceTreeMock).toHaveBeenCalledWith('session-tree', '')
expect(useWorkspacePanelStore.getState().expandedPathsBySession['session-tree']).toEqual([''])
expect(useWorkspacePanelStore.getState().treeBySessionPath['session-tree']?.['']).toMatchObject({
entries: [{ path: 'src' }, { path: 'README.md' }],
})
await useWorkspacePanelStore.getState().toggleTreeNode('session-tree', '')
expect(useWorkspacePanelStore.getState().expandedPathsBySession['session-tree']).toEqual([])
await useWorkspacePanelStore.getState().toggleTreeNode('session-tree', '')
expect(mocks.getWorkspaceTreeMock).toHaveBeenCalledTimes(1)
expect(useWorkspacePanelStore.getState().expandedPathsBySession['session-tree']).toEqual([''])
})
it('ignores stale tree responses for the same session path', async () => {
const first = deferred<{ state: 'ok'; path: string; entries: Array<{ name: string; path: string; isDirectory: boolean }> }>()
const second = deferred<{ state: 'ok'; path: string; entries: Array<{ name: string; path: string; isDirectory: boolean }> }>()
mocks.getWorkspaceTreeMock
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
const firstLoad = useWorkspacePanelStore.getState().loadTree('session-tree-race', 'src')
const secondLoad = useWorkspacePanelStore.getState().loadTree('session-tree-race', 'src')
second.resolve({
state: 'ok',
path: 'src',
entries: [{ name: 'new.ts', path: 'src/new.ts', isDirectory: false }],
})
await secondLoad
first.resolve({
state: 'ok',
path: 'src',
entries: [{ name: 'old.ts', path: 'src/old.ts', isDirectory: false }],
})
await firstLoad
expect(useWorkspacePanelStore.getState().treeBySessionPath['session-tree-race']?.src?.entries).toEqual([
{ name: 'new.ts', path: 'src/new.ts', isDirectory: false },
])
})
it('opens preview tabs, supports multiple kinds, and reuses duplicates without persistence', async () => {
const storage = typeof globalThis.localStorage === 'undefined' ? null : globalThis.localStorage
const setItemSpy = storage ? vi.spyOn(storage, 'setItem') : null
mocks.getWorkspaceFileMock.mockResolvedValue({
state: 'ok',
path: 'src/a.ts',
content: 'export const a = 1',
language: 'typescript',
size: 18,
})
mocks.getWorkspaceDiffMock.mockResolvedValue({
state: 'ok',
path: 'src/a.ts',
diff: '@@ -1 +1 @@',
})
useWorkspacePanelStore.getState().openPanel('session-preview')
await useWorkspacePanelStore.getState().openPreview('session-preview', 'src/a.ts', 'file')
await useWorkspacePanelStore.getState().openPreview('session-preview', 'src/a.ts', 'diff')
await useWorkspacePanelStore.getState().openPreview('session-preview', 'src/a.ts', 'file')
expect(mocks.getWorkspaceFileMock).toHaveBeenCalledTimes(1)
expect(mocks.getWorkspaceDiffMock).toHaveBeenCalledTimes(1)
const tabs = useWorkspacePanelStore.getState().previewTabsBySession['session-preview']
expect(tabs).toBeDefined()
expect(tabs).toHaveLength(2)
expect(tabs![0]).toMatchObject({
id: 'file:src/a.ts',
kind: 'file',
path: 'src/a.ts',
content: 'export const a = 1',
language: 'typescript',
})
expect(tabs![1]).toMatchObject({
id: 'diff:src/a.ts',
kind: 'diff',
path: 'src/a.ts',
diff: '@@ -1 +1 @@',
})
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview']).toBe('file:src/a.ts')
if (setItemSpy) {
expect(setItemSpy).not.toHaveBeenCalled()
} else {
expect(storage).toBeNull()
}
})
it('closes exact tab id and preserves sibling preview for the same path', async () => {
mocks.getWorkspaceFileMock.mockResolvedValue({
state: 'ok',
path: 'src/a.ts',
content: 'export const a = 1',
language: 'typescript',
size: 18,
})
mocks.getWorkspaceDiffMock.mockResolvedValue({
state: 'ok',
path: 'src/a.ts',
diff: '@@ -1 +1 @@',
})
await useWorkspacePanelStore.getState().openPreview('session-close-path', 'src/a.ts', 'file')
await useWorkspacePanelStore.getState().openPreview('session-close-path', 'src/a.ts', 'diff')
useWorkspacePanelStore.getState().closePreview('session-close-path', 'diff:src/a.ts')
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-close-path']).toEqual([
expect.objectContaining({ id: 'file:src/a.ts' }),
])
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-close-path']).toBe('file:src/a.ts')
})
it('chooses the next sensible active preview when closing the current tab', async () => {
mocks.getWorkspaceFileMock
.mockResolvedValueOnce({
state: 'ok',
path: 'src/a.ts',
content: 'a',
language: 'typescript',
size: 1,
})
.mockResolvedValueOnce({
state: 'ok',
path: 'src/b.ts',
content: 'b',
language: 'typescript',
size: 1,
})
.mockResolvedValueOnce({
state: 'ok',
path: 'src/c.ts',
content: 'c',
language: 'typescript',
size: 1,
})
await useWorkspacePanelStore.getState().openPreview('session-close', 'src/a.ts', 'file')
await useWorkspacePanelStore.getState().openPreview('session-close', 'src/b.ts', 'file')
await useWorkspacePanelStore.getState().openPreview('session-close', 'src/c.ts', 'file')
useWorkspacePanelStore.getState().closePreview('session-close', 'file:src/b.ts')
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-close']).toMatchObject([
{ id: 'file:src/a.ts' },
{ id: 'file:src/c.ts' },
])
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-close']).toBe('file:src/c.ts')
useWorkspacePanelStore.getState().closePreview('session-close', 'file:src/c.ts')
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-close']).toBe('file:src/a.ts')
})
it('ignores stale status responses for the same session', async () => {
const first = deferred<{
state: 'ok'
workDir: string
repoName: string | null
branch: string | null
isGitRepo: boolean
changedFiles: []
error?: string
}>()
const second = deferred<{
state: 'ok'
workDir: string
repoName: string | null
branch: string | null
isGitRepo: boolean
changedFiles: []
error?: string
}>()
mocks.getWorkspaceStatusMock
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
const firstLoad = useWorkspacePanelStore.getState().loadStatus('session-race')
const secondLoad = useWorkspacePanelStore.getState().loadStatus('session-race')
second.resolve({
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'new',
isGitRepo: true,
changedFiles: [],
})
await secondLoad
first.resolve({
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'old',
isGitRepo: true,
changedFiles: [],
})
await firstLoad
expect(useWorkspacePanelStore.getState().statusBySession['session-race']?.branch).toBe('new')
})
it('ignores stale preview responses after close and reopen', async () => {
const first = deferred<{ state: 'ok'; path: string; content: string; language: string; size: number }>()
const second = deferred<{ state: 'ok'; path: string; content: string; language: string; size: number }>()
mocks.getWorkspaceFileMock
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
const firstOpen = useWorkspacePanelStore.getState().openPreview('session-preview-race', 'src/a.ts', 'file')
useWorkspacePanelStore.getState().closePreview('session-preview-race', 'file:src/a.ts')
const secondOpen = useWorkspacePanelStore.getState().openPreview('session-preview-race', 'src/a.ts', 'file')
second.resolve({
state: 'ok',
path: 'src/a.ts',
content: 'new',
language: 'typescript',
size: 3,
})
await secondOpen
first.resolve({
state: 'ok',
path: 'src/a.ts',
content: 'old',
language: 'typescript',
size: 3,
})
await firstOpen
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-preview-race']).toEqual([
expect.objectContaining({ id: 'file:src/a.ts', content: 'new' }),
])
})
it('does not recreate loading or error state when a loading preview is closed before completion', async () => {
const pending = deferred<{ state: 'ok'; path: string; diff: string }>()
const tabId = getWorkspacePreviewTabId('src/a.ts', 'diff')
const previewKey = `session-preview-close::${tabId}`
mocks.getWorkspaceDiffMock.mockReturnValueOnce(pending.promise)
const openPromise = useWorkspacePanelStore.getState().openPreview('session-preview-close', 'src/a.ts', 'diff')
useWorkspacePanelStore.getState().closePreview('session-preview-close', tabId)
pending.resolve({
state: 'ok',
path: 'src/a.ts',
diff: '@@ -1 +1 @@',
})
await openPromise
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-preview-close']).toBeUndefined()
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview-close']).toBeNull()
expect(useWorkspacePanelStore.getState().loading.previewByTabId[previewKey]).toBeUndefined()
expect(useWorkspacePanelStore.getState().errors.previewByTabId[previewKey]).toBeUndefined()
})
it('clears session UI and cached data for clearSession and resetSessionUi', () => {
useWorkspacePanelStore.setState((state) => ({
...state,
panelBySession: {
'session-clear': { isOpen: true, activeView: 'all' },
'session-reset': { isOpen: true, activeView: 'all' },
},
statusBySession: {
'session-clear': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
'session-reset': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
expandedPathsBySession: {
'session-clear': ['src'],
'session-reset': ['src'],
},
treeBySessionPath: {
'session-clear': {
src: { state: 'ok', path: 'src', entries: [] },
},
'session-reset': {
src: { state: 'ok', path: 'src', entries: [] },
},
},
previewTabsBySession: {
'session-clear': [
{ id: 'file:src/a.ts', path: 'src/a.ts', kind: 'file', title: 'a.ts' },
],
'session-reset': [
{ id: 'file:src/b.ts', path: 'src/b.ts', kind: 'file', title: 'b.ts' },
],
},
activePreviewTabIdBySession: {
'session-clear': 'file:src/a.ts',
'session-reset': 'file:src/b.ts',
},
loading: {
statusBySession: {
'session-clear': true,
'session-reset': true,
},
treeBySessionPath: {
'session-clear::src': true,
'session-reset::src': true,
},
previewByTabId: {
'session-clear::file:src/a.ts': true,
'session-reset::file:src/b.ts': true,
},
},
errors: {
statusBySession: {
'session-clear': 'bad',
'session-reset': 'bad',
},
treeBySessionPath: {
'session-clear::src': 'bad',
'session-reset::src': 'bad',
},
previewByTabId: {
'session-clear::file:src/a.ts': 'bad',
'session-reset::file:src/b.ts': 'bad',
},
},
}))
useWorkspacePanelStore.getState().clearSession('session-clear')
useWorkspacePanelStore.getState().resetSessionUi('session-reset')
const state = useWorkspacePanelStore.getState()
expect(state.panelBySession['session-clear']).toBeUndefined()
expect(state.panelBySession['session-reset']).toBeUndefined()
expect(state.statusBySession['session-clear']).toBeUndefined()
expect(state.statusBySession['session-reset']).toBeUndefined()
expect(state.expandedPathsBySession['session-clear']).toBeUndefined()
expect(state.expandedPathsBySession['session-reset']).toBeUndefined()
expect(state.treeBySessionPath['session-clear']).toBeUndefined()
expect(state.treeBySessionPath['session-reset']).toBeUndefined()
expect(state.previewTabsBySession['session-clear']).toBeUndefined()
expect(state.previewTabsBySession['session-reset']).toBeUndefined()
expect(state.activePreviewTabIdBySession['session-clear']).toBeUndefined()
expect(state.activePreviewTabIdBySession['session-reset']).toBeUndefined()
expect(state.loading.statusBySession['session-clear']).toBeUndefined()
expect(state.loading.statusBySession['session-reset']).toBeUndefined()
expect(state.loading.treeBySessionPath['session-clear::src']).toBeUndefined()
expect(state.loading.treeBySessionPath['session-reset::src']).toBeUndefined()
expect(state.loading.previewByTabId['session-clear::file:src/a.ts']).toBeUndefined()
expect(state.loading.previewByTabId['session-reset::file:src/b.ts']).toBeUndefined()
expect(state.errors.statusBySession['session-clear']).toBeUndefined()
expect(state.errors.statusBySession['session-reset']).toBeUndefined()
expect(state.errors.treeBySessionPath['session-clear::src']).toBeUndefined()
expect(state.errors.treeBySessionPath['session-reset::src']).toBeUndefined()
expect(state.errors.previewByTabId['session-clear::file:src/a.ts']).toBeUndefined()
expect(state.errors.previewByTabId['session-reset::file:src/b.ts']).toBeUndefined()
})
})

View File

@ -0,0 +1,653 @@
import { create } from 'zustand'
import {
sessionsApi,
type WorkspaceDiffResult,
type WorkspaceReadFileResult,
type WorkspaceStatusResult,
type WorkspaceTreeResult,
} from '../api/sessions'
export const WORKSPACE_PANEL_DEFAULT_WIDTH = 860
export const WORKSPACE_PANEL_MIN_WIDTH = 640
export const WORKSPACE_PANEL_MAX_WIDTH = 1120
export type WorkspacePanelView = 'changed' | 'all'
export type WorkspacePreviewKind = 'file' | 'diff'
export type WorkspacePreviewState =
| 'loading'
| WorkspaceReadFileResult['state']
| WorkspaceDiffResult['state']
export type WorkspacePreviewTab = {
id: string
path: string
kind: WorkspacePreviewKind
title: string
language?: string
content?: string
dataUrl?: string
mimeType?: string
previewType?: 'text' | 'image'
diff?: string
state?: WorkspacePreviewState
error?: string
size?: number
}
export type WorkspacePanelSessionState = {
isOpen: boolean
activeView: WorkspacePanelView
}
type WorkspacePanelLoadingState = {
statusBySession: Record<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
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 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,
},
},
})),
loadStatus: async (sessionId) => {
const requestId = nextRequestId(statusRequestIds, sessionId)
set((state) => ({
loading: {
...state.loading,
statusBySession: {
...state.loading.statusBySession,
[sessionId]: true,
},
},
errors: {
...state.errors,
statusBySession: {
...state.errors.statusBySession,
[sessionId]: null,
},
},
}))
try {
const result = await sessionsApi.getWorkspaceStatus(sessionId)
if (!isLatestRequest(statusRequestIds, sessionId, requestId)) return
set((state) => ({
statusBySession: {
...state.statusBySession,
[sessionId]: result,
},
loading: {
...state.loading,
statusBySession: {
...state.loading.statusBySession,
[sessionId]: false,
},
},
errors: {
...state.errors,
statusBySession: {
...state.errors.statusBySession,
[sessionId]: result.error ?? null,
},
},
}))
} catch (error) {
if (!isLatestRequest(statusRequestIds, sessionId, requestId)) return
set((state) => ({
loading: {
...state.loading,
statusBySession: {
...state.loading.statusBySession,
[sessionId]: false,
},
},
errors: {
...state.errors,
statusBySession: {
...state.errors.statusBySession,
[sessionId]: error instanceof Error ? error.message : 'Failed to load workspace status',
},
},
}))
}
},
loadTree: async (sessionId, path = '') => {
const treeKey = makeTreeKey(sessionId, path)
const requestId = nextRequestId(treeRequestIds, treeKey)
set((state) => ({
loading: {
...state.loading,
treeBySessionPath: {
...state.loading.treeBySessionPath,
[treeKey]: true,
},
},
errors: {
...state.errors,
treeBySessionPath: {
...state.errors.treeBySessionPath,
[treeKey]: null,
},
},
}))
try {
const result = await sessionsApi.getWorkspaceTree(sessionId, path)
if (!isLatestRequest(treeRequestIds, treeKey, requestId)) return
set((state) => ({
treeBySessionPath: {
...state.treeBySessionPath,
[sessionId]: {
...state.treeBySessionPath[sessionId],
[path]: result,
},
},
loading: {
...state.loading,
treeBySessionPath: {
...state.loading.treeBySessionPath,
[treeKey]: false,
},
},
errors: {
...state.errors,
treeBySessionPath: {
...state.errors.treeBySessionPath,
[treeKey]: result.error ?? null,
},
},
}))
} catch (error) {
if (!isLatestRequest(treeRequestIds, treeKey, requestId)) return
set((state) => ({
loading: {
...state.loading,
treeBySessionPath: {
...state.loading.treeBySessionPath,
[treeKey]: false,
},
},
errors: {
...state.errors,
treeBySessionPath: {
...state.errors.treeBySessionPath,
[treeKey]: error instanceof Error ? error.message : 'Failed to load workspace tree',
},
},
}))
}
},
toggleTreeNode: async (sessionId, path) => {
let shouldLoad = false
set((state) => {
const expanded = new Set(state.expandedPathsBySession[sessionId] ?? [])
if (expanded.has(path)) {
expanded.delete(path)
} else {
expanded.add(path)
if (!state.treeBySessionPath[sessionId]?.[path]) {
shouldLoad = true
}
}
return {
expandedPathsBySession: {
...state.expandedPathsBySession,
[sessionId]: [...expanded],
},
}
})
if (shouldLoad) {
await get().loadTree(sessionId, path)
}
},
openPreview: async (sessionId, path, kind) => {
const tabId = getWorkspacePreviewTabId(path, kind)
const requestKey = makePreviewKey(sessionId, tabId)
const existing = get().previewTabsBySession[sessionId]?.find((tab) => tab.id === tabId)
if (existing) {
set((state) => ({
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
[sessionId]: tabId,
},
}))
return
}
const requestId = nextRequestId(previewRequestIds, requestKey)
const baseTab: WorkspacePreviewTab = {
id: tabId,
path,
kind,
title: getPathTitle(path),
state: 'loading',
}
set((state) => ({
previewTabsBySession: {
...state.previewTabsBySession,
[sessionId]: [...(state.previewTabsBySession[sessionId] ?? []), baseTab],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
[sessionId]: tabId,
},
loading: {
...state.loading,
previewByTabId: {
...state.loading.previewByTabId,
[requestKey]: true,
},
},
errors: {
...state.errors,
previewByTabId: {
...state.errors.previewByTabId,
[requestKey]: null,
},
},
}))
try {
if (kind === 'diff') {
const result = await sessionsApi.getWorkspaceDiff(sessionId, path)
if (!isLatestRequest(previewRequestIds, requestKey, requestId)) return
if (!get().previewTabsBySession[sessionId]?.some((tab) => tab.id === tabId)) return
set((state) => {
const tabs = state.previewTabsBySession[sessionId] ?? []
return {
previewTabsBySession: {
...state.previewTabsBySession,
[sessionId]: upsertPreviewTab(tabs, tabId, (current) => ({
...current,
diff: result.diff ?? '',
content: undefined,
language: undefined,
size: undefined,
state: result.state,
error: result.error,
})),
},
loading: {
...state.loading,
previewByTabId: {
...state.loading.previewByTabId,
[requestKey]: false,
},
},
errors: {
...state.errors,
previewByTabId: {
...state.errors.previewByTabId,
[requestKey]: result.error ?? null,
},
},
}
})
return
}
const result = await sessionsApi.getWorkspaceFile(sessionId, path)
if (!isLatestRequest(previewRequestIds, requestKey, requestId)) return
if (!get().previewTabsBySession[sessionId]?.some((tab) => tab.id === tabId)) return
set((state) => {
const tabs = state.previewTabsBySession[sessionId] ?? []
return {
previewTabsBySession: {
...state.previewTabsBySession,
[sessionId]: upsertPreviewTab(tabs, tabId, (current) => ({
...current,
content: result.content,
dataUrl: result.dataUrl,
mimeType: result.mimeType,
previewType: result.previewType ?? 'text',
diff: undefined,
language: result.language,
size: result.size,
state: result.state,
error: result.error,
})),
},
loading: {
...state.loading,
previewByTabId: {
...state.loading.previewByTabId,
[requestKey]: false,
},
},
errors: {
...state.errors,
previewByTabId: {
...state.errors.previewByTabId,
[requestKey]: result.error ?? null,
},
},
}
})
} catch (error) {
if (!isLatestRequest(previewRequestIds, requestKey, requestId)) return
if (!get().previewTabsBySession[sessionId]?.some((tab) => tab.id === tabId)) return
set((state) => {
const tabs = state.previewTabsBySession[sessionId] ?? []
const message = error instanceof Error ? error.message : 'Failed to load workspace preview'
return {
previewTabsBySession: {
...state.previewTabsBySession,
[sessionId]: upsertPreviewTab(tabs, tabId, (current) => ({
...current,
state: 'error',
error: message,
})),
},
loading: {
...state.loading,
previewByTabId: {
...state.loading.previewByTabId,
[requestKey]: false,
},
},
errors: {
...state.errors,
previewByTabId: {
...state.errors.previewByTabId,
[requestKey]: message,
},
},
}
})
}
},
closePreview: (sessionId, tabId) => {
const requestKey = makePreviewKey(sessionId, tabId)
invalidateRequest(previewRequestIds, requestKey)
set((state) => {
const tabs = state.previewTabsBySession[sessionId] ?? []
const index = tabs.findIndex((tab) => tab.id === tabId)
if (index < 0) {
return {
loading: {
...state.loading,
previewByTabId: removeRecordKey(state.loading.previewByTabId, requestKey),
},
errors: {
...state.errors,
previewByTabId: removeRecordKey(state.errors.previewByTabId, requestKey),
},
}
}
const closingTab = tabs[index]!
const nextTabs = tabs.filter((tab) => tab.id !== closingTab.id)
const activeTabId = state.activePreviewTabIdBySession[sessionId] ?? null
let nextActiveTabId = activeTabId
if (activeTabId === closingTab.id) {
if (nextTabs.length === 0) {
nextActiveTabId = null
} else if (index >= nextTabs.length) {
nextActiveTabId = nextTabs[nextTabs.length - 1]!.id
} else {
nextActiveTabId = nextTabs[index]!.id
}
}
return {
previewTabsBySession: {
...state.previewTabsBySession,
[sessionId]: nextTabs.length > 0 ? nextTabs : undefined,
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
[sessionId]: nextActiveTabId,
},
loading: {
...state.loading,
previewByTabId: removeRecordKey(state.loading.previewByTabId, requestKey),
},
errors: {
...state.errors,
previewByTabId: removeRecordKey(state.errors.previewByTabId, requestKey),
},
}
})
},
clearSession: (sessionId) => {
invalidateRequest(statusRequestIds, sessionId)
invalidateSessionScopedRequests(treeRequestIds, sessionId)
invalidateSessionScopedRequests(previewRequestIds, sessionId)
set((state) => ({
panelBySession: removeRecordKey(state.panelBySession, sessionId),
statusBySession: removeRecordKey(state.statusBySession, sessionId),
expandedPathsBySession: removeRecordKey(state.expandedPathsBySession, sessionId),
treeBySessionPath: removeRecordKey(state.treeBySessionPath, sessionId),
previewTabsBySession: removeRecordKey(state.previewTabsBySession, sessionId),
activePreviewTabIdBySession: removeRecordKey(state.activePreviewTabIdBySession, sessionId),
loading: {
statusBySession: removeRecordKey(state.loading.statusBySession, sessionId),
treeBySessionPath: stripSessionKeys(state.loading.treeBySessionPath, sessionId),
previewByTabId: stripSessionKeys(state.loading.previewByTabId, sessionId),
},
errors: {
statusBySession: removeRecordKey(state.errors.statusBySession, sessionId),
treeBySessionPath: stripSessionKeys(state.errors.treeBySessionPath, sessionId),
previewByTabId: stripSessionKeys(state.errors.previewByTabId, sessionId),
},
}))
},
resetSessionUi: (sessionId) => {
get().clearSession(sessionId)
},
}))

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

View File

@ -0,0 +1,444 @@
import { afterEach, describe, expect, it } from 'bun:test'
import * as fs from 'node:fs/promises'
import { execFileSync } from 'node:child_process'
import * as os from 'node:os'
import * as path from 'node:path'
import { WorkspaceService } from '../services/workspaceService.js'
const cleanupDirs = new Set<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, too-large, and missing targets', async () => {
const workDir = await makeTempDir('workspace-service-files-')
const service = new WorkspaceService(async () => workDir)
await fs.writeFile(path.join(workDir, 'note.ts'), 'export const answer = 42\n')
await fs.writeFile(path.join(workDir, 'binary.bin'), Buffer.from([0, 1, 2, 3]))
await fs.writeFile(path.join(workDir, 'image.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00]))
await fs.writeFile(path.join(workDir, 'large.txt'), Buffer.alloc(ONE_MIB + 1, 'a'))
await fs.mkdir(path.join(workDir, 'folder'))
await expect(service.readFile('session-1', 'note.ts')).resolves.toMatchObject({
state: 'ok',
language: 'typescript',
size: 25,
content: 'export const answer = 42\n',
})
await expect(service.readFile('session-1', 'binary.bin')).resolves.toMatchObject({
state: 'binary',
language: 'binary',
size: 4,
})
await expect(service.readFile('session-1', 'image.png')).resolves.toMatchObject({
state: 'ok',
previewType: 'image',
language: 'image',
mimeType: 'image/png',
dataUrl: 'data:image/png;base64,iVBORwA=',
size: 5,
})
await expect(service.readFile('session-1', 'large.txt')).resolves.toMatchObject({
state: 'too_large',
language: 'text',
size: ONE_MIB + 1,
})
await expect(service.readFile('session-1', 'missing.txt')).resolves.toMatchObject({
state: 'missing',
})
await expect(service.readFile('session-1', 'folder')).resolves.toMatchObject({
state: 'missing',
})
})
it('lists a single directory level with dotfiles excluded and directories first', async () => {
const workDir = await makeTempDir('workspace-service-tree-')
const service = new WorkspaceService(async () => workDir)
await fs.mkdir(path.join(workDir, 'b-dir'))
await fs.mkdir(path.join(workDir, 'a-dir'))
await fs.mkdir(path.join(workDir, 'a-dir', 'inner'))
await fs.writeFile(path.join(workDir, 'a-dir', 'note.txt'), 'nested\n')
await fs.writeFile(path.join(workDir, 'z-file.txt'), 'root file\n')
await fs.writeFile(path.join(workDir, '.hidden.txt'), 'ignore\n')
await expect(service.readTree('session-1')).resolves.toMatchObject({
state: 'ok',
path: '',
entries: [
{ name: 'a-dir', path: 'a-dir', isDirectory: true },
{ name: 'b-dir', path: 'b-dir', isDirectory: true },
{ name: 'z-file.txt', path: 'z-file.txt', isDirectory: false },
],
})
await expect(service.readTree('session-1', 'a-dir')).resolves.toMatchObject({
state: 'ok',
path: 'a-dir',
entries: [
{ name: 'inner', path: 'a-dir/inner', isDirectory: true },
{ name: 'note.txt', path: 'a-dir/note.txt', isDirectory: false },
],
})
})
it('returns diffs for modified, added, deleted, and untracked files', async () => {
const repoDir = await createGitWorkspace()
const service = new WorkspaceService(async (sessionId) => sessionId === 'session-1' ? repoDir : null)
const modified = await service.getDiff('session-1', 'tracked.txt')
expect(modified.state).toBe('ok')
expect(modified.diff).toContain('tracked.txt')
expect(modified.diff.length).toBeGreaterThan(0)
const added = await service.getDiff('session-1', 'new.txt')
expect(added.state).toBe('ok')
expect(added.diff).toContain('new.txt')
expect(added.diff.length).toBeGreaterThan(0)
const deleted = await service.getDiff('session-1', 'deleted.txt')
expect(deleted.state).toBe('ok')
expect(deleted.diff).toContain('deleted.txt')
expect(deleted.diff.length).toBeGreaterThan(0)
const untracked = await service.getDiff('session-1', 'untracked.txt')
expect(untracked.state).toBe('ok')
expect(untracked.diff).toContain('untracked.txt')
expect(untracked.diff.length).toBeGreaterThan(0)
await expect(service.getDiff('session-1', 'clean.txt')).resolves.toMatchObject({
state: 'missing',
path: 'clean.txt',
})
const nonGitDir = await makeTempDir('workspace-service-diff-non-git-')
const nonGitService = new WorkspaceService(async () => nonGitDir)
await expect(nonGitService.getDiff('session-1', 'whatever.txt')).resolves.toMatchObject({
state: 'not_git_repo',
path: 'whatever.txt',
})
})
it('returns explicit error state when git status fails instead of ok-empty', async () => {
const repoDir = await createGitWorkspace()
const service = new WorkspaceService(async () => repoDir) as WorkspaceService & {
runGit: (workDir: string, args: string[]) => Promise<{
stdout: string
stderr: string
code: number
}>
}
service.runGit = async (workDir, args) => {
if (args[0] === 'rev-parse' && args[1] === '--show-toplevel') {
return { stdout: `${workDir}\n`, stderr: '', code: 0 }
}
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') {
return { stdout: 'main\n', stderr: '', code: 0 }
}
if (args[0] === 'status') {
return { stdout: '', stderr: 'fatal: synthetic git failure', code: 1 }
}
return { stdout: '', stderr: 'unexpected call', code: 1 }
}
await expect(service.getStatus('session-1')).resolves.toMatchObject({
state: 'error',
isGitRepo: true,
})
const result = await service.getStatus('session-1')
expect(result.state).toBe('error')
expect(result.changedFiles).toEqual([])
expect(result.error).toContain('Failed to read git status')
expect(result.error).toContain('synthetic git failure')
})
it('reads tracked diff stats in one bulk git call', async () => {
const repoDir = await makeTempDir('workspace-service-bulk-stats-')
await fs.writeFile(path.join(repoDir, 'a.txt'), 'a\n')
await fs.writeFile(path.join(repoDir, 'b.txt'), 'b\n')
const diffStatCalls: string[][] = []
const service = new WorkspaceService(async () => repoDir) as WorkspaceService & {
runGit: (workDir: string, args: string[]) => Promise<{
stdout: string
stderr: string
code: number
}>
}
service.runGit = async (_workDir, args) => {
if (args[0] === 'rev-parse' && args[1] === '--show-toplevel') {
return { stdout: `${repoDir}\n`, stderr: '', code: 0 }
}
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') {
return { stdout: 'main\n', stderr: '', code: 0 }
}
if (args[0] === 'status') {
return { stdout: ' M a.txt\0 M b.txt\0', stderr: '', code: 0 }
}
if (args[0] === 'diff' && args.includes('--numstat')) {
diffStatCalls.push(args)
return { stdout: '1\t0\ta.txt\n2\t3\tb.txt\n', stderr: '', code: 0 }
}
return { stdout: '', stderr: `unexpected git call: ${args.join(' ')}`, code: 1 }
}
const result = await service.getStatus('session-1')
expect(result.state).toBe('ok')
expect(diffStatCalls).toHaveLength(1)
expect(result.changedFiles).toEqual([
{ path: 'a.txt', oldPath: undefined, status: 'modified', additions: 1, deletions: 0 },
{ path: 'b.txt', oldPath: undefined, status: 'modified', additions: 2, deletions: 3 },
])
})
})

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

File diff suppressed because it is too large Load Diff