From 9c44d6b4978da56862c5ea59eac066e36fe054a0 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 30 Sep 2026 06:58:16 +0000 Subject: [PATCH] Wire inbox tab as the right-rail head with live card state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: T2 frontend of the inbox (D25): the inbox is the first tab with a live pending count; card events (created/updated/status/resolved) upsert the projection in real time; confirm-projection cards route clicks back to HITL resolution (run resumes, server syncs both sides); other actions call the cards API and append the server-persisted receipt into the timeline. Evidence: tsc + build green; vitest 9/9; card state resets/refreshes on conversation open and reconcile. Impact: default right tab becomes 收件箱 (localStorage-persisted preference still honored for existing users). Refs: modules/documents docs/proposals/2026-09-30-ui-inbox-command-workspace.md --- src/api.ts | 11 +++++++ src/components/Workbench.tsx | 16 +++++++-- src/store.ts | 63 +++++++++++++++++++++++++++++++++--- 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/api.ts b/src/api.ts index bfe31e6..2f0cef4 100644 --- a/src/api.ts +++ b/src/api.ts @@ -4,6 +4,7 @@ import { auth } from './auth' import type { Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse, AgentProfile, SettingDesc, StreamEvent, PlanSnapshot, TodoList, UploadInfo, + ActivityCard, } from './contracts' const BASE = '' @@ -43,6 +44,16 @@ export const api = { auth.fetch(`${BASE}/api/hitl/${cardId}/resolve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(resp) }) .then(r => json<{ resolved: boolean; pivoted: boolean; pivotText: string }>(r)), + listCards: (conversationId: string) => + auth.fetch(`${BASE}/api/conversations/${conversationId}/cards`).then(r => json<{ cards: ActivityCard[] }>(r)).then(d => d.cards ?? []), + cardAction: (cardId: string, actionId: string, freeText?: string) => + auth.fetch(`${BASE}/api/cards/${cardId}/actions/${encodeURIComponent(actionId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ freeText }) }) + .then(r => json<{ card: ActivityCard; receipt?: Message }>(r)), + cardPin: (cardId: string, pinned: boolean) => + auth.fetch(`${BASE}/api/cards/${cardId}/pin`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pinned }) }).then(r => json<{ card: ActivityCard }>(r)), + cardDismiss: (cardId: string) => + auth.fetch(`${BASE}/api/cards/${cardId}/dismiss`, { method: 'POST' }).then(r => json<{ card: ActivityCard }>(r)), + upload: (projectId: string, file: File) => { const fd = new FormData() fd.append('file', file) diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx index 46bc84e..4facd2e 100644 --- a/src/components/Workbench.tsx +++ b/src/components/Workbench.tsx @@ -1,7 +1,7 @@ -// 右栏工作台:Plan / Todos / Artifacts / Agents + 插件面板(4.4);tab 显隐排序见设置。 +// 右栏工作台:收件箱(D25 置首)+ Plan / Todos / Artifacts / Agents + 插件面板;tab 显隐排序见设置。 import { store, useStore, isTerminal } from '../store' import type { Run } from '../contracts' -import { UIRenderer, type DocBridge } from '@ui' +import { UIRenderer, InboxList, pendingCount, type DocBridge, type ActivityCard } from '@ui' // 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。 export const docBridge: DocBridge = { @@ -19,8 +19,11 @@ export function Workbench() { 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}` : ''}` }, @@ -38,6 +41,7 @@ export function Workbench() {
+ {ui.rightTab === 'inbox' && } {ui.rightTab === 'plan' && } {ui.rightTab === 'todos' && } {ui.rightTab === 'artifacts' && } @@ -50,6 +54,14 @@ export function Workbench() { ) } +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 ◐ diff --git a/src/store.ts b/src/store.ts index edb078b..3612a49 100644 --- a/src/store.ts +++ b/src/store.ts @@ -4,7 +4,7 @@ import { useSyncExternalStore } from 'react' import { api, connectEvents } from './api' import type { Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList, - AgentProfile, StreamEvent, PluginPanel, + AgentProfile, StreamEvent, PluginPanel, ActivityCard, } from './contracts' export interface ContextObs { @@ -50,6 +50,7 @@ interface AppState { plan: PlanSnapshot | null todos: TodoList | null artifacts: Artifact[] + cards: ActivityCard[] profiles: AgentProfile[] pluginPanels: PluginPanel[] ui: UIState @@ -63,7 +64,7 @@ const DEFAULT_UI: UIState = { leftCollapsed: false, rightWidth: 340, rightCollapsed: false, - rightTab: 'plan', + rightTab: 'inbox', theme: 'dark', autoCollapseProcess: true, settingsOpen: false, @@ -83,7 +84,7 @@ let state: AppState = { contextObs: {}, projects: [], currentProjectId: null, conversations: [], currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, - plan: null, todos: null, artifacts: [], profiles: [], pluginPanels: [], + plan: null, todos: null, artifacts: [], cards: [], profiles: [], pluginPanels: [], ui: loadUI(), connected: false, error: null, toast: null, } @@ -151,7 +152,7 @@ export const store = { async openConversation(convId: string) { if (sse) { sse.close(); sse = null } - set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null }) + set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [] }) await store.reconcile() await store.connectLive() }, @@ -172,6 +173,7 @@ export const store = { }) const [plan, todos] = await Promise.all([api.getPlan(d.conversation.id), api.getTodos(d.conversation.id)]) set({ plan, todos }) + store.refreshCards(d.conversation.id) } catch (e) { set({ error: String(e) }) } @@ -252,6 +254,24 @@ export const store = { set({ todos: p.list }) break } + case 'card.created': + case 'card.updated': { + const p = e.payload as { card: ActivityCard } + if (p?.card) store.upsertCard(p.card) + break + } + case 'card.status': { + const p = e.payload as { cardId: string; status: ActivityCard['status']; tone?: string } + const cards = state.cards.map(c => c.id === p.cardId ? { ...c, status: p.status, tone: p.tone ?? c.tone } : c) + set({ cards }) + break + } + case 'card.resolved': { + const p = e.payload as { cardId: string } + const cards = state.cards.map(c => c.id === p.cardId ? { ...c, status: 'resolved' as const } : c) + set({ cards }) + break + } case 'artifact.created': { if (state.currentProjectId) store.refreshArtifacts(state.currentProjectId) break @@ -319,6 +339,41 @@ export const store = { } }, + upsertCard(card: ActivityCard) { + const i = state.cards.findIndex(c => c.id === card.id) + const cards = i >= 0 ? state.cards.map(c => c.id === card.id ? card : c) : [card, ...state.cards] + set({ cards }) + }, + + async refreshCards(convId: string) { + try { set({ cards: await api.listCards(convId) }) } catch { /* 会话竞态,静默 */ } + }, + + // 卡片动作:确认投影卡(来源 hitl:)路由回 HITL 解析(恢复挂起 run,服务端同步销项); + // 其余卡走卡片动作 API,并把服务端持久化的回执消息追加进本地时间线(id 一致,对账不重复)。 + async cardAction(card: ActivityCard, actionId: string, freeText?: string) { + const hitlRef = card.body?.find(n => n.type === 'kv')?.kv?.find(([k]) => k === '来源')?.[1] + if (hitlRef?.startsWith('hitl:')) { + await store.resolveHITL(hitlRef.slice(5), actionId ? { choiceId: actionId } : { freeText }) + return + } + try { + const out = await api.cardAction(card.id, actionId, freeText) + store.upsertCard(out.card) + if (out.receipt) set({ messages: [...state.messages, out.receipt as Message] }) + } catch (e) { + store.toast(`操作失败:${e}`) + } + }, + + async cardPin(card: ActivityCard, pinned: boolean) { + try { store.upsertCard((await api.cardPin(card.id, pinned)).card) } catch (e) { store.toast(`置顶失败:${e}`) } + }, + + async cardDismiss(card: ActivityCard) { + try { store.upsertCard((await api.cardDismiss(card.id)).card) } catch (e) { store.toast(`关闭失败:${e}`) } + }, + async refreshArtifacts(projectId: string) { try { const artifacts = await api.listArtifacts(projectId)