From 298c41ff67bd9dd63cb6a9123d0998700d3a7f48 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 30 Sep 2026 08:04:21 +0000 Subject: [PATCH] Make the right rail pure inbox and move deep views into overlay workspaces (T4) Why: D25 final state - the right rail answers only "what needs me now" (inbox + a pinned current-task card); artifacts/agents/plugin panels become overlay workspaces with Cmd shortcuts and a left-rail quick section; DocViewer/AgentTracker migrate onto the shared OverlayHost. Evidence: tsc clean; vitest 9/9; vite build ok; delegation-only logic (overlay semantics covered by ui OverlayHost suite). Impact: Workbench drops classic tabs and gains CurrentTaskCard (plan+todos merged); new Workspaces.tsx (workspaceEntries, Cmd1-9 handler, builtin/plugin/server workspace rendering); store gains activeWorkspace/serverWorkspaces + open/close; ProjectNav quick section; Conversation project badge (D26); DocViewer/AgentTracker render through OverlayHost. Refs: docs/proposals/2026-09-30-ui-inbox-command-workspace.md T4; task card feature/workspaces-r1 --- src/App.tsx | 8 ++ src/api.ts | 3 +- src/components/AgentTracker.tsx | 5 +- src/components/Conversation.tsx | 2 + src/components/DocViewer.tsx | 5 +- src/components/ProjectNav.tsx | 12 +++ src/components/Workbench.tsx | 141 +++++++++++--------------------- src/components/Workspaces.tsx | 92 +++++++++++++++++++++ src/store.ts | 10 +++ src/styles.css | 14 ++++ tsconfig.tsbuildinfo | 2 +- 11 files changed, 194 insertions(+), 100 deletions(-) create mode 100644 src/components/Workspaces.tsx diff --git a/src/App.tsx b/src/App.tsx index c83ed83..48b7a77 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { store, useStore } from './store' import { ProjectNav } from './components/ProjectNav' import { Conversation } from './components/Conversation' import { Workbench } from './components/Workbench' +import { Workspaces, workspaceShortcuts } from './components/Workspaces' import { AgentTracker } from './components/AgentTracker' import { SettingsModal } from './components/SettingsModal' import { DocViewer } from './components/DocViewer' @@ -40,6 +41,12 @@ function LoginGate({ onSuccess }: { onSuccess: () => void }) { } export function App() { + useEffect(() => { + const h = (e: KeyboardEvent) => workspaceShortcuts(e) + window.addEventListener('keydown', h, true) + return () => window.removeEventListener('keydown', h, true) + }, []) + const ui = useStore(s => s.ui) const toast = useStore(s => s.toast) const trackerRunId = useStore(s => s.ui.trackerRunId) @@ -138,6 +145,7 @@ export function App() { )} + {trackerRunId && } {ui.settingsOpen && } {viewingArtifact && } diff --git a/src/api.ts b/src/api.ts index 2f0cef4..f423d10 100644 --- a/src/api.ts +++ b/src/api.ts @@ -78,7 +78,8 @@ export const api = { listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()), listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()), reloadPlugins: () => auth.fetch(`${BASE}/api/plugins/reload`, { method: 'POST' }).then(r => r.json()), - listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name: string; description: string }[]>, + listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string }[]>, + listWorkspaces: () => auth.fetch(`${BASE}/api/workspaces`).then(r => r.json()) as Promise<{ id: string; title: string; icon?: string; shortcut?: string; badgeTool?: string; body?: any[]; refreshTool?: string }[]>, } // ---- SSE 客户端:Last-Event-ID 游标续传;断线自动重连 ---- diff --git a/src/components/AgentTracker.tsx b/src/components/AgentTracker.tsx index 66613e2..1052836 100644 --- a/src/components/AgentTracker.tsx +++ b/src/components/AgentTracker.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { api } from '../api' import { store, useStore } from '../store' +import { OverlayHost } from '@ui' import type { Message, Run, Usage } from '../contracts' export function AgentTracker() { @@ -24,7 +25,7 @@ export function AgentTracker() { const focusMsgs = focus === runId ? messagesAll.filter(m => m.runId === runId) : subMessages[focus] ?? [] return ( -
{ if (e.target === e.currentTarget) store.setUI({ trackerRunId: null }) }}> + store.setUI({ trackerRunId: null })}>
🤖 Sub-agent 追踪 @@ -75,7 +76,7 @@ export function AgentTracker() { )}
-
+ ) } diff --git a/src/components/Conversation.tsx b/src/components/Conversation.tsx index f6cd13d..a347a43 100644 --- a/src/components/Conversation.tsx +++ b/src/components/Conversation.tsx @@ -199,6 +199,7 @@ export function Conversation() { const artifacts = useStore(s => s.artifacts) const profiles = useStore(s => s.profiles) const cards = useStore(s => s.cards) + const projectName = useStore(s => s.projects.find(p => p.id === s.currentProjectId)?.name ?? '项目') const [text, setText] = useState('') const [attachmentIds, setAttachmentIds] = useState([]) @@ -235,6 +236,7 @@ export function Conversation() { return (
+ {projectName}
{conversation?.title ?? '会话'}
setNewName(e.target.value)} diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx index 4facd2e..8532c3e 100644 --- a/src/components/Workbench.tsx +++ b/src/components/Workbench.tsx @@ -1,7 +1,8 @@ -// 右栏工作台:收件箱(D25 置首)+ Plan / Todos / Artifacts / Agents + 插件面板;tab 显隐排序见设置。 -import { store, useStore, isTerminal } from '../store' +// 右栏终态(提案 T4/D25):纯收件箱 + 「当前任务」常驻卡(计划/待办并入;工件/代理/ +// 插件面板迁为工作区弹层,见 Workspaces.tsx)。 +import { store, useStore } from '../store' import type { Run } from '../contracts' -import { UIRenderer, InboxList, pendingCount, type DocBridge, type ActivityCard } from '@ui' +import { InboxList, pendingCount, type ActivityCard, type DocBridge } from '@ui' // 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。 export const docBridge: DocBridge = { @@ -13,127 +14,79 @@ export const docBridge: DocBridge = { } export function Workbench() { - const ui = useStore(s => s.ui) + const cards = useStore(s => s.cards) const plan = useStore(s => s.plan) const todos = useStore(s => s.todos) - const artifacts = useStore(s => s.artifacts) - const runs = useStore(s => s.runs) - const pluginPanels = useStore(s => s.pluginPanels) - const cards = useStore(s => s.cards) const pending = pendingCount(cards) - const tabs: { id: string; label: string }[] = [ - { id: 'inbox', label: pending > 0 ? `收件箱 ${pending}` : '收件箱' }, - { id: 'plan', label: '计划' }, - { id: 'todos', label: '待办' }, - { id: 'artifacts', label: `工件${artifacts.length ? ` ${artifacts.length}` : ''}` }, - { id: 'agents', label: '代理' }, - ...pluginPanels.map(p => ({ id: `plugin:${p.id}`, label: p.title })), - ] - return ( <>
- {tabs.map(t => ( - - ))} - + 收件箱{pending > 0 ? ` ${pending}` : ''} +
- {ui.rightTab === 'inbox' && } - {ui.rightTab === 'plan' && } - {ui.rightTab === 'todos' && } - {ui.rightTab === 'artifacts' && } - {ui.rightTab === 'agents' && } - {ui.rightTab.startsWith('plugin:') && ( - `plugin:${p.id}` === ui.rightTab)?.body ?? []} docBridge={docBridge} /> - )} + + store.cardAction(c, id), + onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p), + onDismiss: (c: ActivityCard) => store.cardDismiss(c), + onLocate: (c: ActivityCard) => store.openInbox(c.id), + }} />
) } -function InboxTab({ cards }: { cards: ActivityCard[] }) { - return store.cardAction(c, id), - onPin: (c, p) => store.cardPin(c, p), - onDismiss: c => store.cardDismiss(c), - }} /> -} - -function StatusIcon({ status }: { status?: string }) { - if (status === 'done' || status === 'completed') return ● - if (status === 'in_progress' || status === 'running') return ◐ - if (status === 'skipped') return ◌ - return ○ -} - -function PlanTab({ plan }: { plan: { title: string; steps: { id: string; text: string; status: string; agentRun?: string }[] } | null }) { - if (!plan?.steps?.length) return +/** 当前任务常驻卡(D25 R4):计划+待办合成一张卡,钉在收件箱顶部——右栏只回答"现在要我做什么"。 */ +export function CurrentTaskCard({ plan, todos }: { + plan: { title: string; steps: { id: string; text: string; status: string; agentRun?: string }[] } | null + todos: { items: { id: string; text: string; status: string }[] } | null +}) { + const steps = plan?.steps ?? [] + const items = todos?.items ?? [] + if (steps.length === 0 && items.length === 0) return null + const done = [...steps, ...items].filter(x => x.status === 'done' || x.status === 'completed').length + const total = steps.length + items.length return ( -
-
{plan.title}
- {plan.steps.map(s => ( +
+
+ ● + {plan?.title ?? '当前任务'} + {done}/{total} +
+ {steps.map(s => (
- + {s.text} {s.agentRun && }
))} + {items.map(t => ( +
+ + {t.text} +
+ ))}
) } -function TodosTab({ todos }: { todos: { items: { id: string; text: string; status: string }[] } | null }) { - if (!todos?.items?.length) return - return <>{todos.items.map(t => ( -
- - {t.text} -
- ))} +export function StatusIcon({ status }: { status?: string }) { + if (status === 'done' || status === 'completed') return ● + if (status === 'in_progress' || status === 'running') return ◐ + if (status === 'skipped') return ◌ + return ○ } -function ArtifactsTab() { - const artifacts = useStore(s => s.artifacts) - if (artifacts.length === 0) return - return <>{artifacts.map(a => ( -
store.viewArtifact(a)}> - {a.kind === 'image' ? '🖼' : a.kind === 'document' ? '📄' : '🗂'} - {a.name} - {a.kind} -
- ))} -} - -function AgentsTab({ runs }: { runs: Run[] }) { - const convRuns = runs.filter(r => r.kind !== 'tool') - if (convRuns.length === 0) return - // 任务树:顶层 run + 其子代理。 - const roots = convRuns.filter(r => !r.parentRunId) - return
- {roots.map(r => ( -
- - {convRuns.filter(c => c.parentRunId === r.id).map(c => )} -
- ))} -
-} - -function AgentRow({ run, depth }: { run: Run; depth: number }) { +/** 代理行(工作区弹层复用;点击进 AgentTracker)。 */ +export function AgentRow({ run, depth }: { run: Run; depth: number }) { const label = run.kind === 'subagent' ? `🤖 ${run.agentName || '子代理'}` : '💬 对话任务' - const terminal = isTerminal(run.status) return (
store.openTracker(run.id)}> - {label} - {terminal ? run.status : (run.phase || run.status)} + {label} + {run.status}
) } - -function Empty({ text }: { text: string }) { - return
{text}
-} diff --git a/src/components/Workspaces.tsx b/src/components/Workspaces.tsx new file mode 100644 index 0000000..edff518 --- /dev/null +++ b/src/components/Workspaces.tsx @@ -0,0 +1,92 @@ +// 工作区弹层(提案 T4/D25):工件/代理/插件面板/插件声明工作区统一经 OverlayHost 呈现; +// ⌘1–9 快捷键与左栏快捷区共享同一清单(workspaceEntries)。 +import { store, useStore } from '../store' +import type { Run } from '../contracts' +import { OverlayHost, type DocBridge } from '@ui' +import { docBridge } from './Workbench' +import { AgentRow } from './Workbench' + +/** 工作区清单(顺序即 ⌘ 序号):内置在前,插件面板/声明工作区随后。 */ +export function workspaceEntries(): { key: string; title: string; icon: string; shortcut: string }[] { + const s = store.getState() + const out = [ + { key: 'artifacts', title: '工件', icon: '📦', shortcut: '⌘1' }, + { key: 'agents', title: '代理', icon: '🤖', shortcut: '⌘2' }, + ...s.pluginPanels.map((p, i) => ({ key: `plugin:${p.id}`, title: p.title, icon: '🧩', shortcut: `⌘${i + 3}` })), + ...s.serverWorkspaces.map((w, i) => ({ key: `ws:${w.id}`, title: w.title, icon: w.icon || '🗂', shortcut: w.shortcut ? `⌘${w.shortcut}` : `⌘${i + 3 + s.pluginPanels.length}` })), + ] + return out.slice(0, 9) +} + +export function Workspaces() { + const active = useStore(s => s.ui.activeWorkspace) + const artifacts = useStore(s => s.artifacts) + const runs = useStore(s => s.runs) + const pluginPanels = useStore(s => s.pluginPanels) + const serverWorkspaces = useStore(s => s.serverWorkspaces) + if (!active) return null + const entry = workspaceEntries().find(e => e.key === active) + const close = () => store.closeWorkspace() + + if (active === 'artifacts') { + return + + + } + if (active === 'agents') { + const convRuns = runs.filter(r => r.kind !== 'tool') + return + + + } + if (active.startsWith('plugin:')) { + const panel = pluginPanels.find(p => `plugin:${p.id}` === active) + return store.refreshPlugins()} /> + } + if (active.startsWith('ws:')) { + const ws = serverWorkspaces.find(w => `ws:${w.id}` === active) + return store.refreshPlugins()} /> + } + return null +} + +/** 工件面板(自 Workbench tabs 迁入;点击进 DocViewer 深读)。 */ +export function ArtifactsPanel() { + const artifacts = useStore(s => s.artifacts) + if (artifacts.length === 0) return
暂无工件——上传附件或让模型产出文档(mock:#doc)
+ return <>{artifacts.map(a => ( +
store.viewArtifact(a)}> + {a.kind === 'image' ? '🖼' : a.kind === 'document' ? '📄' : '🗂'} + {a.name} + {a.kind} +
+ ))} +} + +/** 代理面板(任务树;点击进 AgentTracker 深读)。 */ +export function AgentsPanel({ runs }: { runs: Run[] }) { + if (runs.length === 0) return
暂无运行——多代理任务会在此形成任务树(mock:#agents)
+ const roots = runs.filter(r => !r.parentRunId) + return
+ {roots.map(r => ( +
+ + {runs.filter(c => c.parentRunId === r.id).map(c => )} +
+ ))} +
+} + +/** ⌘1–9 / Ctrl+1–9 全局快捷键(挂载于 App)。 */ +export function workspaceShortcuts(e: KeyboardEvent) { + if (!(e.metaKey || e.ctrlKey)) return + const n = parseInt(e.key, 10) + if (!Number.isInteger(n) || n < 1 || n > 9) return + const entries = workspaceEntries() + const hit = entries[n - 1] + if (!hit) return + e.preventDefault() + store.openWorkspace(hit.key) +} diff --git a/src/store.ts b/src/store.ts index 004d0e4..9e16cd1 100644 --- a/src/store.ts +++ b/src/store.ts @@ -34,6 +34,7 @@ export interface UIState { settingsOpen: boolean trackerRunId: string | null viewingArtifact: Artifact | null + activeWorkspace: string | null } interface AppState { @@ -53,6 +54,7 @@ interface AppState { cards: ActivityCard[] profiles: AgentProfile[] pluginPanels: PluginPanel[] + serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[] ui: UIState connected: boolean error: string | null @@ -65,6 +67,7 @@ const DEFAULT_UI: UIState = { rightWidth: 340, rightCollapsed: false, rightTab: 'inbox', + activeWorkspace: null, theme: 'dark', autoCollapseProcess: true, settingsOpen: false, @@ -85,6 +88,7 @@ let state: AppState = { projects: [], currentProjectId: null, conversations: [], currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, artifacts: [], cards: [], profiles: [], pluginPanels: [], + serverWorkspaces: [], ui: loadUI(), connected: false, error: null, toast: null, } @@ -391,6 +395,10 @@ export const store = { const panels = list.flatMap(p => p.panels ?? []) set({ pluginPanels: panels }) } catch { /* ignore */ } + try { + const ws = await api.listWorkspaces() + set({ serverWorkspaces: ws ?? [] }) + } catch { /* ignore */ } }, async uploadAttachment(file: File): Promise { @@ -409,6 +417,8 @@ export const store = { setUI, toggleLeft() { setUI({ leftCollapsed: !state.ui.leftCollapsed }) }, toggleRight() { setUI({ rightCollapsed: !state.ui.rightCollapsed }) }, + openWorkspace(id: string) { setUI({ activeWorkspace: id }) }, + closeWorkspace() { setUI({ activeWorkspace: null }) }, openInbox(cardId?: string) { setUI({ rightCollapsed: false, rightTab: 'inbox' }) if (cardId) setTimeout(() => { diff --git a/src/styles.css b/src/styles.css index 1038ba3..4d43a27 100644 --- a/src/styles.css +++ b/src/styles.css @@ -182,3 +182,17 @@ button { font-family: inherit; } background: #3b82f6; color: #fff; font-size: 14px; cursor: pointer; } .login-err { margin-top: 10px; font-size: 12px; color: #f87171; } + +/* 工作区快捷区(T4/D26 左栏)与当前任务常驻卡(D25 R4) */ +.ws-quick{padding:6px 8px 8px;border-bottom:1px solid var(--border)} +.ws-quick-list{display:flex;flex-direction:column;gap:2px;margin-top:4px} +.ws-quick-item{display:flex;align-items:center;gap:8px;padding:4px 8px;border:none;background:transparent;color:inherit;cursor:pointer;border-radius:6px;text-align:left;font-size:12px} +.ws-quick-item:hover{background:rgba(120,160,255,.12)} +.ws-quick-item .sc{margin-left:auto;font-size:10px;color:var(--text-faint);border:1px solid var(--border);border-radius:3px;padding:0 4px} +.current-task{border:1px solid var(--border);border-radius:8px;padding:8px 10px;margin-bottom:8px;background:rgba(120,160,255,.05)} +.ct-head{display:flex;align-items:center;gap:8px;font-weight:600;margin-bottom:6px} +.ct-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.ct-progress{font-size:11px;color:var(--text-faint)} +.project-badge{font-size:11px;padding:1px 8px;border-radius:8px;background:rgba(120,160,255,.15);color:var(--accent);white-space:nowrap} +.tab-hint{font-size:10px;color:var(--text-faint);align-self:center} +.empty-note{color:var(--text-faint);font-size:12px;padding:12px 4px} diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index fe98e6f..00c22a1 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/main.tsx","./src/store.ts","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/ProjectNav.tsx","./src/components/SettingsModal.tsx","./src/components/Workbench.tsx","./test/auth.test.ts","./test/contracts.test.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/main.tsx","./src/store.ts","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/ProjectNav.tsx","./src/components/SettingsModal.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts"],"version":"5.9.3"} \ No newline at end of file