-
- {/* Header: tab strip + close. */}
-
-
- )
-}
-
-// ═══════════════════════════════════════════════════════════════════════════
-// Plan pane — todos from the Redux store + a progress bar.
-// ═══════════════════════════════════════════════════════════════════════════
-
-function PlanPane() {
- const { t } = useTranslation()
- const todos = useAppSelector((s) => s.chat.todos)
- const total = todos.length
- const completed = todos.filter((t) => t.status === 'completed').length
- const progressPct = total ? Math.round((completed / total) * 100) : 0
-
- return (
-
- )
-}
-
-// ═══════════════════════════════════════════════════════════════════════════
-// TaskList — inline port of web/src/components/TaskList.vue. Status icon +
-// title per todo, with a left accent bar for the in-progress task.
-// ═══════════════════════════════════════════════════════════════════════════
-
-function TaskList({ todos }: { todos: TodoItem[] }) {
- // Respect prefers-reduced-motion: swap the spinning ArrowPathIcon for a static
- // EllipsisHorizontalCircleIcon so the spin actually stops.
- const [reduceMotion, setReduceMotion] = useState(false)
- useEffect(() => {
- if (typeof window === 'undefined' || !window.matchMedia) return
- const mql = window.matchMedia('(prefers-reduced-motion: reduce)')
- const sync = (e: MediaQueryListEvent) => setReduceMotion(e.matches)
- setReduceMotion(mql.matches)
- mql.addEventListener('change', sync)
- return () => mql.removeEventListener('change', sync)
- }, [])
-
- return (
-
- {todos.map((todo) => {
- const done = todo.status === 'completed' || todo.status === 'cancelled'
- const inProgress = todo.status === 'in_progress'
- return (
-
+
+
+ {t('rightPanel.workspace')}
+
+
+ {t('rightPanel.workspaceSummary')}
+
+
+
+
+
+
+
+
+ {/* Keyed on the project path so switching projects remounts the children
+ and re-fetches — both only load on mount. */}
+ {activeTab === 'plan' ? (
+
+ ) : activeTab === 'files' ? (
+
+ ) : (
+
+ )}
+
+
+ >
)
}
diff --git a/web/src/components/StatusPanel.test.tsx b/web/src/components/StatusPanel.test.tsx
new file mode 100644
index 00000000..87917f4a
--- /dev/null
+++ b/web/src/components/StatusPanel.test.tsx
@@ -0,0 +1,146 @@
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { Provider } from 'react-redux'
+import { chatActions, store } from '../app/store'
+import { i18n } from '../i18n'
+import { StatusPanel } from './StatusPanel'
+
+beforeEach(async () => {
+ cleanup()
+ await i18n.changeLanguage('en')
+ store.dispatch(chatActions.clearChat())
+ store.dispatch(chatActions.setTodos([
+ { id: 1, title: 'Inspect layout', status: 'completed' },
+ { id: 2, title: 'Verify floating panel', status: 'in_progress' },
+ ]))
+ store.dispatch(chatActions.setPlanHistory([
+ {
+ id: 'plan-1',
+ title: 'Initial layout plan',
+ status: 'completed',
+ todos: [{ id: 1, title: 'Inspect layout', status: 'completed' }],
+ timestamp: Date.parse('2026-08-04T08:00:00Z'),
+ },
+ {
+ id: 'plan-2',
+ title: 'Verify floating panel',
+ status: 'in_progress',
+ todos: [
+ { id: 1, title: 'Inspect layout', status: 'completed' },
+ { id: 2, title: 'Verify floating panel', status: 'in_progress' },
+ ],
+ timestamp: Date.parse('2026-08-04T09:00:00Z'),
+ },
+ ]))
+})
+
+afterEach(() => {
+ cleanup()
+ store.dispatch(chatActions.clearChat())
+})
+
+describe('StatusPanel', () => {
+ it('shows plan history in the floating task-status region', () => {
+ const onClose = vi.fn()
+ render(
+
+ {}} onClose={onClose} />
+ ,
+ )
+
+ expect(screen.getByRole('dialog', { name: 'Task status' })).toBeTruthy()
+ expect(screen.getAllByText('Verify floating panel').length).toBeGreaterThan(0)
+ expect(screen.getByText('Initial layout plan')).toBeTruthy()
+ expect(screen.getAllByText('Todo List').length).toBeGreaterThan(0)
+ expect(screen.getAllByText('Plan history').length).toBeGreaterThan(0)
+ expect(screen.getAllByText('Artifacts').length).toBeGreaterThan(0)
+ expect(screen.queryByRole('button', { name: 'Open task status' })).toBeNull()
+
+ const collapseHeader = screen.getByRole('button', { name: 'Collapse task status' })
+ expect(collapseHeader.textContent).toContain('Verify floating panel')
+ fireEvent.click(collapseHeader)
+ expect(onClose).toHaveBeenCalledOnce()
+ })
+
+ it('uses the live status capsule as the only opener while collapsed', () => {
+ const onOpen = vi.fn()
+ render(
+
+ {}} />
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Open task status' }))
+ expect(onOpen).toHaveBeenCalledOnce()
+ expect(screen.queryByRole('dialog', { name: 'Task status' })).toBeNull()
+ })
+
+ it('shows the latest completed todo in the collapsed status capsule', () => {
+ store.dispatch(chatActions.setTodos([
+ { id: 1, title: 'Run tests', status: 'completed' },
+ { id: 2, title: 'commit + push master', status: 'completed' },
+ ]))
+
+ render(
+
+ {}} onClose={() => {}} />
+ ,
+ )
+
+ expect(screen.getByText('commit + push master')).toBeTruthy()
+ expect(screen.queryByText('Run tests')).toBeNull()
+ })
+
+ it('summarizes plan snapshots without repeating their todo rows', () => {
+ store.dispatch(chatActions.setTodos([{ id: 1, title: 'Current todo', status: 'in_progress' }]))
+ store.dispatch(chatActions.setPlanHistory([{
+ id: 'history-only',
+ title: 'Historical plan',
+ status: 'completed',
+ todos: [{ id: 9, title: 'Duplicated historical todo', status: 'completed' }],
+ timestamp: Date.parse('2026-08-04T09:00:00Z'),
+ }]))
+
+ render(
+
+ {}} onClose={() => {}} />
+ ,
+ )
+
+ expect(screen.getByText('Historical plan')).toBeTruthy()
+ expect(screen.getByText(/1 Todo/)).toBeTruthy()
+ expect(screen.queryByText('Duplicated historical todo')).toBeNull()
+ })
+
+ it('keeps the status opener visible for sessions without status data', () => {
+ store.dispatch(chatActions.clearChat())
+ const onOpen = vi.fn()
+
+ const view = render(
+
+ {}} />
+ ,
+ )
+
+ const opener = screen.getByRole('button', { name: 'Open task status' })
+ expect(opener.parentElement?.className).toContain('w-[min(248px,calc(100%_-_64px))]')
+ expect(screen.getByText('Task status')).toBeTruthy()
+ expect(screen.queryByText('Todo List')).toBeNull()
+ expect(screen.queryByText('Plan history')).toBeNull()
+ expect(screen.queryByText('Artifacts')).toBeNull()
+
+ fireEvent.click(opener)
+ expect(onOpen).toHaveBeenCalledOnce()
+
+ view.rerender(
+
+ {}} />
+ ,
+ )
+ const panel = screen.getByRole('dialog', { name: 'Task status' })
+ expect(panel.className).toContain('w-[min(248px,calc(100%_-_64px))]')
+ expect(screen.getByRole('button', { name: 'Collapse task status' })).toBe(opener)
+ expect(screen.getByText('No Todo, Plan, or artifacts yet')).toBeTruthy()
+ expect(screen.queryByText('Todo List')).toBeNull()
+ })
+})
diff --git a/web/src/components/StatusPanel.tsx b/web/src/components/StatusPanel.tsx
new file mode 100644
index 00000000..3f79464d
--- /dev/null
+++ b/web/src/components/StatusPanel.tsx
@@ -0,0 +1,330 @@
+import { useCallback, useEffect, useState, type ReactNode } from 'react'
+import { createPortal } from 'react-dom'
+import { useTranslation } from 'react-i18next'
+import {
+ ArrowDownTrayIcon,
+ ArrowPathIcon,
+ CheckCircleIcon,
+ ChevronDownIcon,
+ ChevronUpIcon,
+ ClipboardDocumentCheckIcon,
+ ClockIcon,
+ DocumentDuplicateIcon,
+ EllipsisHorizontalCircleIcon,
+ ExclamationTriangleIcon,
+ MinusCircleIcon,
+ NoSymbolIcon,
+ XMarkIcon,
+} from '@heroicons/react/24/outline'
+import { useAppSelector } from '../app/hooks'
+import { api } from '../lib/api'
+import type { ArtifactRecord, PlanHistoryEntry, TodoItem } from '../lib/types'
+import { ArtifactsPanel } from './ArtifactsPanel'
+
+interface Props {
+ open: boolean
+ isRunning: boolean
+ bottomOffset?: number
+ onOpen: () => void
+ onClose: () => void
+}
+
+export function StatusPanel({ open, isRunning, bottomOffset = 18, onOpen, onClose }: Props) {
+ const { t } = useTranslation()
+ const todos = useAppSelector((state) => state.chat.todos)
+ const planHistory = useAppSelector((state) => state.chat.planHistory)
+ const currentSessionId = useAppSelector((state) => state.session.currentSessionId)
+ const artifactTask = useAppSelector((state) => state.session.tasks.find((task) => task.uuid === currentSessionId))
+ const artifactCount = artifactTask?.artifact_count ?? 0
+ const activeTodo = todos.find((todo) => todo.status === 'in_progress') ?? todos.find((todo) => todo.status === 'pending')
+ const latestCompletedTodo = [...todos].reverse().find((todo) => todo.status === 'completed' || todo.status === 'cancelled')
+ const summaryTodo = activeTodo ?? latestCompletedTodo
+ const summaryDone = summaryTodo?.status === 'completed'
+ const summaryTitle = summaryTodo?.title || t('statusPanel.title')
+ const summaryIconClass = `grid h-5 w-5 shrink-0 place-items-center rounded-[var(--radius-pill)] ${summaryDone ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : isRunning || activeTodo ? 'bg-[var(--accent-wash)] text-[var(--color-primary)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}`
+ const hasContent = todos.length > 0 || planHistory.length > 0 || artifactCount > 0
+
+ return (
+
+
+
+ {open && (
+
+ {!hasContent ? (
+
{t('statusPanel.empty')}
+ ) : (
+ <>
+
} title={t('statusPanel.todos')} count={todos.length}>
+ {todos.length > 0 &&
}
+
+
} title={t('statusPanel.plans')} count={planHistory.length}>
+ {planHistory.length > 0 &&
}
+
+
} title={t('statusPanel.artifacts')} count={artifactCount} last>
+ {artifactCount > 0 &&
}
+
+ >
+ )}
+
+ )}
+
+ )
+}
+
+function StatusSection({ icon, title, count, last = false, children }: { icon: ReactNode; title: string; count: number; last?: boolean; children: ReactNode }) {
+ return (
+
+
+ {icon}
+ {title}
+ {count}
+
+ {children}
+
+ )
+}
+
+function CompactArtifactsPane({ taskId }: { taskId: string }) {
+ const { t } = useTranslation()
+ const [records, setRecords] = useState
([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [downloadingID, setDownloadingID] = useState('')
+ const [previewID, setPreviewID] = useState('')
+
+ const load = useCallback(async () => {
+ if (!taskId) { setRecords([]); setLoading(false); return }
+ setLoading(true)
+ setError('')
+ try {
+ setRecords(await api.artifacts(taskId))
+ await api.markArtifactsViewed(taskId).catch(() => undefined)
+ } catch {
+ setError(t('artifacts.loadError'))
+ } finally {
+ setLoading(false)
+ }
+ }, [taskId, t])
+
+ useEffect(() => { void load() }, [load])
+ useEffect(() => {
+ const refresh = () => { void load() }
+ window.addEventListener('jcode:artifact-upserted', refresh)
+ return () => window.removeEventListener('jcode:artifact-upserted', refresh)
+ }, [load])
+
+ async function download(record: ArtifactRecord) {
+ if (downloadingID) return
+ setDownloadingID(record.id)
+ setError('')
+ try {
+ const blob = await api.artifactDownload(taskId, record.id)
+ const objectURL = URL.createObjectURL(blob)
+ const anchor = document.createElement('a')
+ anchor.href = objectURL
+ anchor.download = record.relative_path.split('/').pop() || record.title
+ document.body.appendChild(anchor)
+ anchor.click()
+ anchor.remove()
+ window.setTimeout(() => URL.revokeObjectURL(objectURL), 0)
+ } catch {
+ setError(t('artifacts.downloadError'))
+ } finally {
+ setDownloadingID('')
+ }
+ }
+
+ if (loading) return {t('common.loading')}
+ if (error && records.length === 0) return {error}
+ if (records.length === 0) return {t('artifacts.empty')}
+
+ return (
+ <>
+
+ {records.map((record) => (
+
+
+
+
+ ))}
+ {error &&
{error}
}
+
+ {previewID && createPortal(
+
+
,
+ document.body,
+ )}
+ >
+ )
+}
+
+function CompactArtifactMessage({ error = false, children }: { error?: boolean; children: ReactNode }) {
+ return {children}
+}
+
+function formatArtifactBytes(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+}
+
+export function CurrentPlanPane({ todos }: { todos?: TodoItem[] }) {
+ const { t } = useTranslation()
+ const storeTodos = useAppSelector((state) => state.chat.todos)
+ const items = todos ?? storeTodos
+ const completed = items.filter((todo) => todo.status === 'completed' || todo.status === 'cancelled').length
+ const progress = items.length > 0 ? Math.round((completed / items.length) * 100) : 0
+ return (
+
+
+
{t('statusPanel.currentProgress')}
+
+
{completed} / {items.length}
+
+ {items.length > 0 ?
:
{t('rightPanel.noTasks')}}
+
+ )
+}
+
+function PlanHistoryPane({ history }: { history: PlanHistoryEntry[] }) {
+ const { t } = useTranslation()
+ if (history.length === 0) return {t('statusPanel.noPlans')}
+ return (
+
+ {[...history].reverse().map((entry, index) =>
)}
+
+ )
+}
+
+function PlanHistoryRow({ entry, defaultOpen }: { entry: PlanHistoryEntry; defaultOpen: boolean }) {
+ const { t } = useTranslation()
+ const hasDetails = !!entry.content || !!entry.feedback
+ const summary = (
+ <>
+
+
+ {entry.title || t('statusPanel.planFallback')}
+
+ {formatPlanTime(entry.timestamp)}{entry.todos.length > 0 ? ` · ${t('statusPanel.todoCount', { count: entry.todos.length })}` : ''}
+
+
+ >
+ )
+
+ if (!hasDetails) return {summary}
+ return (
+
+
+ {summary}
+
+
+
+ {entry.content &&
{entry.content}}
+ {entry.feedback &&
{entry.feedback}
}
+
+
+ )
+}
+
+function TaskList({ todos, compact = false }: { todos: TodoItem[]; compact?: boolean }) {
+ const [reduceMotion, setReduceMotion] = useState(false)
+ useEffect(() => {
+ if (typeof window === 'undefined' || !window.matchMedia) return
+ const query = window.matchMedia('(prefers-reduced-motion: reduce)')
+ const sync = (event: MediaQueryListEvent) => setReduceMotion(event.matches)
+ setReduceMotion(query.matches)
+ query.addEventListener('change', sync)
+ return () => query.removeEventListener('change', sync)
+ }, [])
+
+ return (
+
+ {todos.map((todo) => {
+ const done = todo.status === 'completed' || todo.status === 'cancelled'
+ const active = todo.status === 'in_progress'
+ return (
+
+ {todo.status === 'completed' ? (
+
+ ) : todo.status === 'cancelled' ? (
+
+ ) : active ? (
+ reduceMotion ?
:
+ ) : (
+
+ )}
+
+ {todo.title}
+
+
+ )
+ })}
+
+ )
+}
+
+function PlanStatusIcon({ status }: { status: string }) {
+ if (status === 'completed' || status === 'approved') return
+ if (status === 'rejected') return
+ return
+}
+
+function EmptyState({ children }: { children: ReactNode }) {
+ return {children}
+}
+
+function formatPlanTime(timestamp: number): string {
+ if (!timestamp || Number.isNaN(timestamp)) return '—'
+ return new Date(timestamp).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
+}
diff --git a/web/src/components/StatusPanelArtifacts.test.tsx b/web/src/components/StatusPanelArtifacts.test.tsx
new file mode 100644
index 00000000..66ca9699
--- /dev/null
+++ b/web/src/components/StatusPanelArtifacts.test.tsx
@@ -0,0 +1,86 @@
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { Provider } from 'react-redux'
+import { chatActions, sessionActions, store } from '../app/store'
+import { i18n } from '../i18n'
+import { StatusPanel } from './StatusPanel'
+
+const mocks = vi.hoisted(() => ({
+ artifacts: vi.fn(),
+ markArtifactsViewed: vi.fn(),
+ artifactDownload: vi.fn(),
+ artifactContent: vi.fn(),
+ cloudStatus: vi.fn(),
+ artifactShares: vi.fn(),
+}))
+
+vi.mock('../lib/api', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, api: { ...actual.api, ...mocks } }
+})
+
+beforeEach(async () => {
+ cleanup()
+ vi.clearAllMocks()
+ await i18n.changeLanguage('en')
+ store.dispatch(chatActions.clearChat())
+ store.dispatch(sessionActions.setCurrentSession('compact-artifact-task'))
+ store.dispatch(sessionActions.setTasks([{
+ uuid: 'compact-artifact-task',
+ project: '/workspace/jcode',
+ created_at: '2026-08-04T08:00:00Z',
+ provider: 'openai',
+ model: 'gpt-5',
+ pinned: false,
+ archived: false,
+ unread: false,
+ artifact_count: 1,
+ }]))
+ mocks.artifacts.mockResolvedValue([{
+ id: 'artifact-1',
+ session_id: 'compact-artifact-task',
+ relative_path: 'reports/route-analysis.md',
+ title: 'Route analysis',
+ kind: 'markdown',
+ media_type: 'text/markdown',
+ size: 1843,
+ revision: 2,
+ updated_at: '2026-08-04T08:30:00Z',
+ status: 'available',
+ }])
+ mocks.markArtifactsViewed.mockResolvedValue(undefined)
+ mocks.artifactContent.mockResolvedValue(new Blob(['# Route analysis'], { type: 'text/markdown' }))
+ mocks.cloudStatus.mockResolvedValue({ logged_in: true, state: 'offline' })
+ mocks.artifactShares.mockResolvedValue([])
+})
+
+afterEach(() => {
+ cleanup()
+ store.dispatch(sessionActions.setCurrentSession(''))
+ store.dispatch(sessionActions.setTasks([]))
+ store.dispatch(chatActions.clearChat())
+})
+
+describe('StatusPanel compact artifacts', () => {
+ it('opens the full artifact preview and keeps Cloud sharing available', async () => {
+ render(
+
+ {}} onClose={() => {}} />
+ ,
+ )
+
+ expect(await screen.findByText('Route analysis')).toBeTruthy()
+ expect(screen.getByText('markdown · 1.8 KB')).toBeTruthy()
+ expect(screen.getByRole('button', { name: 'Download Route analysis' })).toBeTruthy()
+ expect(screen.queryByRole('button', { name: 'Full screen' })).toBeNull()
+ expect(screen.queryByRole('button', { name: 'Share' })).toBeNull()
+ await waitFor(() => expect(mocks.markArtifactsViewed).toHaveBeenCalledWith('compact-artifact-task'))
+
+ fireEvent.click(screen.getByRole('button', { name: 'Preview artifact Route analysis' }))
+ expect(await screen.findByRole('dialog', { name: 'Preview artifact' })).toBeTruthy()
+ expect(await screen.findByRole('button', { name: 'Full screen' })).toBeTruthy()
+ const share = await screen.findByRole('button', { name: 'Share' })
+ fireEvent.click(share)
+ expect(await screen.findByRole('dialog', { name: 'Share encrypted artifact' })).toBeTruthy()
+ })
+})
diff --git a/web/src/components/TerminalPanel.test.tsx b/web/src/components/TerminalPanel.test.tsx
new file mode 100644
index 00000000..a0cb21db
--- /dev/null
+++ b/web/src/components/TerminalPanel.test.tsx
@@ -0,0 +1,103 @@
+import { useState } from 'react'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { i18n } from '../i18n'
+import { TerminalPanel } from './TerminalPanel'
+
+const mocks = vi.hoisted(() => ({
+ ptyCreate: vi.fn(),
+ ptyKill: vi.fn(),
+ terminalDispose: vi.fn(),
+ terminalFocus: vi.fn(),
+ fit: vi.fn(),
+}))
+
+vi.mock('../lib/api', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, api: { ...actual.api, ptyCreate: mocks.ptyCreate, ptyKill: mocks.ptyKill } }
+})
+
+vi.mock('@xterm/xterm', () => ({
+ Terminal: class {
+ cols = 80
+ rows = 24
+ options: Record = {}
+ loadAddon() {}
+ open() {}
+ onData() { return { dispose() {} } }
+ write() {}
+ writeln() {}
+ focus() { mocks.terminalFocus() }
+ dispose() { mocks.terminalDispose() }
+ },
+}))
+
+vi.mock('@xterm/addon-fit', () => ({
+ FitAddon: class { fit() { mocks.fit() } },
+}))
+
+vi.mock('@xterm/addon-web-links', () => ({ WebLinksAddon: class {} }))
+
+class FakeResizeObserver {
+ observe() {}
+ disconnect() {}
+}
+
+class FakeWebSocket {
+ static OPEN = 1
+ readyState = FakeWebSocket.OPEN
+ binaryType = ''
+ onopen: (() => void) | null = null
+ onclose: (() => void) | null = null
+ onerror: (() => void) | null = null
+ onmessage: ((event: MessageEvent) => void) | null = null
+ constructor(_url: string, _protocols?: string[]) {}
+ send() {}
+ close() {}
+}
+
+beforeEach(async () => {
+ cleanup()
+ vi.clearAllMocks()
+ await i18n.changeLanguage('en')
+ let id = 0
+ mocks.ptyCreate.mockImplementation(async () => ({ id: `pty-${++id}` }))
+ mocks.ptyKill.mockResolvedValue({ status: 'ok' })
+ vi.stubGlobal('ResizeObserver', FakeResizeObserver)
+ vi.stubGlobal('WebSocket', FakeWebSocket)
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(0), 0))
+ vi.stubGlobal('cancelAnimationFrame', (handle: number) => window.clearTimeout(handle))
+})
+
+afterEach(() => {
+ cleanup()
+ vi.unstubAllGlobals()
+})
+
+describe('TerminalPanel tabs', () => {
+ it('creates independent PTYs and closes individual tabs', async () => {
+ const onClose = vi.fn()
+
+ function Harness() {
+ const [open, setOpen] = useState(true)
+ return open ? { onClose(); setOpen(false) }} /> : null
+ }
+
+ render()
+ expect(screen.getByRole('tab', { name: 'Shell 1' }).getAttribute('aria-selected')).toBe('true')
+ await waitFor(() => expect(mocks.ptyCreate).toHaveBeenCalledTimes(1))
+
+ fireEvent.click(screen.getByRole('button', { name: 'New terminal' }))
+ expect(screen.getByRole('tab', { name: 'Shell 2' }).getAttribute('aria-selected')).toBe('true')
+ await waitFor(() => expect(mocks.ptyCreate).toHaveBeenCalledTimes(2))
+
+ fireEvent.click(screen.getByRole('button', { name: 'Close Shell 2' }))
+ expect(screen.queryByRole('tab', { name: 'Shell 2' })).toBeNull()
+ expect(screen.getByRole('tab', { name: 'Shell 1' }).getAttribute('aria-selected')).toBe('true')
+ await waitFor(() => expect(mocks.ptyKill).toHaveBeenCalledWith('pty-2'))
+
+ fireEvent.click(screen.getByRole('button', { name: 'Close Shell 1' }))
+ expect(onClose).toHaveBeenCalledOnce()
+ await waitFor(() => expect(mocks.ptyKill).toHaveBeenCalledWith('pty-1'))
+ })
+})
diff --git a/web/src/components/TerminalPanel.tsx b/web/src/components/TerminalPanel.tsx
index 44b76685..70b5fa7f 100644
--- a/web/src/components/TerminalPanel.tsx
+++ b/web/src/components/TerminalPanel.tsx
@@ -1,10 +1,8 @@
/**
- * TerminalPanel — bottom-docked terminal using xterm.js.
+ * TerminalPanel — bottom-docked, multi-tab terminal using xterm.js.
*
- * Ported from web/src/components/TerminalPanel.vue (the tab container) +
- * web/src/components/TerminalInstance.vue (the actual xterm + PTY + WS
- * lifecycle). The React signature is a single-terminal panel { onClose }, so
- * the lifecycle from TerminalInstance is folded in here.
+ * Each tab owns an independent TerminalInstance, PTY, WebSocket, xterm and
+ * cleanup lifecycle. Inactive tabs stay mounted so shell state is preserved.
*
* On mount it creates a PTY via api.ptyCreate(), opens a WS to
* `${wsBase()}/api/pty/${id}/ws`, attaches xterm + FitAddon + WebLinksAddon,
@@ -13,9 +11,9 @@
* WS, and disposes the terminal.
*/
-import { useEffect, useRef } from 'react'
+import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { XMarkIcon } from '@heroicons/react/24/outline'
+import { PlusIcon, XMarkIcon } from '@heroicons/react/24/outline'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
@@ -29,10 +27,64 @@ interface Props {
}
export function TerminalPanel({ onClose }: Props) {
+ const { t } = useTranslation()
+ const nextIDRef = useRef(2)
+ const [tabs, setTabs] = useState([{ id: 1, number: 1 }])
+ const [activeID, setActiveID] = useState(1)
+
+ const addTab = useCallback(() => {
+ const id = nextIDRef.current++
+ setTabs((current) => [...current, { id, number: id }])
+ setActiveID(id)
+ }, [])
+
+ const closeTab = useCallback((id: number) => {
+ if (tabs.length === 1) {
+ onClose()
+ return
+ }
+ const index = tabs.findIndex((tab) => tab.id === id)
+ const remaining = tabs.filter((tab) => tab.id !== id)
+ if (activeID === id) setActiveID(remaining[Math.min(Math.max(index, 0), remaining.length - 1)].id)
+ setTabs(remaining)
+ }, [activeID, onClose, tabs])
+
+ return (
+
+
+
+ {tabs.map((tab) => {
+ const label = t('terminal.shell', { n: tab.number })
+ const active = activeID === tab.id
+ return (
+
+ setActiveID(tab.id)} className="h-full px-2 font-mono text-[10.5px] font-medium">
+ {label}
+
+ closeTab(tab.id)} className="mr-1 grid h-4 w-4 place-items-center rounded-[var(--radius-sm)] hover:bg-[var(--color-background)] hover:text-[var(--color-foreground)]">
+
+
+
+ )
+ })}
+
+
+
+
+
+
+ {tabs.map((tab) => )}
+
+
+ )
+}
+
+function TerminalInstance({ active }: { active: boolean }) {
const { t } = useTranslation()
const termElRef = useRef(null)
- const onCloseRef = useRef(onClose)
- onCloseRef.current = onClose
+ const terminalRef = useRef(null)
+ const fitAddonRef = useRef(null)
+ const socketRef = useRef(null)
useEffect(() => {
const termEl = termElRef.current
@@ -48,6 +100,7 @@ export function TerminalPanel({ onClose }: Props) {
let sessionId = ''
let resizeObserver: ResizeObserver | null = null
let themeObserver: MutationObserver | null = null
+ let disposed = false
// Read a CSS custom property from :root and strip whitespace. Falls back to
// the provided default when the token is unset (e.g. before tokens load).
@@ -103,13 +156,14 @@ export function TerminalPanel({ onClose }: Props) {
// subprotocol — browsers can't set headers on a WS handshake. See auth.go.
const token = getAuthToken()
ws = token ? new WebSocket(url, ['jcode-auth', token]) : new WebSocket(url)
+ socketRef.current = ws
ws.binaryType = 'arraybuffer'
ws.onopen = () => {
sendResize()
}
ws.onclose = () => {
- term?.writeln(`\r\n\x1b[33m[Session ended]\x1b[0m`)
+ term?.writeln(`\r\n\x1b[33m${t('terminal.sessionEnded')}\x1b[0m`)
}
ws.onerror = () => {
/* state handled by onclose */
@@ -140,8 +194,10 @@ export function TerminalPanel({ onClose }: Props) {
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
theme: termTheme(),
})
+ terminalRef.current = term
fitAddon = new FitAddon()
+ fitAddonRef.current = fitAddon
term.loadAddon(fitAddon)
term.loadAddon(new WebLinksAddon())
term.open(el)
@@ -160,11 +216,15 @@ export function TerminalPanel({ onClose }: Props) {
try {
const result = await api.ptyCreate()
+ if (disposed) {
+ void api.ptyKill(result.id).catch(() => {})
+ return
+ }
sessionId = result.id
connectWS(result.id)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
- term.writeln(`\r\n\x1b[31mFailed to create terminal: ${msg}\x1b[0m`)
+ term?.writeln(`\r\n\x1b[31m${t('terminal.failedCreate', { msg })}\x1b[0m`)
}
}
@@ -173,6 +233,7 @@ export function TerminalPanel({ onClose }: Props) {
// Cleanup on unmount: detach WS handlers before closing (so a racing frame
// can't reach a disposed terminal), kill the PTY, dispose the terminal.
return () => {
+ disposed = true
resizeObserver?.disconnect()
resizeObserver = null
themeObserver?.disconnect()
@@ -184,6 +245,7 @@ export function TerminalPanel({ onClose }: Props) {
ws.onopen = null
ws.close()
ws = null
+ socketRef.current = null
}
if (sessionId) {
api.ptyKill(sessionId).catch(() => {})
@@ -192,52 +254,28 @@ export function TerminalPanel({ onClose }: Props) {
if (term) {
term.dispose()
term = null
+ terminalRef.current = null
}
fitAddon = null
+ fitAddonRef.current = null
}
}, [])
- return (
-
- {/* Tab bar — a single terminal plus the close-panel control on the right. */}
-
-
-
- {t('terminal.shell', { n: 1 })}
-
-
-
-
-
-
-
-
+ useEffect(() => {
+ if (!active) return
+ const frame = window.requestAnimationFrame(() => {
+ fitAddonRef.current?.fit()
+ const terminal = terminalRef.current
+ const socket = socketRef.current
+ if (terminal && socket?.readyState === WebSocket.OPEN) {
+ socket.send(JSON.stringify({ type: 'resize', cols: terminal.cols, rows: terminal.rows }))
+ }
+ terminal?.focus()
+ })
+ return () => window.cancelAnimationFrame(frame)
+ }, [active])
- {/* Terminal area. Padding + term-bg live on .xterm via styles.css
- (mirrors Vue TerminalInstance :deep(.xterm) rules). */}
-
-
+ return (
+
)
}
diff --git a/web/src/components/TopBar.tsx b/web/src/components/TopBar.tsx
index a154b933..9adbb7b7 100644
--- a/web/src/components/TopBar.tsx
+++ b/web/src/components/TopBar.tsx
@@ -2,7 +2,7 @@
* TopBar — the single top-right FLOATING control (absolute, top:6px right:14px,
* z-46). Carries the panels menu: a button showing a RectangleStackIcon + a
* ChevronDown + a live status dot, which opens a dropdown with Plan / Files /
- * Changes / Terminal. The Changes item shows a live diff stat (+N/-M).
+ * Changes / Terminal. Artifacts live in the separate status panel.
*
* Ported from web/src/components/TopBar.vue. The React app has no headlessui, so
* the dropdown is implemented manually: a button + an absolute-positioned menu
@@ -43,7 +43,6 @@ const PANEL_BUTTONS: { panel: PanelType; macShortcut: string; otherShortcut: str
{ panel: 'plan', macShortcut: '⇧⌘P', otherShortcut: 'Ctrl+Shift+P' },
{ panel: 'files', macShortcut: '⇧⌘E', otherShortcut: 'Ctrl+Shift+E' },
{ panel: 'changes', macShortcut: '⇧⌘G', otherShortcut: 'Ctrl+Shift+G' },
- { panel: 'artifacts', macShortcut: '⇧⌘A', otherShortcut: 'Ctrl+Shift+A' },
{ panel: 'terminal', macShortcut: '⌘`', otherShortcut: 'Ctrl+`' },
]
diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts
index 2e9f1d2b..ee96919e 100644
--- a/web/src/i18n/locales/en.ts
+++ b/web/src/i18n/locales/en.ts
@@ -1162,7 +1162,7 @@ export default {
pip: {
computerUse: 'Computer use',
shots: '{n} shots',
- collapse: 'Hide',
+ collapse: 'Minimize',
expand: 'Show',
openFull: 'Enlarge',
shrink: 'Shrink',
@@ -1170,7 +1170,27 @@ export default {
latest: 'Latest',
},
+ statusPanel: {
+ title: 'Task status',
+ summary: 'Current task history',
+ empty: 'No Todo, Plan, or artifacts yet',
+ open: 'Open task status',
+ collapse: 'Collapse task status',
+ tabs: 'Task status sections',
+ todos: 'Todo List',
+ plans: 'Plan history',
+ artifacts: 'Artifacts',
+ currentProgress: 'Current plan progress',
+ noPlans: 'No plan history yet',
+ planFallback: 'Plan update',
+ todoCount: '{count} Todo',
+ },
+
rightPanel: {
+ workspace: 'Work panel',
+ workspaceSummary: 'Shares context with the current task',
+ workspaceNavigation: 'Work panel sections',
+ closeWorkspace: 'Close work panel',
plan: 'Plan',
files: 'Files',
changes: 'Changes',
@@ -1182,6 +1202,7 @@ export default {
},
artifacts: {
+ preview: 'Preview artifact',
new: 'New artifacts',
empty: 'No artifacts yet',
select: 'Select an artifact',
@@ -1332,6 +1353,7 @@ export default {
},
terminal: {
+ tabs: 'Terminal tabs',
newTerminal: 'New terminal',
closePanel: 'Close panel',
closeTab: 'Close {label}',
diff --git a/web/src/i18n/locales/ja.ts b/web/src/i18n/locales/ja.ts
index c5efb245..c5d322b0 100644
--- a/web/src/i18n/locales/ja.ts
+++ b/web/src/i18n/locales/ja.ts
@@ -1094,7 +1094,7 @@ export default {
pip: {
computerUse: 'コンピュータ操作',
shots: '{n} 枚',
- collapse: '隠す',
+ collapse: 'しまう',
expand: '表示',
openFull: '拡大',
shrink: '縮小',
@@ -1102,7 +1102,27 @@ export default {
latest: '最新',
},
+ statusPanel: {
+ title: 'タスク状態',
+ summary: '現在のタスク履歴',
+ empty: 'Todo、計画、成果物はまだありません',
+ open: 'タスク状態を開く',
+ collapse: 'タスク状態をしまう',
+ tabs: 'タスク状態セクション',
+ todos: 'Todo List',
+ plans: 'Plan 履歴',
+ artifacts: '成果物',
+ currentProgress: '現在の計画進捗',
+ noPlans: 'Plan 履歴はまだありません',
+ planFallback: '計画の更新',
+ todoCount: 'Todo {count} 件',
+ },
+
rightPanel: {
+ workspace: 'ワークパネル',
+ workspaceSummary: '現在のタスクとコンテキストを共有します',
+ workspaceNavigation: 'ワークパネルのセクション',
+ closeWorkspace: 'ワークパネルを閉じる',
plan: '計画',
files: 'ファイル',
changes: '変更',
@@ -1114,6 +1134,7 @@ export default {
},
artifacts: {
+ preview: '成果物をプレビュー',
new: '新しい成果物',
empty: '成果物はまだありません',
select: '成果物を選択',
@@ -1264,6 +1285,7 @@ export default {
},
terminal: {
+ tabs: 'ターミナルタブ',
newTerminal: '新しいターミナル',
closePanel: 'パネルを閉じる',
closeTab: '{label} を閉じる',
diff --git a/web/src/i18n/locales/ko.ts b/web/src/i18n/locales/ko.ts
index 5d241bbc..dc4a1fa6 100644
--- a/web/src/i18n/locales/ko.ts
+++ b/web/src/i18n/locales/ko.ts
@@ -1093,7 +1093,7 @@ export default {
pip: {
computerUse: '컴퓨터 제어',
shots: '스크린샷 {n}장',
- collapse: '숨기기',
+ collapse: '접기',
expand: '보기',
openFull: '확대',
shrink: '축소',
@@ -1101,7 +1101,27 @@ export default {
latest: '최신',
},
+ statusPanel: {
+ title: '작업 상태',
+ summary: '현재 작업 기록',
+ empty: 'Todo, 계획 또는 산출물이 아직 없습니다',
+ open: '작업 상태 열기',
+ collapse: '작업 상태 접기',
+ tabs: '작업 상태 영역',
+ todos: 'Todo List',
+ plans: 'Plan 기록',
+ artifacts: '산출물',
+ currentProgress: '현재 계획 진행률',
+ noPlans: 'Plan 기록이 없습니다',
+ planFallback: '계획 업데이트',
+ todoCount: 'Todo {count}개',
+ },
+
rightPanel: {
+ workspace: '작업 패널',
+ workspaceSummary: '현재 작업과 컨텍스트를 공유합니다',
+ workspaceNavigation: '작업 패널 섹션',
+ closeWorkspace: '작업 패널 닫기',
plan: '계획',
files: '파일',
changes: '변경',
@@ -1113,6 +1133,7 @@ export default {
},
artifacts: {
+ preview: '산출물 미리보기',
new: '새 산출물',
empty: '아직 산출물이 없습니다',
select: '산출물 선택',
@@ -1263,6 +1284,7 @@ export default {
},
terminal: {
+ tabs: '터미널 탭',
newTerminal: '새 터미널',
closePanel: '패널 닫기',
closeTab: '{label} 닫기',
diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts
index cbb8804a..0e6b2805 100644
--- a/web/src/i18n/locales/zh-Hans.ts
+++ b/web/src/i18n/locales/zh-Hans.ts
@@ -1138,7 +1138,7 @@ export default {
pip: {
computerUse: '电脑操控',
shots: '{n} 张截图',
- collapse: '隐藏',
+ collapse: '收回',
expand: '显示',
openFull: '放大',
shrink: '缩小',
@@ -1146,7 +1146,27 @@ export default {
latest: '最新',
},
+ statusPanel: {
+ title: '任务状态',
+ summary: '当前任务历史',
+ empty: '暂无 Todo、Plan 或产物',
+ open: '打开任务状态',
+ collapse: '收回任务状态',
+ tabs: '任务状态分区',
+ todos: 'Todo List',
+ plans: 'Plan 历史',
+ artifacts: '产物',
+ currentProgress: '当前计划进程',
+ noPlans: '暂无 Plan 历史',
+ planFallback: '计划更新',
+ todoCount: '{count} 个 Todo',
+ },
+
rightPanel: {
+ workspace: '工作面板',
+ workspaceSummary: '与当前任务共享上下文',
+ workspaceNavigation: '工作面板分区',
+ closeWorkspace: '关闭工作面板',
plan: '计划',
files: '文件',
changes: '变更',
@@ -1158,6 +1178,7 @@ export default {
},
artifacts: {
+ preview: '预览产物',
new: '有新产物',
empty: '暂无产物',
select: '选择一个产物',
@@ -1308,6 +1329,7 @@ export default {
},
terminal: {
+ tabs: '终端标签页',
newTerminal: '新建终端',
closePanel: '关闭面板',
closeTab: '关闭 {label}',
diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts
index 371cce0f..ff2b56f2 100644
--- a/web/src/i18n/locales/zh-Hant.ts
+++ b/web/src/i18n/locales/zh-Hant.ts
@@ -1088,7 +1088,7 @@ export default {
pip: {
computerUse: '電腦操控',
shots: '{n} 張截圖',
- collapse: '隱藏',
+ collapse: '收回',
expand: '顯示',
openFull: '放大',
shrink: '縮小',
@@ -1096,7 +1096,27 @@ export default {
latest: '最新',
},
+ statusPanel: {
+ title: '工作狀態',
+ summary: '目前工作歷史',
+ empty: '暫無 Todo、Plan 或產物',
+ open: '開啟工作狀態',
+ collapse: '收回工作狀態',
+ tabs: '工作狀態分區',
+ todos: 'Todo List',
+ plans: 'Plan 歷史',
+ artifacts: '產物',
+ currentProgress: '目前計劃進度',
+ noPlans: '暫無 Plan 歷史',
+ planFallback: '計劃更新',
+ todoCount: '{count} 個 Todo',
+ },
+
rightPanel: {
+ workspace: '工作面板',
+ workspaceSummary: '與目前工作共享上下文',
+ workspaceNavigation: '工作面板分區',
+ closeWorkspace: '關閉工作面板',
plan: '計劃',
files: '檔案',
changes: '變更',
@@ -1108,6 +1128,7 @@ export default {
},
artifacts: {
+ preview: '預覽產物',
new: '有新產物',
empty: '暫無產物',
select: '選擇一個產物',
@@ -1258,6 +1279,7 @@ export default {
},
terminal: {
+ tabs: '終端機分頁',
newTerminal: '新增終端機',
closePanel: '關閉面板',
closeTab: '關閉 {label}',
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
index 4149a2e2..5bd3b269 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -96,6 +96,19 @@ export interface TodoItem {
status: 'pending' | 'in_progress' | 'completed' | 'cancelled'
}
+/** A durable plan revision reconstructed from session plan_update and
+ * todo_snapshot entries. Todo snapshots are the plan format used by the web
+ * agent today; legacy explicit plans retain their markdown content. */
+export interface PlanHistoryEntry {
+ id: string
+ title: string
+ status: string
+ content?: string
+ feedback?: string
+ todos: TodoItem[]
+ timestamp: number
+}
+
export interface FileItem {
name: string
is_dir: boolean