diff --git a/src/api.ts b/src/api.ts index 1c0a576..3e17570 100644 --- a/src/api.ts +++ b/src/api.ts @@ -70,6 +70,10 @@ export const api = { 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)), + listProjectCards: (projectId: string) => + auth.fetch(`${BASE}/api/projects/${projectId}/cards`).then(r => json<{ cards: ActivityCard[] }>(r)).then(d => d.cards ?? []), + cardToProject: (cardId: string) => + auth.fetch(`${BASE}/api/cards/${cardId}/to_project`, { method: 'POST' }).then(r => json<{ card: ActivityCard; origin: ActivityCard }>(r)), upload: (projectId: string, file: File) => { const fd = new FormData() @@ -114,13 +118,17 @@ export interface SSEHandlers { onError?: (err: Error) => void } -export function connectEvents(conversationId: string, since: number, handlers: SSEHandlers): { close: () => void } { +export function connectEvents(conversationId: string, since: number, handlers: SSEHandlers, projectId?: string): { close: () => void } { let closed = false let es: EventSource | null = null const connect = () => { if (closed) return - es = new EventSource(auth.withKey(`${BASE}/api/events?conversationId=${encodeURIComponent(conversationId)}&since=${since}`)) + // 项目流(CF3/D50):scope=project 订阅项目频道(伪会话键,信封带 ProjectID)。 + const url = projectId + ? `${BASE}/api/events?scope=project&projectId=${encodeURIComponent(projectId)}&since=${since}` + : `${BASE}/api/events?conversationId=${encodeURIComponent(conversationId)}&since=${since}` + es = new EventSource(auth.withKey(url)) es.onopen = () => handlers.onOpen?.() es.onerror = () => { if (closed) return @@ -131,7 +139,8 @@ export function connectEvents(conversationId: string, since: number, handlers: S // 同时监听具名事件与默认(服务端 event: 字段)。 const types = ['message.delta', 'thinking.delta', 'tool.call', 'tool.result', 'run.phase', 'hitl.request', 'hitl.resolved', 'plan.update', 'todo.update', 'agent.spawn', 'agent.progress', - 'agent.result', 'artifact.created', 'artifact.updated', 'run.completed', 'run.failed', 'error'] + 'agent.result', 'artifact.created', 'artifact.updated', 'run.completed', 'run.failed', 'error', + 'card.created', 'card.updated', 'card.status', 'card.resolved'] for (const t of types) es.addEventListener(t, ev => handle(t, (ev as MessageEvent).data)) } diff --git a/src/components/TaskSidebar.tsx b/src/components/TaskSidebar.tsx index 60b71fa..308ffaf 100644 --- a/src/components/TaskSidebar.tsx +++ b/src/components/TaskSidebar.tsx @@ -51,7 +51,9 @@ export function TaskSidebar() { const org = useStore(s => s.ui.taskOrg) const showArchived = useStore(s => s.ui.showArchived) const theme = useStore(s => s.ui.theme) - const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length) + const pendingBadge = useStore(s => + s.cards.filter(c => c.status === 'action_required').length + + s.projectCards.filter(c => c.status === 'action_required').length) const [q, setQ] = useState('') const [collapsed, setCollapsed] = useState>(loadCollapsed) diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx index 2cb8c41..c5d7788 100644 --- a/src/components/Workbench.tsx +++ b/src/components/Workbench.tsx @@ -15,11 +15,44 @@ export const docBridge: DocBridge = { }, } +const inboxCallbacks = { + onAction: (c: ActivityCard, id: string) => store.cardAction(c, id), + onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p), + onDismiss: (c: ActivityCard) => store.cardDismiss(c), + onLocate: (c: ActivityCard) => store.locateMessage(c.anchorMessageId), + onArchive: (c: ActivityCard) => store.cardToProject(c), +} + +/** 项目收件箱视图(CF3/D49/D51):项目级卡片 ∪ 其他会话待办(done 不聚合)。 */ +function ProjectInbox({ cards }: { cards: ActivityCard[] }) { + const own = cards.filter(c => !c.conversationId) + const cross = cards.filter(c => !!c.conversationId) + if (cards.length === 0) { + return
项目收件箱为空——「留档到项目」的产物卡与其他会话的待办会聚合在这里。
+ } + return ( + <> +
📁 项目级卡片(跨会话可见)
+ {own.length > 0 + ? + :
暂无——会话卡点 🗂 即留档至此
} + {cross.length > 0 && ( + <> +
🕓 其他会话待办
+ + + )} + + ) +} + export function Workbench() { const cards = useStore(s => s.cards) const plan = useStore(s => s.plan) const todos = useStore(s => s.todos) - const pending = pendingCount(cards) + const inboxScope = useStore(s => s.inboxScope) + const projectCards = useStore(s => s.projectCards) + const pending = pendingCount(cards) + pendingCount(projectCards) const rightTab = useStore(s => s.ui.rightTab) const railId = useStore(s => s.ui.railPreviewId) const railArtifact = useStore(s => (railId ? s.artifacts.find(a => a.id === railId) : undefined)) @@ -61,12 +94,15 @@ export function Workbench() { ) : (
- store.cardAction(c, id), - onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p), - onDismiss: (c: ActivityCard) => store.cardDismiss(c), - onLocate: (c: ActivityCard) => store.locateMessage(c.anchorMessageId), - }} /> +
+ + +
+ {inboxScope === 'project' + ? + : }
)} diff --git a/src/store.ts b/src/store.ts index 960b8b0..d43c306 100644 --- a/src/store.ts +++ b/src/store.ts @@ -64,6 +64,8 @@ interface AppState { todos: TodoList | null artifacts: Artifact[] cards: ActivityCard[] + projectCards: ActivityCard[] + inboxScope: 'conv' | 'project' profiles: AgentProfile[] pluginPanels: PluginPanel[] serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[] @@ -106,7 +108,7 @@ let state: AppState = { contextObs: {}, projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, - plan: null, todos: null, artifacts: [], cards: [], profiles: [], pluginPanels: [], + plan: null, todos: null, artifacts: [], cards: [], projectCards: [], inboxScope: 'conv', profiles: [], pluginPanels: [], serverWorkspaces: [], ui: loadUI(), connected: false, error: null, toast: null, } @@ -131,6 +133,8 @@ function applyTheme(theme: string) { } let sse: { close: () => void } | null = null +let projectSse: { close: () => void } | null = null +let projectSeq = 0 export function useStore(selector: (s: AppState) => T): T { return useSyncExternalStore( @@ -173,6 +177,10 @@ export const store = { async openProject(projectId: string) { set({ currentProjectId: projectId }) + if (store.getState().inboxScope === 'project') { + store.loadProjectCards() + store.connectProject() + } store.setUI({ lastProjectId: projectId }) await store.refreshArtifacts(projectId) }, @@ -224,7 +232,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, cards: [] }) + set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], inboxScope: 'conv' }) await store.reconcile() await store.connectLive() }, @@ -492,6 +500,72 @@ export const store = { toggleRight() { setUI({ rightCollapsed: !state.ui.rightCollapsed }) }, openWorkspace(id: string) { setUI({ activeWorkspace: id }) }, /** 收纳策略(T5):一键收纳已完成卡(done→dismissed;resolved 盖章留痕不动)。 */ + // 项目收件箱(CF3/D48-D51):加载聚合视图 + 项目流订阅 + 留档。 + async loadProjectCards() { + const pid = store.getState().currentProjectId + if (!pid) return + try { set({ projectCards: await api.listProjectCards(pid) }) } catch { /* 静默:切项目竞态 */ } + }, + + setInboxScope(scope: 'conv' | 'project') { + set({ inboxScope: scope }) + if (scope === 'project') { + store.loadProjectCards() + store.connectProject() + } else if (projectSse) { + projectSse.close() + projectSse = null + } + }, + + connectProject() { + const pid = store.getState().currentProjectId + if (!pid) return + projectSse?.close() + projectSse = connectEvents('', projectSeq, { onEvent: e => store.applyProjectEvent(e) }, pid) + }, + + applyProjectEvent(e: StreamEvent) { + projectSeq = Math.max(projectSeq, e.seq) + const upsert = (card: ActivityCard) => { + const list = store.getState().projectCards + const i = list.findIndex(c => c.id === card.id) + set({ projectCards: i >= 0 ? list.map(c => (c.id === card.id ? card : c)) : [card, ...list] }) + } + switch (e.type) { + case 'card.created': + case 'card.updated': + if ((e.payload as { card?: ActivityCard })?.card) upsert((e.payload as { card: ActivityCard }).card) + break + case 'card.status': { + const p = e.payload as { cardId: string; status: ActivityCard['status']; tone?: string } + const list = store.getState().projectCards + set({ projectCards: list.map(c => (c.id === p.cardId ? { ...c, status: p.status, tone: p.tone ?? c.tone } : c)) }) + break + } + case 'card.resolved': { + const p = e.payload as { cardId: string } + const list = store.getState().projectCards + set({ projectCards: list.map(c => (c.id === p.cardId ? { ...c, status: 'resolved' as const } : c)) }) + break + } + } + }, + + async cardToProject(card: ActivityCard) { + try { + const out = await api.cardToProject(card.id) + store.upsertCard(out.origin) // 会话视图:原卡 dismissed 留痕 + const list = store.getState().projectCards + const i = list.findIndex(c => c.id === out.card.id) + set({ projectCards: i >= 0 ? list.map(c => (c.id === out.card.id ? out.card : c)) : [out.card, ...list] }) + store.toast('已留档到项目收件箱') + store.setInboxScope('project') + } catch (e) { + store.toast(`留档失败:${e}`) + } + }, + async tidyCards() { const done = state.cards.filter(c => c.status === 'done') for (const c of done) { diff --git a/src/styles.css b/src/styles.css index ce2270a..b988522 100644 --- a/src/styles.css +++ b/src/styles.css @@ -404,3 +404,13 @@ button { font-family: inherit; font-size: inherit; color: inherit; } .tab-body[data-rail-preview] { display: flex; flex-direction: column; } .tab-body[data-rail-preview] .ui-fp { flex: 1; } .artifact-chip .ac-act { border: none; background: none; } + +/* 项目收件箱(CF3/D51):会话/项目范围分段控件 */ +.inbox-scope { + display: flex; gap: 4px; padding: 6px 14px 2px; +} +.inbox-scope button { + border: 1px solid var(--border, #d8d8d8); background: transparent; color: inherit; + border-radius: 999px; padding: 2px 12px; font-size: 12px; cursor: pointer; opacity: .72; +} +.inbox-scope button.active { opacity: 1; font-weight: 600; background: var(--hover, rgba(0,0,0,.05)); }