Files
Agent-System-Frontend/src/store.ts
T
weichenandCursor 794c3e1657 Show the final reply apart from the work timeline
Why: Deliverable cards appeared as soon as a file was registered, and the last draft was shown as the answer before the run had finished composing one.
Evidence: vitest run test/live.test.ts test/toolkind.test.ts test/messageparts.test.ts test/design-guard.test.ts
Impact: Cards wait until the run ends. A finalizing phase keeps earlier text in the process block, and check rows render there.
Refs: feature/final-reply-compose
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 13:41:36 +00:00

800 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
import { useSyncExternalStore } from 'react'
import { api, connectEvents } from './api'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
import { closePreviewTab, openPreviewTab, type PreviewTab } from './previewtabs'
import { enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
import { flashElement } from './scrollsync'
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList,
AgentProfile, StreamEvent, PluginPanel, ActivityCard,
} from './contracts'
export interface ContextObs {
items: string[]
truncations?: string[]
estChars: number
}
export interface LiveState {
runId: string
phase: string
// 到达序部件流(见 live.ts LiveItem):事件到达序即落库序,与落库部件的轮次穿插序
// 同构;文本段是否为过程由「本段内是否出现工具调用」判定,tool.call 一到即改标。
items: LiveItem[]
agentNotes: Record<string, string>
startedAt: number
}
export interface UIState {
leftWidth: number
leftCollapsed: boolean
rightWidth: number
rightCollapsed: boolean
rightTab: string
/** 收件箱筛选:全部 / 待处理。铃铛打开时切到待处理。 */
inboxFilter: 'all' | 'todo'
theme: ThemeChoice
autoCollapseProcess: boolean
settingsOpen: boolean
trackerRunId: string | null
viewingArtifact: Artifact | null
/** 预览区宽度。标签列表按会话放在 AppState,不进本地偏好。 */
previewWidth: number
activeWorkspace: string | null
taskOrg: 'project' | 'timeline'
showArchived: boolean
lastProjectId: string | null
/** 系统管理全屏视图(阶段② IA:仅管理员;入口账户菜单,纵深防御在 AdminZone 再拦一次)。 */
adminZoneOpen: boolean
changePwOpen: boolean
projectSettingsId: string | null
}
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean }
interface AppState {
contextObs: Record<string, ContextObs>
projects: Project[]
currentProjectId: string | null
tasks: Conversation[]
me: Me | null
currentConvId: string | null
messages: Message[]
runs: Run[]
activeHitl: HITLCard[]
lastSeq: number
live: LiveState | null
plan: PlanSnapshot | null
todos: TodoList | null
artifacts: Artifact[]
cards: ActivityCard[]
projectCards: ActivityCard[]
inboxScope: 'conv' | 'project'
/** runtime.mock:只控制演示指令提示,不改变对话能力。 */
runtimeMock: boolean
profiles: AgentProfile[]
pluginPanels: PluginPanel[]
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
ui: UIState
connected: boolean
error: string | null
toast: string | null
outbox: QueuedMessage[]
outboxDraining: boolean
previewTabs: Record<string, PreviewTab[]>
previewActive: Record<string, string | null>
}
const DEFAULT_UI: UIState = {
leftWidth: 260,
leftCollapsed: false,
rightWidth: 340,
rightCollapsed: false,
rightTab: 'inbox',
inboxFilter: 'all',
activeWorkspace: null,
theme: 'dark',
autoCollapseProcess: true,
settingsOpen: false,
trackerRunId: null,
viewingArtifact: null,
previewWidth: 640,
taskOrg: 'project',
showArchived: false,
lastProjectId: null,
adminZoneOpen: false,
changePwOpen: false,
projectSettingsId: null,
}
function loadUI(): UIState {
try {
const raw = localStorage.getItem('as.ui')
if (raw) {
const ui = { ...DEFAULT_UI, ...JSON.parse(raw) } as UIState
if (!isThemeChoice(ui.theme)) ui.theme = 'dark'
if (ui.inboxFilter !== 'all' && ui.inboxFilter !== 'todo') ui.inboxFilter = 'all'
return ui
}
} catch { /* ignore */ }
return { ...DEFAULT_UI }
}
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: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
serverWorkspaces: [],
ui: loadUI(), connected: false, error: null, toast: null,
outbox: [], outboxDraining: false,
previewTabs: {}, previewActive: {},
}
const listeners = new Set<() => void>()
function set(patch: Partial<AppState>) {
state = { ...state, ...patch }
listeners.forEach(l => l())
}
// 预览区打开前右栏是否收起。null = 当前没有因为预览而改过右栏。
let previewRightWasCollapsed: boolean | null = null
function syncPreviewRail(hasTabs: boolean) {
if (hasTabs) {
if (previewRightWasCollapsed === null) previewRightWasCollapsed = state.ui.rightCollapsed
if (!state.ui.rightCollapsed) setUI({ rightCollapsed: true })
return
}
if (previewRightWasCollapsed === null) return
const restore = previewRightWasCollapsed
previewRightWasCollapsed = null
setUI({ rightCollapsed: restore })
}
function setUI(patch: Partial<UIState>) {
const ui = { ...state.ui, ...patch }
state = { ...state, ui }
try { localStorage.setItem('as.ui', JSON.stringify(ui)) } catch { /* ignore */ }
applyTheme(ui.theme)
listeners.forEach(l => l())
}
let systemQuery: MediaQueryList | null = null
let systemHandler: (() => void) | null = null
function applyTheme(theme: string) {
if (typeof document === 'undefined') return
if (systemQuery && systemHandler) {
systemQuery.removeEventListener('change', systemHandler)
systemQuery = null
systemHandler = null
}
const paint = (prefersDark: boolean) => {
document.documentElement.dataset.theme = resolveTheme(theme, prefersDark)
}
if (theme === 'system' && typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
systemQuery = window.matchMedia('(prefers-color-scheme: dark)')
systemHandler = () => paint(systemQuery?.matches ?? false)
systemQuery.addEventListener('change', systemHandler)
paint(systemQuery.matches)
return
}
paint(false)
}
applyTheme(state.ui.theme)
function flashLocate(root: string, attr: string, id: string) {
if (typeof document === 'undefined') return
const esc = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id.replace(/["\\]/g, '')
const el = document.querySelector(`${root} [${attr}="${esc}"]`)
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
flashElement(el)
}
let sse: { close: () => void } | null = null
let projectSse: { close: () => void } | null = null
let projectSeq = 0
export function useStore<T>(selector: (s: AppState) => T): T {
return useSyncExternalStore(
(cb) => { listeners.add(cb); return () => listeners.delete(cb) },
() => selector(state),
)
}
export const store = {
getState: () => state,
async refreshProjects() {
try {
const projects = await api.listProjects()
const current = projects.find(p => p.id === state.currentProjectId) ?? projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
set({ projects, currentProjectId: current?.id ?? null, error: null })
if (current) await store.openProject(current.id)
} catch (e) {
set({ error: String(e) })
}
store.refreshTasks()
store.refreshMe()
},
// 跨项目任务列表(方案 A 侧栏数据源):归档视图切换时带 includeArchived 重取。
async refreshTasks(includeArchived = state.ui.showArchived) {
try { set({ tasks: await api.listTasks(includeArchived) }) } catch { /* 列表竞态,静默 */ }
},
async refreshMe() {
try { set({ me: await api.me() }) } catch { /* 身份不可用时侧栏只显示通用入口 */ }
},
async createProject(name: string) {
const p = await api.createProject(name)
await store.refreshProjects()
store.toast(`项目「${p.name}」已创建`)
return p
},
async openProject(projectId: string) {
set({ currentProjectId: projectId })
if (store.getState().inboxScope === 'project') {
store.loadProjectCards()
store.connectProject()
}
store.setUI({ lastProjectId: projectId })
await store.refreshArtifacts(projectId)
},
/** 新建任务(=会话):默认当前项目上下文;显式 projectId 用于"新建于指定项目"。
* 空库时主按钮与空状态都写着「点击新建任务开始」,这里先落一个默认项目再开会话。 */
async createTask(projectId?: string) {
let pid = projectId ?? state.currentProjectId
if (!pid && state.projects.length > 0) pid = state.projects[0].id
if (!pid) {
const p = await store.createProject('默认项目')
pid = p.id
}
if (pid !== state.currentProjectId) await store.openProject(pid)
const c = await api.createConversation(pid)
await store.refreshTasks()
await store.openTask(c.id)
},
/** 兼容旧入口(⌘/命令总线) */
async createConversation(title?: string) {
if (title) store.toast('已在当前项目创建任务')
await store.createTask()
},
/** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确)。 */
async openTask(convId: string) {
const t = state.tasks.find(x => x.id === convId)
if (t && t.projectId !== state.currentProjectId) await store.openProject(t.projectId)
await store.openConversation(convId)
},
async pinTask(convId: string, pinned: boolean) {
const tasks = state.tasks.map(t => t.id === convId ? { ...t, pinned } : t)
set({ tasks })
try { await api.setConversationFlags(convId, { pinned }) } catch (e) { store.toast(`置顶失败:${e}`); store.refreshTasks() }
},
async archiveTask(convId: string, archived: boolean) {
const tasks = state.tasks.map(t => t.id === convId ? { ...t, archived } : t)
set({ tasks })
try {
await api.setConversationFlags(convId, { archived })
if (state.currentConvId === convId && archived) store.toast('任务已归档')
} catch (e) { store.toast(`归档失败:${e}`) }
store.refreshTasks()
},
// 跨会话标记(提案 2026-09-30-cross-session-reading):出站 allowCrossRead / 入站 crossPrivate。
async setCrossFlags(convId: string, flags: { allowCrossRead?: boolean; crossPrivate?: boolean }) {
const tasks = state.tasks.map(t => t.id === convId ? { ...t, ...flags } : t)
set({ tasks })
try { await api.setConversationFlags(convId, flags) } catch (e) { store.toast(`跨会话设置失败:${e}`); store.refreshTasks() }
},
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: [], inboxScope: 'conv', outbox: [], outboxDraining: false })
syncPreviewRail((state.previewTabs[convId] ?? []).length > 0)
await store.reconcile()
await store.connectLive()
},
// 终态对账:以 Query 的权威消息树/运行树/游标为准收敛。
async reconcile() {
if (!state.currentConvId) return
try {
const d = await api.conversationDetail(state.currentConvId)
const liveRunActive = state.live && !isTerminal(d.runs.find(r => r.id === state.live!.runId)?.status)
set({
messages: d.messages,
runs: d.runs,
activeHitl: d.activeHitl,
lastSeq: Math.max(state.lastSeq, d.lastSeq),
live: liveRunActive ? state.live : null,
connected: true,
})
const [plan, todos] = await Promise.all([api.getPlan(d.conversation.id), api.getTodos(d.conversation.id)])
set({ plan, todos })
store.refreshCards(d.conversation.id)
store.refreshTasks()
} catch (e) {
set({ error: String(e) })
}
},
connectLive() {
if (!state.currentConvId) return
const convId = state.currentConvId
sse = connectEvents(convId, state.lastSeq, {
onOpen: () => set({ connected: true }),
onError: () => set({ connected: false }),
onEvent: e => store.applyEvent(e),
})
},
applyEvent(e: StreamEvent) {
switch (e.type) {
case 'message.delta': {
const p = e.payload as { index: number; text: string }
set({ live: touchLive(e.runId ?? '', l => { l.items = applyTextDelta(l.items, p.index, p.text) }) })
break
}
case 'thinking.delta': {
const p = e.payload as { index: number; text: string }
set({ live: touchLive(e.runId ?? '', l => { l.items = applyThinkingDelta(l.items, p.text) }) })
break
}
case 'tool.call.delta': {
const p = e.payload as { index: number; callId?: string; name?: string; delta: string }
set({ live: touchLive(e.runId ?? '', l => { l.items = applyToolCallDelta(l.items, p.index, p.delta, p.callId, p.name) }) })
break
}
case 'tool.call': {
const p = e.payload as { callId: string; name: string; arguments?: string }
set({ live: touchLive(e.runId ?? '', l => { l.items = applyToolCall(l.items, p.name, p.callId, p.arguments, Date.now()) }) })
break
}
case 'tool.output': {
const p = e.payload as { callId?: string; text: string }
set({ live: touchLive(e.runId ?? '', l => { l.items = applyToolOutput(l.items, p.callId, p.text) }) })
break
}
case 'tool.result': {
const p = e.payload as { callId: string; status: string; summary?: string }
set({ live: touchLive(e.runId ?? '', l => { l.items = applyToolResult(l.items, p.callId, p.status, p.summary, Date.now()) }) })
break
}
case 'run.phase': {
const p = e.payload as { phase: string }
set({ live: touchLive(e.runId ?? '', l => {
l.phase = p.phase
if (p.phase === 'finalizing') l.items = applyFinalizing(l.items)
}) })
break
}
case 'run.check': {
const p = e.payload as { passed?: boolean; reason?: string }
set({ live: touchLive(e.runId ?? '', l => { l.items = applyCheck(l.items, !!p.passed, p.reason ?? '') }) })
break
}
case 'hitl.request': {
store.reconcile()
break
}
case 'hitl.resolved': {
set({ activeHitl: state.activeHitl.filter(c => c.id !== (e.payload as { cardId: string }).cardId) })
break
}
case 'agent.spawn': {
const p = e.payload as { agentRun: string; name: string; task: string }
set({ live: touchLive(e.runId ?? '', l => { l.agentNotes = { ...l.agentNotes, [p.agentRun]: `已派生 ${p.name}:${p.task}` } }) })
break
}
case 'agent.progress': {
const p = e.payload as { agentRun: string; note?: string }
set({ live: touchLive(e.runId ?? '', l => { if (p.note) l.agentNotes = { ...l.agentNotes, [p.agentRun]: p.note! } }) })
break
}
case 'agent.result': {
const p = e.payload as { agentRun: string; summary?: string }
set({ live: touchLive(e.runId ?? '', l => { l.agentNotes = { ...l.agentNotes, [p.agentRun]: p.summary ?? '完成' } }) })
store.reconcile()
break
}
case 'plan.update': {
const p = e.payload as { plan: PlanSnapshot }
set({ plan: p.plan })
break
}
case 'todo.update': {
const p = e.payload as { list: TodoList }
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':
case 'artifact.updated': {
const p = e.payload as { artifact?: { id: string; name: string; kind?: string } }
if (p?.artifact) {
const ref = { artifactId: p.artifact.id, name: p.artifact.name, kind: p.artifact.kind }
set({ live: touchLive(e.runId ?? '', l => {
l.items = e.type === 'artifact.updated' ? applyArtifactUpdated(l.items, ref) : applyArtifactCreated(l.items, ref)
}) })
}
if (state.currentProjectId) store.refreshArtifacts(state.currentProjectId)
break
}
case 'run.completed': {
const p = e.payload as { usage?: unknown; context?: ContextObs }
if (p?.context && e.runId) {
set({ contextObs: { ...state.contextObs, [e.runId]: p.context } })
}
setTimeout(() => { void store.reconcile().then(() => store.flushOutbox()) }, 250)
break
}
case 'run.failed':
case 'error': {
// 终态:短暂延迟后对账(等待消息树落库),折叠过程输出,再发送排队消息。
setTimeout(() => { void store.reconcile().then(() => store.flushOutbox()) }, 250)
break
}
}
},
async sendMessage(text: string, attachmentIds: string[]): Promise<'ok' | 'busy'> {
if (!state.currentConvId) return 'ok'
try {
const out = await api.sendMessage(state.currentConvId, text, attachmentIds)
if (out.command) {
store.toast(out.reply ?? `命令 ${out.command} 已执行`)
if (out.conversation) await store.openConversation(out.conversation.id)
else await store.reconcile()
await store.flushOutbox()
return 'ok'
}
if (out.run) {
// 竞态保护:POST 返回前 SSE 事件可能已先行到达并累积(同 run),
// 此时保留已累积的 live,只补挂用户消息;否则初始化空 live。
if (!state.live || state.live.runId !== out.run.id) {
set({ live: { runId: out.run.id, phase: 'thinking', items: [], agentNotes: {}, startedAt: Date.now() } })
}
set({ messages: [...state.messages, out.message] })
store.connectIfNeeded()
}
return 'ok'
} catch (e) {
if (isConversationBusy(e)) {
set({ outbox: enqueueMessageFront(state.outbox, newQueuedMessage(text, attachmentIds, [])) })
return 'busy'
}
store.toast(`发送失败:${e}`)
await store.reconcile()
return 'ok'
}
},
enqueueOutbox(text: string, attachmentIds: string[], attachmentNames: string[]) {
set({ outbox: enqueueMessage(state.outbox, newQueuedMessage(text, attachmentIds, attachmentNames)) })
},
updateOutbox(id: string, text: string) {
set({ outbox: replaceQueuedText(state.outbox, id, text) })
},
removeOutbox(id: string) {
set({ outbox: removeQueuedMessage(state.outbox, id) })
},
async sendOutboxNow(id: string) {
const item = state.outbox.find(q => q.id === id)
if (!item) return
const rest = removeQueuedMessage(state.outbox, id)
if (activeRun(state.runs) || state.live) {
set({ outbox: promoteQueuedMessage(state.outbox, id) })
store.toast('回合进行中,已放到队首,结束后自动发送')
return
}
set({ outbox: rest })
await store.sendMessage(item.text, item.attachmentIds)
},
async flushOutbox() {
if (state.outboxDraining) return
if (activeRun(state.runs) || state.live) return
const next = state.outbox[0]
if (!next) return
set({ outbox: state.outbox.slice(1), outboxDraining: true })
let result: 'ok' | 'busy' = 'ok'
try {
result = await store.sendMessage(next.text, next.attachmentIds)
} finally {
set({ outboxDraining: false })
}
if (result === 'busy') return
if (!activeRun(state.runs) && !state.live && state.outbox.length > 0) {
await store.flushOutbox()
}
},
connectIfNeeded() {
if (!sse && state.currentConvId) store.connectLive()
},
async cancelActive() {
const active = activeRun(state.runs)
if (!active) return
await api.cancelRun(active.id).catch(() => undefined)
setTimeout(() => store.reconcile(), 200)
},
async resolveHITL(cardId: string, resp: { choiceId?: string; freeText?: string }) {
try {
const out = await api.resolveHITL(cardId, resp)
set({ activeHitl: state.activeHitl.filter(c => c.id !== cardId) })
if (out.pivoted && out.pivotText) {
await store.sendMessage(out.pivotText, [])
}
} catch (e) {
store.toast(`HITL 解析失败:${e}`)
}
},
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] })
// 命令确认卡的动作响应是决议前快照。再拉一次,内联卡才能从待确认过渡到折叠。
if (card.kind === 'confirm' && actionId !== 'recheck' && out.card.status === 'action_required' && card.conversationId) {
await store.refreshCards(card.conversationId)
}
} 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)
set({ artifacts })
} catch { /* 项目删除竞态等,静默 */ }
},
async refreshProfiles() {
try { set({ profiles: await api.listProfiles() }) } catch { /* ignore */ }
},
async refreshPlugins() {
try {
const list = await api.listPlugins() as { panels?: PluginPanel[] }[]
const panels = list.flatMap(p => p.panels ?? [])
set({ pluginPanels: panels })
} catch { /* ignore */ }
try {
const ws = await api.listWorkspaces()
set({ serverWorkspaces: ws ?? [] })
} catch { /* ignore */ }
try {
const settings = await api.getSettings()
const runtime = settings.user.runtime
const mock = !!runtime && typeof runtime === 'object' && (runtime as { mock?: unknown }).mock === true
set({ runtimeMock: mock })
} catch { /* ignore */ }
},
async uploadAttachment(file: File): Promise<string | null> {
if (!state.currentProjectId) return null
try {
const up = await api.upload(state.currentProjectId, file)
store.toast(`已上传 ${up.filename}${up.parsed ? `(解析:${up.parsed.meta.parser})` : ''}`)
await store.refreshArtifacts(state.currentProjectId)
return up.artifactId
} catch (e) {
store.toast(`上传失败:${e}`)
return null
}
},
setUI,
toggleLeft() { setUI({ leftCollapsed: !state.ui.leftCollapsed }) },
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) {
try { const out = await api.cardDismiss(c.id); store.upsertCard(out.card) } catch { /* 单卡失败不阻断 */ }
}
if (done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`)
},
closeWorkspace() { setUI({ activeWorkspace: null }) },
/** 铃铛:打开右栏并切到待处理。哪一侧有待办就看哪一侧。 */
focusPending() {
const conv = state.cards.filter(c => c.status === 'action_required').length
const proj = state.projectCards.filter(c => c.status === 'action_required').length
if (conv === 0 && proj > 0) store.setInboxScope('project')
setUI({ rightCollapsed: false, rightTab: 'inbox', inboxFilter: 'todo' })
},
openInbox(cardId?: string) {
setUI({ rightCollapsed: false, rightTab: 'inbox' })
if (cardId) setTimeout(() => flashLocate('.right-panel', 'data-card-id', cardId), 60)
},
// 卡→消息互定位另一半(D32):从收件箱卡滚回产生它的对话消息。
locateMessage(messageId?: string) {
if (!messageId) return
setTimeout(() => flashLocate('.chat', 'data-msg-id', messageId), 60)
},
openTracker(runId: string) { setUI({ trackerRunId: runId }) },
viewArtifact(a: Artifact | null) { setUI({ viewingArtifact: a }) },
/**
* 工件预览唯一入口。默认在对话右侧预览区打开或激活标签;hint 'full' 落位全屏
* DocViewer。打开第一张标签时收起右栏,关掉最后一张时恢复。
*/
requestPreview(a: Artifact, hint?: 'full') {
if (hint === 'full') { setUI({ viewingArtifact: a }); return }
const conv = state.currentConvId ?? '_'
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: a.id, name: a.name })
set({
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
previewActive: { ...state.previewActive, [conv]: opened.activeId },
})
syncPreviewRail(true)
},
activatePreview(id: string) {
const conv = state.currentConvId ?? '_'
set({ previewActive: { ...state.previewActive, [conv]: id } })
},
closePreview(id: string) {
const conv = state.currentConvId ?? '_'
const next = closePreviewTab(state.previewTabs[conv] ?? [], state.previewActive[conv] ?? null, id)
set({
previewTabs: { ...state.previewTabs, [conv]: next.tabs },
previewActive: { ...state.previewActive, [conv]: next.activeId },
})
syncPreviewRail(next.tabs.length > 0)
},
openSettings(open: boolean) { setUI({ settingsOpen: open }) },
openProjectSettings(id: string | null) { setUI({ projectSettingsId: id }) },
toast(msg: string) {
set({ toast: msg })
setTimeout(() => { if (state.toast === msg) set({ toast: null }) }, 2600)
},
}
function touchLive(_runId: string, mutate: (l: LiveState) => void): LiveState | null {
const base = state.live ?? { runId: _runId, phase: 'thinking', items: [], agentNotes: {}, startedAt: Date.now() }
const next: LiveState = { ...base, agentNotes: { ...base.agentNotes } }
mutate(next)
return next
}
export function isTerminal(status?: string): boolean {
return status === 'completed' || status === 'failed' || status === 'cancelled' || status === 'interrupted'
}
function isConversationBusy(err: unknown): boolean {
return String(err).includes('409:')
}
export function activeRun(runs: Run[]): Run | undefined {
return runs.find(r => r.kind !== 'subagent' && !isTerminal(r.status))
}