diff --git a/design/jcode-desktop-redesign-v1-floating.html b/design/jcode-desktop-redesign-v1-floating.html new file mode 100644 index 00000000..f09d38f3 --- /dev/null +++ b/design/jcode-desktop-redesign-v1-floating.html @@ -0,0 +1,149 @@ + + + + + +jcode desktop redesign · 方案 A 双区域工作台 + + + +
+
+
+
诊断 jcode Desktop 面板占用空间问题
+
+
+
+ +
+
+ + +
+ + +
+
+
+
+
任务状态跨会话汇总
+
+
+
当前计划进程5 / 7
+
+
拆解现有三栏布局与交互状态完成
+
分离浮动状态面板与右侧工作窗口完成
+
聚合历史 Plan 与 Artifacts完成
+
保留输入框上方当前计划完成
+
右侧窗口支持新增工作标签完成
+
验证两个区域互不干扰进行中
+
整理组件迁移清单待处理
+
+
+
Desktop 面板重构
7 个步骤 · 当前任务
刚刚
修复 Cloud relay 重连
5 个步骤 · 已完成
2 天前
浏览器 Bridge 调试计划
8 个步骤 · 已完成
5 天前
主题 Token 迁移
4 个步骤 · 已停止
上周
+
desktop-layout-spec.md
交互与空间规格
MD
right-workspace.html
可交互设计原型
HTML
component-mapping.json
现有组件迁移映射
JSON
responsive-review.png
窗口尺寸验收截图
PNG
+
+ +
+
最后两张是当前 jcode desktop 的样式,我希望按照前三张的 UI 进行重新设计。
+
JJCODE
已拆分状态面板与工作窗口

两个区域承担不同职责

浮动状态面板覆盖在聊天内容上,不改变布局,用来查看 Todo List、所有历史 Plan 和当前任务产生的 Artifacts。

右侧工作窗口是独立区域,通过「+」新建辅助对话或浏览器标签;终端从底部展开,不占用右侧窗口。

区域内容空间行为
浮动状态Todo / Plan 历史 / Artifacts覆盖聊天,不改变宽度
右侧窗口辅助对话 / 浏览器独立分栏,可关闭
底部终端Terminal 会话向上展开,可收回
+
+ +
向 JCODE 提问,或输入 / 查看命令
+
>_终端1
jcode ~/workpath/jjj/jcode
$ make lint-web
✓ web typecheck passed
✓ jcode-ui packages passed

$
+
面板已打开
+
+
+
+
+ + + diff --git a/design/jcode-desktop-redesign-v2-dock.html b/design/jcode-desktop-redesign-v2-dock.html new file mode 100644 index 00000000..673d090a --- /dev/null +++ b/design/jcode-desktop-redesign-v2-dock.html @@ -0,0 +1,49 @@ + + + + +jcode desktop redesign · 方案 B 轨道式工具坞 + + + +
+
Desktop 工具面板重构 · 方案 B
+ +
+
Agent 在线
+
+
按照参考图重新设计,但不要让工具面板一直压缩对话区。
JJCODE
已读取当前布局与交互实现

方案 B:轨道式工具坞

右侧只保留一条 56px 的工具轨道。面板打开时覆盖在对话上方,不触发布局重排,因此编辑器、终端和计划之间切换时,内容宽度不会反复跳动。

为什么它适合桌面端
工具位置固定,形成稳定的肌肉记忆
面板作为临时抽屉,不挤压正在阅读的对话
顶部标签保留跨工具工作的上下文

点击右侧图标可切换计划、文件、变更、终端与浏览器;再次点击当前图标即可收回。

向 JCODE 提问,或输入 / 查看命令
+
+ + +
+ + + diff --git a/design/jcode-desktop-redesign-v3-focus.html b/design/jcode-desktop-redesign-v3-focus.html new file mode 100644 index 00000000..0dde66db --- /dev/null +++ b/design/jcode-desktop-redesign-v3-focus.html @@ -0,0 +1,52 @@ + + + + +jcode desktop redesign · 方案 C 聚焦画布 + + + +
+
JCODE
+ +
+
希望工具能力都保留,但默认界面更像参考图一样干净。
JJCODE
已完成界面空间模型分析

方案 C:聚焦画布

计划、文件和变更保留在集中工作面板;Terminal 固定从底部展开。内置浏览器与辅助对话暂不进入本轮实现。

任务状态不再拆成 TabTodo List、所有 Plan 历史和 Artifacts 按区块直接显示在同一个浮动面板里,打开后可以连续浏览。
状态浮窗Todo / Plan / Artifacts
Terminal底部停靠
工作面板计划 / 文件 / 变更
+
5 / 7 · Desktop 面板重构正在验证不同窗口尺寸
验证 13 / 14 / 16 英寸窗口
已运行 28 秒

✓ Todo List 5 / 7

拆分状态浮窗与工作面板完成
验证不同窗口尺寸进行中

◷ Plan 历史 3

Desktop 面板重构刚刚
主题 Token 迁移上周

▧ Artifacts 2

panel-layout-spec.mdr3
desktop-status-preview.htmlr1
+
向 JCODE 提问,或输入 / 查看命令
+
>_终端1
jcode ~/workpath/jjj/jcode
$ make lint-web
✓ web typecheck passed
✓ jcode-ui packages passed

$
+
+
+
工作面板与当前任务共享上下文
+
+ + + diff --git a/design/jcode-desktop-redesign.html b/design/jcode-desktop-redesign.html new file mode 100644 index 00000000..9fb12255 --- /dev/null +++ b/design/jcode-desktop-redesign.html @@ -0,0 +1,14 @@ + + + + +jcode Desktop redesign · 方案总览 + + +
JCODE DESKTOP · INTERACTION STUDY

让工具随时可达,
但不再永久占据画布。

三套方案都从参考图提取同一套空间原则:工具从固定侧栏变成可关闭的工作面板;运行状态收回成悬浮卡或胶囊;终端与浏览器只在需要时出现。

可关闭标签页悬浮任务进度对话宽度稳定终端按需出现保留工作区导航
+
A双区域信息架构

浮动状态 + 右侧工作窗口

浮动面板汇总 Todo、历史 Plan 与 Artifacts;输入框上方保留当前计划。右侧窗口负责辅助工作,终端从底部展开。

职责完全分离打开完整原型 ↗
+
B工具切换效率优先

轨道式工具坞

右侧保留 56px 工具轨道,所有面板覆盖打开、不触发布局重排。适合文件、变更和终端之间高频切换。

+
C内容空间优先 当前推荐

聚焦画布 + 聚合状态

工作面板只保留计划、文件、变更,终端固定底部;Todo、Plan 历史和 Artifacts 直接汇总在同一个状态浮窗。

空间与职责最清晰打开完整原型 ↗
+
diff --git a/web/src/App.tsx b/web/src/App.tsx index b9e92444..ceb718c0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -58,6 +58,7 @@ import { CloudSyncToggle } from './components/CloudSyncToggle' import { DesktopTitlebar } from './components/DesktopTitlebar' import { ComputerShotPiP } from './components/ComputerShotPiP' import { RightPanel } from './components/RightPanel' +import { StatusPanel } from './components/StatusPanel' import { TerminalPanel } from './components/TerminalPanel' import { RemoteConnectWizard } from './components/RemoteConnectWizard' import type { RemotePrefill } from './lib/remote' @@ -184,10 +185,12 @@ function Shell({ activeView }: { activeView: 'chat' | 'automations' | 'cloud-mob const isRunning = useAppSelector((s) => s.chat.isRunning) const wsConnected = useAppSelector((s) => s.session.wsConnected) - // Panel state — a right panel (files/changes/plan/artifacts) and a - // bottom panel (terminal) that can be open simultaneously. + // Two independent regions: a floating status panel (todos/plan history/ + // artifacts), plus a temporary workspace overlay (plan/files/changes). + // Terminal stays bottom-docked and keeps its size when either overlay opens. const [rightPanelOpen, setRightPanelOpen] = useState(false) - const [rightPanelTab, setRightPanelTab] = useState<'files' | 'changes' | 'plan' | 'artifacts'>('files') + const [rightPanelTab, setRightPanelTab] = useState<'plan' | 'files' | 'changes'>('plan') + const [statusPanelOpen, setStatusPanelOpen] = useState(false) const [bottomPanel, setBottomPanel] = useState<'none' | 'terminal'>('none') const [bottomPanelHeight, setBottomPanelHeight] = useState(260) const [activeRun, setActiveRun] = useState(null) @@ -210,6 +213,10 @@ function Shell({ activeView }: { activeView: 'chat' | 'automations' | 'cloud-mob setBottomPanel((p) => (p === 'terminal' ? 'none' : 'terminal')) return } + if (panel === 'artifacts') { + setStatusPanelOpen((open) => !open) + return + } setRightPanelTab((current) => { if (rightPanelOpen && current === panel) { setRightPanelOpen(false) @@ -237,16 +244,18 @@ function Shell({ activeView }: { activeView: 'chat' | 'automations' | 'cloud-mob // ⌘` never reaches the page on macOS (OS window cycling), so ⌘J is the // alias shown in the UI. `!e.shiftKey` keeps ⇧⌘J (DevTools) intact. e.preventDefault(); togglePanel('terminal') + } else if (e.key === 'Escape') { + if (rightPanelOpen) setRightPanelOpen(false) + else if (statusPanelOpen) setStatusPanelOpen(false) } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [togglePanel]) + }, [rightPanelOpen, statusPanelOpen, togglePanel]) useEffect(() => { function onArtifactUpserted() { - setRightPanelTab('artifacts') - setRightPanelOpen(true) + setStatusPanelOpen(true) } window.addEventListener('jcode:artifact-upserted', onArtifactUpserted) return () => window.removeEventListener('jcode:artifact-upserted', onArtifactUpserted) @@ -334,6 +343,16 @@ function Shell({ activeView }: { activeView: 'chat' | 'automations' | 'cloud-mob {/* M18: settings is a first-class view, not an overlay dialog. */} {activeView === 'settings' && } + {activeView === 'chat' && ( + setStatusPanelOpen(true)} + onClose={() => setStatusPanelOpen(false)} + /> + )} + {/* Bottom panel (terminal) — lives under the inset surface. */} {bottomPanel === 'terminal' && (
@@ -348,7 +367,8 @@ function Shell({ activeView }: { activeView: 'chat' | 'automations' | 'cloud-mob )} - {/* Right panel (files/changes/plan/artifacts) — sibling of main. */} + {/* Temporary workspace overlay (plan/files/changes). It is absolutely + positioned, so opening it never reflows the conversation. */} {rightPanelOpen && ( { store.dispatch(uiActions.setView('chat')) store.dispatch(uiActions.setSettingsTab('general')) + store.dispatch(chatActions.clearChat()) +}) + +describe('plan history', () => { + it('reconstructs durable todo snapshots and legacy plans without duplicate revisions', () => { + const history = buildPlanHistory([ + { + type: 'todo_snapshot', + timestamp: '2026-08-04T08:00:00Z', + todos: [{ id: 1, title: 'Inspect layout', status: 'in_progress' }], + }, + { + type: 'todo_snapshot', + timestamp: '2026-08-04T08:01:00Z', + todos: [{ id: 1, title: 'Inspect layout', status: 'in_progress' }], + }, + { + type: 'plan_update', + timestamp: '2026-08-04T08:02:00Z', + plan_status: 'approved', + plan_title: 'Desktop redesign', + plan_content: '1. Inspect\n2. Implement', + }, + { type: 'todo_snapshot', timestamp: '2026-08-04T08:03:00Z', todos: [] }, + ]) + + expect(history).toHaveLength(2) + expect(history[0].title).toBe('Inspect layout') + expect(history[1]).toMatchObject({ title: 'Desktop redesign', status: 'approved' }) + }) + + it('merges status revisions of the same todo plan into its latest snapshot', () => { + const history = buildPlanHistory([ + { + type: 'todo_snapshot', + timestamp: '2026-08-04T08:00:00Z', + todos: [ + { id: 1, title: 'Create tests', status: 'in_progress' }, + { id: 2, title: 'Verify state', status: 'pending' }, + ], + }, + { + type: 'todo_snapshot', + timestamp: '2026-08-04T08:05:00Z', + todos: [ + { id: 2, title: 'Verify state', status: 'completed' }, + { id: 1, title: 'Create tests', status: 'completed' }, + ], + }, + ]) + + expect(history).toHaveLength(1) + expect(history[0]).toMatchObject({ status: 'completed', timestamp: Date.parse('2026-08-04T08:05:00Z') }) + expect(history[0].todos.every((todo) => todo.status === 'completed')).toBe(true) + }) }) describe('ui routing (M18 settings view)', () => { diff --git a/web/src/app/store.ts b/web/src/app/store.ts index 29336767..14bcec46 100644 --- a/web/src/app/store.ts +++ b/web/src/app/store.ts @@ -16,7 +16,7 @@ import { configureStore, createSlice, createAsyncThunk } from '@reduxjs/toolkit' import type { ThreadItem, Message, ToolCall, Approval, TokenSnapshot, Goal, TodoItem, QueuedMessage, AskUserQuestion } from 'jcode-ui-core' import { api } from '../lib/api' import { extractToolDisplayInfo } from '../lib/toolInfo' -import { normalizeMode, type AgentMode, type CustomAgentInfo, type ProviderInfo, type SessionItem, type TaskItem, type ProjectInfo, type SlashCommandInfo, type SessionEntry, type ModelRef } from '../lib/types' +import { normalizeMode, type AgentMode, type CustomAgentInfo, type ProviderInfo, type SessionItem, type TaskItem, type ProjectInfo, type SlashCommandInfo, type SessionEntry, type ModelRef, type PlanHistoryEntry } from '../lib/types' import { i18n, setLocale, SUPPORTED_LOCALES } from '../i18n' import { hydrateTheme } from '../lib/useTheme' @@ -41,6 +41,82 @@ function isNewerTs(candidate: string, current: string | undefined): boolean { return ct > cur } +function normalizeTodoItems(items: SessionEntry['todos']): TodoItem[] { + if (!items) return [] + return items.flatMap((item) => { + if (!['pending', 'in_progress', 'completed', 'cancelled'].includes(item.status)) return [] + return [{ id: item.id, title: item.title, status: item.status as TodoItem['status'] }] + }) +} + +function planEntryFromTodos(todos: TodoItem[], timestamp: number, id: string): PlanHistoryEntry { + const active = todos.find((todo) => todo.status === 'in_progress') + ?? todos.find((todo) => todo.status === 'pending') + ?? todos[0] + const complete = todos.length > 0 && todos.every((todo) => todo.status === 'completed' || todo.status === 'cancelled') + return { + id, + title: active?.title ?? '', + status: complete ? 'completed' : todos.some((todo) => todo.status === 'in_progress') ? 'in_progress' : 'pending', + todos, + timestamp, + } +} + +function planSignature(entry: PlanHistoryEntry): string { + return JSON.stringify({ status: entry.status, title: entry.title, content: entry.content, feedback: entry.feedback, todos: entry.todos }) +} + +function todoPlanKey(entry: PlanHistoryEntry): string { + if (entry.todos.length === 0) return '' + return JSON.stringify( + entry.todos + .map((todo) => ({ id: todo.id, title: todo.title })) + .sort((a, b) => String(a.id).localeCompare(String(b.id))), + ) +} + +function appendOrMergePlanHistory(history: PlanHistoryEntry[], next: PlanHistoryEntry): void { + const previous = history[history.length - 1] + if (!previous) { + history.push(next) + return + } + if (planSignature(previous) === planSignature(next)) return + const previousTodoKey = todoPlanKey(previous) + if (previousTodoKey && previousTodoKey === todoPlanKey(next)) { + history[history.length - 1] = { ...next, id: previous.id } + return + } + history.push(next) +} + +export function buildPlanHistory(entries: SessionEntry[]): PlanHistoryEntry[] { + const history: PlanHistoryEntry[] = [] + for (let index = 0; index < entries.length; index++) { + const entry = entries[index] + const timestamp = ts(entry.timestamp) + let next: PlanHistoryEntry | null = null + if (entry.type === 'todo_snapshot') { + const todos = normalizeTodoItems(entry.todos) + if (todos.length > 0) next = planEntryFromTodos(todos, timestamp, `todo_${index}_${timestamp}`) + } else if (entry.type === 'plan_update') { + next = { + id: `plan_${index}_${timestamp}`, + title: entry.plan_title?.trim() || entry.plan_content?.split('\n').find((line) => line.trim())?.replace(/^#+\s*/, '') || '', + status: entry.plan_status || 'draft', + content: entry.plan_content, + feedback: entry.feedback, + todos: [], + timestamp, + } + } + if (!next) continue + appendOrMergePlanHistory(history, next) + } + return history +} + // ═══════════════════════════════════════════════════════════════════════════ // chat slice — timeline + streaming accumulation // ═══════════════════════════════════════════════════════════════════════════ @@ -55,6 +131,7 @@ interface ChatState { goal: Goal | null goalArmed: boolean todos: TodoItem[] + planHistory: PlanHistoryEntry[] /** Type-ahead queues keyed by session id — a message queued while an agent * runs belongs to THAT conversation and must survive switching away and * back (previously a single global list wiped by clearChat on switch). */ @@ -70,6 +147,7 @@ const initialChat: ChatState = { goal: null, goalArmed: false, todos: [], + planHistory: [], queuedBySession: {}, slashCommands: [], } @@ -89,6 +167,7 @@ const chatSlice = createSlice({ s.goal = null s.goalArmed = false s.todos = [] + s.planHistory = [] // NOTE: queuedBySession is deliberately NOT cleared here — clearChat runs // on every session switch, and stashed type-ahead queues must survive. streamingText = '' @@ -232,6 +311,14 @@ const chatSlice = createSlice({ setTodos(s, a: { payload: TodoItem[] }) { s.todos = a.payload }, + setPlanHistory(s, a: { payload: PlanHistoryEntry[] }) { + s.planHistory = a.payload + }, + appendPlanSnapshot(s, a: { payload: { todos: TodoItem[]; timestamp: number } }) { + if (a.payload.todos.length === 0) return + const next = planEntryFromTodos(a.payload.todos, a.payload.timestamp, `live_${a.payload.timestamp}`) + appendOrMergePlanHistory(s.planHistory, next) + }, enqueueMessage(s, a: { payload: { sessionId: string; message: QueuedMessage } }) { ;(s.queuedBySession[a.payload.sessionId] ??= []).push(a.payload.message) }, @@ -1226,6 +1313,7 @@ export const loadSession = createAsyncThunk( // Any tool calls that never got a result (interrupted session) → done. for (const tc of pendingToolCalls.values()) tc.status = 'done' + dispatch(chatActions.setPlanHistory(buildPlanHistory(entries || []))) dispatch(chatActions.setTimeline(timeline)) if (oneShot) { @@ -1386,6 +1474,7 @@ export const replaySession = createAsyncThunk( } } for (const tc of pendingToolCalls.values()) tc.status = 'done' + dispatch(chatActions.setPlanHistory(buildPlanHistory(entries))) dispatch(chatActions.setTimeline(timeline)) dispatch(chatActions.setRunning(false)) }, diff --git a/web/src/app/wsBridge.ts b/web/src/app/wsBridge.ts index 35ac6a41..023bdd33 100644 --- a/web/src/app/wsBridge.ts +++ b/web/src/app/wsBridge.ts @@ -87,7 +87,10 @@ export function createWSHandlers( } }, onTodoUpdate: () => { - void api.todos().then((todos) => dispatch(chatActions.setTodos(todos))) + void api.todos().then((todos) => { + dispatch(chatActions.setTodos(todos)) + dispatch(chatActions.appendPlanSnapshot({ todos, timestamp: Date.now() })) + }) }, onGoalUpdate: (d) => dispatch(chatActions.setGoal(d as Goal | null)), onApprovalRequest: (d) => diff --git a/web/src/components/ArtifactsPanel.tsx b/web/src/components/ArtifactsPanel.tsx index 17518a11..255f7dc5 100644 --- a/web/src/components/ArtifactsPanel.tsx +++ b/web/src/components/ArtifactsPanel.tsx @@ -88,11 +88,11 @@ function parseCSV(source: string, maxRows = 200, maxColumns = 50): string[][] { return rows } -export function ArtifactsPanel() { +export function ArtifactsPanel({ initialSelectedID = '' }: { initialSelectedID?: string }) { const { t } = useTranslation() const taskId = useAppSelector((state) => state.session.currentSessionId) const [records, setRecords] = useState([]) - const [selectedID, setSelectedID] = useState('') + const [selectedID, setSelectedID] = useState(initialSelectedID) const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [cloudLoggedIn, setCloudLoggedIn] = useState(false) @@ -120,7 +120,7 @@ export function ArtifactsPanel() { } }, [taskId, t]) - useEffect(() => { void load() }, [load]) + useEffect(() => { void load(initialSelectedID) }, [initialSelectedID, load]) useEffect(() => { let active = true void api.cloudStatus().then((status) => { if (active) setCloudLoggedIn(status.logged_in) }).catch(() => undefined) diff --git a/web/src/components/ComputerShotPiP.test.tsx b/web/src/components/ComputerShotPiP.test.tsx new file mode 100644 index 00000000..013813a2 --- /dev/null +++ b/web/src/components/ComputerShotPiP.test.tsx @@ -0,0 +1,62 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { Provider } from 'react-redux' +import type { ThreadItem } from 'jcode-ui-core' +import { chatActions, store } from '../app/store' +import { i18n } from '../i18n' +import { ComputerShotPiP } from './ComputerShotPiP' + +beforeEach(async () => { + cleanup() + await i18n.changeLanguage('en') + store.dispatch(chatActions.clearChat()) +}) + +afterEach(() => { + cleanup() + store.dispatch(chatActions.clearChat()) +}) + +function renderPip(timeline: ThreadItem[]) { + store.dispatch(chatActions.setTimeline(timeline)) + return render() +} + +describe('ComputerShotPiP', () => { + it('shows computer-use screenshots and ignores browser screenshots', () => { + renderPip([ + { + kind: 'tool', + seq: 1, + data: { + id: 'computer-1', + name: 'computer_screenshot', + args: '{}', + status: 'done', + timestamp: 1, + output: 'image_ref=/api/computer/shots/older.png', + }, + }, + { + kind: 'tool', + seq: 2, + data: { + id: 'browser-1', + name: 'browser_screenshot', + args: '{}', + status: 'done', + timestamp: 2, + output: 'image_ref=/api/browser/shots/latest.png', + }, + }, + ]) + + expect(screen.getByText('Computer use')).toBeTruthy() + expect(screen.queryByText('Browser')).toBeNull() + expect(screen.getByRole('img', { name: 'Computer use' }).getAttribute('src')).toContain('/api/computer/shots/older.png') + + fireEvent.click(screen.getByTitle('Minimize')) + expect(screen.getByText('Computer use')).toBeTruthy() + expect(screen.getByTitle('Show')).toBeTruthy() + }) +}) diff --git a/web/src/components/ComputerShotPiP.tsx b/web/src/components/ComputerShotPiP.tsx index 6af1d705..45982038 100644 --- a/web/src/components/ComputerShotPiP.tsx +++ b/web/src/components/ComputerShotPiP.tsx @@ -5,7 +5,7 @@ * the agent captured in this session, so the user can watch what the agent is * seeing without scrolling the thread. Derives its data from the chat timeline: * any tool result whose output carries an `image_ref=/api/computer/shots/…` - * marker (today: computer_screenshot) — no extra WS channel needed, so it also + * marker (computer_screenshot) — no extra WS channel needed, so it also * works when replaying a historical session. * * The card collapses to a small pill (Codex's 显示/Show toggle). "Open full" @@ -58,6 +58,7 @@ export function ComputerShotPiP() { const src = `${apiBase}${latest.ref}` const broken = brokenRef === latest.ref + const title = t('pip.computerUse') if (collapsed) { return ( @@ -65,10 +66,10 @@ export function ComputerShotPiP() { type="button" onClick={() => setCollapsed(false)} title={t('pip.expand')} - className="fixed right-[14px] top-[46px] z-[45] inline-flex items-center gap-1.5 rounded-[var(--radius-pill)] border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] font-medium text-[var(--color-foreground)] shadow-[var(--shadow-md)] transition-colors hover:bg-[var(--color-muted)]" + className="fixed right-[14px] top-[96px] z-[45] inline-flex items-center gap-1.5 rounded-[var(--radius-pill)] border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] font-medium text-[var(--color-foreground)] shadow-[var(--shadow-md)] transition-colors hover:bg-[var(--color-muted)]" > - {t('pip.computerUse')} + {title} {latest.count} ) @@ -78,14 +79,14 @@ export function ComputerShotPiP() { return (
{/* Header */}
- {t('pip.computerUse')} + {title} {t('pip.shots', { n: latest.count })} @@ -125,7 +126,7 @@ export function ComputerShotPiP() { {t('pip.computerUse')} setBrokenRef(latest.ref)} className="w-full object-cover object-top" style={{ maxHeight: expanded ? 'min(70vh, 460px)' : 160 }} diff --git a/web/src/components/RightPanel.test.tsx b/web/src/components/RightPanel.test.tsx new file mode 100644 index 00000000..655799d1 --- /dev/null +++ b/web/src/components/RightPanel.test.tsx @@ -0,0 +1,48 @@ +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 { RightPanel } from './RightPanel' + +beforeEach(async () => { + cleanup() + await i18n.changeLanguage('en') + store.dispatch(chatActions.clearChat()) + store.dispatch(chatActions.setTodos([ + { id: 1, title: 'Keep the conversation width', status: 'in_progress' }, + ])) +}) + +afterEach(() => { + cleanup() + store.dispatch(chatActions.clearChat()) +}) + +describe('RightPanel workspace overlay', () => { + it('floats above the task and exposes only plan, files, and changes', () => { + const onClose = vi.fn() + const onSwitchTab = vi.fn() + + render( + + + , + ) + + const panel = screen.getByTestId('workspace-panel') + expect(screen.getByRole('dialog', { name: 'Work panel' })).toBe(panel) + expect(panel.className).toContain('absolute') + expect(screen.getByRole('navigation', { name: 'Work panel sections' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Plan' }).getAttribute('aria-current')).toBe('page') + expect(screen.getByRole('button', { name: 'Files' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Changes' })).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Artifacts' })).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Files' })) + expect(onSwitchTab).toHaveBeenCalledWith('files') + + fireEvent.click(screen.getByTestId('workspace-backdrop')) + expect(onClose).toHaveBeenCalledOnce() + }) +}) diff --git a/web/src/components/RightPanel.tsx b/web/src/components/RightPanel.tsx index 223f89b9..ea4d9d30 100644 --- a/web/src/components/RightPanel.tsx +++ b/web/src/components/RightPanel.tsx @@ -1,37 +1,35 @@ /** - * RightPanel — right-side panel (width ~20rem, full height, border-left) with - * four tabs (Plan / Files / Changes / Artifacts) and a close button. + * RightPanel — temporary workspace overlay with three context views + * (Plan / Files / Changes). It floats above the current task so opening it + * never resizes the conversation or the bottom terminal. * * Ported from web/src/components/RightPanel.vue. The Vue version imports three - * child components (FileTreePanel.vue / DiffViewer.vue / TaskList.vue) that + * child components (FileTreePanel.vue / DiffViewer.vue) that * don't exist in React yet; per the migration brief they are implemented INLINE * here as simple functional sub-components so the panel is functional now. * - * The Plan tab reads `todos` from the Redux chat slice. The Files and Changes - * tabs fetch their own data on mount (and are keyed on the project path so a + * Files and Changes fetch their own data on mount (and are keyed on the project path so a * project switch remounts + re-fetches them — see the key prop in render). */ import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { - ArrowPathIcon, - CheckCircleIcon, + ArrowsRightLeftIcon, ChevronLeftIcon, ChevronRightIcon, + ClipboardDocumentCheckIcon, DocumentIcon, - EllipsisHorizontalCircleIcon, FolderIcon, - MinusCircleIcon, - NoSymbolIcon, + FolderOpenIcon, XMarkIcon, } from '@heroicons/react/24/outline' import { useAppSelector } from '../app/hooks' import { api } from '../lib/api' -import type { DiffResponse, FileItem, TodoItem } from '../lib/types' -import { ArtifactsPanel } from './ArtifactsPanel' +import type { DiffResponse, FileItem } from '../lib/types' +import { CurrentPlanPane } from './StatusPanel' -type Tab = 'files' | 'changes' | 'plan' | 'artifacts' +type Tab = 'plan' | 'files' | 'changes' interface Props { activeTab: Tab @@ -44,195 +42,88 @@ const TAB_KEYS: Record = { plan: 'rightPanel.plan', files: 'rightPanel.files', changes: 'rightPanel.changes', - artifacts: 'rightPanel.artifacts', +} as const + +const TAB_ICONS = { + plan: ClipboardDocumentCheckIcon, + files: FolderOpenIcon, + changes: ArrowsRightLeftIcon, } as const export function RightPanel({ activeTab, onClose, onSwitchTab }: Props) { const { t } = useTranslation() - // Panel width with a min/max clamp; resized via the left drag handle. - const [panelWidth, setPanelWidth] = useState(activeTab === 'artifacts' ? 560 : 320) const projectPath = useAppSelector((s) => s.session.projectPath) - useEffect(() => { - if (activeTab === 'artifacts') setPanelWidth((width) => Math.max(width, 480)) - }, [activeTab]) - - function startResize(e: React.MouseEvent) { - e.preventDefault() - const startX = e.clientX - const startWidth = panelWidth - - function onMove(ev: MouseEvent) { - // Dragging left grows the panel (the handle is on the left edge). - const dx = startX - ev.clientX - setPanelWidth(Math.min(900, Math.max(220, startWidth + dx))) - } - function onUp() { - window.removeEventListener('mousemove', onMove) - window.removeEventListener('mouseup', onUp) - } - window.addEventListener('mousemove', onMove) - window.addEventListener('mouseup', onUp) - } - return ( -
- -
- {/* Keyed on the project path so switching projects remounts the children - and re-fetches — both only load on mount. */} - {activeTab === 'files' ? ( - - ) : activeTab === 'changes' ? ( - - ) : activeTab === 'artifacts' ? ( - - ) : ( - - )} -
- - ) -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 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 ( -
-
- {t('rightPanel.plan')} -
-
-
- - {completed} / {total} - -
-
- {total > 0 ? :
{t('rightPanel.noTasks')}
} -
-
- ) -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 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 ( +
+ + +
+ ) + })} +
+ +
+
+ {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