Merge origin/main into the duty matrix branch

Why: project settings must keep the shared duty draft and must not regain template document creation from main.
Evidence: SELF vitest 292 passed, tsc --noEmit passed, knip unused exports 43.
Impact: the duties tab stays assignment-only while conversation ordering from main is included.
Refs: docs/proposals/2026-10-15-rdm-duty-matrix-rework.md
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-15 08:37:53 +00:00
co-authored by Cursor
20 changed files with 633 additions and 126 deletions
+6 -5
View File
@@ -5,7 +5,7 @@ import { rdmCommandProjectId, rdmProjectCommandError } from './rdmManagement'
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
ActivityCard, SurfaceMeta, SurfaceSnapshot,
ActivityCard, SurfaceMeta, SurfaceSnapshot, WorkCardAction,
} from './contracts'
const BASE = ''
@@ -90,8 +90,9 @@ export const api = {
auth.fetch(`${BASE}/api/conversations/${encodeURIComponent(convId)}/runs/${encodeURIComponent(runId)}/events?after=${after}&limit=500`, { signal })
.then(r => json<{ events: StreamEvent[] }>(r)),
sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string) =>
auth.fetch(`${BASE}/api/conversations/${convId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, attachmentIds, requestId }) })
sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string, taskRef?: { cardId: string; resultVersion: number }) =>
auth.fetch(`${BASE}/api/conversations/${convId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, attachmentIds, requestId,
...(taskRef ? { taskRef: { cardId: taskRef.cardId, resultVersion: taskRef.resultVersion } } : {}) }) })
.then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
return r.json() as Promise<{ message: Message; run?: Run; command?: string; reply?: string; conversation?: Conversation }>
@@ -105,8 +106,8 @@ export const api = {
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 }) })
cardAction: (cardId: string, actionId: string, payload?: string | WorkCardAction) =>
auth.fetch(`${BASE}/api/cards/${cardId}/actions/${encodeURIComponent(actionId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(typeof payload === 'object' ? payload : { freeText: payload }) })
.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)),
+13
View File
@@ -71,6 +71,19 @@
font-weight: 600;
}
.work-companion-chat .msg.user .msg-role { justify-content: flex-end; }
.message-timestamp { color: var(--text-dim); font-size: var(--fs-ui-sm); font-weight: 400; white-space: nowrap; }
.work-companion-chat .msg.assistant .message-timestamp { margin-left: auto; }
.work-result-notice, .composer-work-reference { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; box-sizing: border-box; padding: 8px 12px; margin-bottom: 8px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-raised); color: var(--text-dim); font-size: var(--fs-ui-caption); text-align: left; }
.work-result-notice { cursor: pointer; }
.work-result-notice:hover { background: var(--bg-hover); color: var(--text); }
.work-result-notice > span:last-child { margin-left: auto; color: var(--accent-text); }
.composer-work-reference > span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.work-execution-record { min-width: 0; padding: 16px; color: var(--text); background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-lg); }
.work-record-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.work-record-heading strong { min-width: 0; overflow-wrap: anywhere; font-size: var(--fs-ui-base); }
.work-record-heading .btn { flex-shrink: 0; font-size: var(--fs-ui-caption); }
.work-execution-record .tl { margin-top: 16px; }
.work-execution-record .part-text { overflow-wrap: anywhere; }
.work-companion-chat .msg.assistant .role-mark {
background: var(--primary);
border-color: var(--primary);
+34 -31
View File
@@ -5,15 +5,16 @@ import { Icon, Avatar, BrandMark, Collapsible, getPersistedOpen, setPersistedOpe
import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../api'
import { store, useStore, activeRun, isTerminal, type LiveState, type ContextObs } from '../store'
import { scrollRatio, scrollTopForRatio } from '../scrollsync'
import { liveToMessage, livePhaseLabel, runEventsToTimeline, showProcessInline } from '../live'
import { deriveShellPhase, isShellBusy, showLiveBubble, type ShellPhase } from '../runstate'
import type { Message, Part, HITLCard, ActivityCard, Artifact, StreamEvent } from '../contracts'
import type { Message, Part, HITLCard, ActivityCard, Artifact } from '../contracts'
import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui'
import { docBridge } from './Workbench'
import { splitTimeline, mergeProcessTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts'
import { ProcessTimeline } from './ProcessTimeline'
import { ConversationWork } from './ConversationWork'
import { MessageTimestamp } from './MessageTimestamp'
import { loadRunEvents } from '../runEvents'
import '../companion-conversation.css'
function hasFiles(dt: DataTransfer | null): boolean {
@@ -52,7 +53,7 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
const ref = part.card.cardId
const card = findCard(ref)
const bodyChars = card?.body?.reduce((n, node) => n + (node.markdown?.length ?? 0), 0)
return <CardAnchor title={card?.title ?? part.card.kind} chars={bodyChars} card={workPaneOwnsCards ? undefined : card}
return <CardAnchor title={card?.title ?? part.card.kind} chars={bodyChars} card={workPaneOwnsCards || card?.work ? undefined : card}
onLocate={() => store.openInbox(ref)} docBridge={docBridge} />
}
// 插件内嵌卡片部件(cardlink 互定位,D32):点击 ↗ 定位收件箱对应卡(从 data 解析 card:<id>)。
@@ -226,17 +227,7 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
let active = true
void (async () => {
try {
const events: StreamEvent[] = []
let after = 0
while (true) {
const page = await api.runEventsPage(convId, msg.runId!, after, controller.signal)
const rows = page.events ?? []
events.push(...rows)
if (rows.length < 500) break
const next = rows[rows.length - 1].seq
if (next <= after) throw new Error('process event cursor did not advance')
after = next
}
const events = await loadRunEvents(convId, msg.runId!, controller.signal)
if (active) setReplayed(runEventsToTimeline(events, msg.runId))
} catch {
if (active) setProcessError(true)
@@ -269,6 +260,7 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
<div className="msg-role">
<span className="role-mark role-assistant"><BrandMark size={16} /></span>
<span>助手</span>
<MessageTimestamp value={msg.createdAt || run?.createdAt} />
{live && <LiveBar live={live} />}
</div>
{(split.timeline.length > 0 || !!msg.runId) && (
@@ -462,18 +454,19 @@ function OutboxPanel() {
if (outbox.length === 0) return null
return (
<Collapsible id="composer-outbox" defaultOpen
trigger={<span>排队 {outbox.length}</span>}
trigger={<span>待发送 {outbox.length}</span>}
triggerClassName="outbox-trigger">
<ul className="outbox-list">
{outbox.map(item => (
<li key={item.id} className="outbox-item">
{item.deliveryError && <span className="outbox-text" role="status">{item.deliveryError}</span>}
{editing === item.id ? (
<input className="search-input outbox-edit" value={draft} onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') { store.updateOutbox(item.id, draft); setEditing(null) }
}} />
) : (
<span className="outbox-text" title={item.text}>{item.text.trim() || '(仅附件)'}{item.attachmentNames.length > 0 ? ` · ${item.attachmentNames.join('、')}` : ''}</span>
<span className="outbox-text" title={item.text}>{item.text.trim() || '(仅附件)'}{item.attachmentNames.length > 0 ? ` · ${item.attachmentNames.join('、')}` : ''}{item.taskRef ? ` · 基于「${item.taskRef.title}」` : ''}</span>
)}
{editing === item.id ? (
<button className="btn ghost" onClick={() => { store.updateOutbox(item.id, draft); setEditing(null) }}>保存</button>
@@ -481,6 +474,7 @@ function OutboxPanel() {
<button className="btn ghost" onClick={() => { setEditing(item.id); setDraft(item.text) }}>修改</button>
)}
<button className="btn ghost" onClick={() => store.sendOutboxNow(item.id)}>发送</button>
{item.taskRef && <button className="btn ghost" onClick={() => store.clearOutboxReference(item.id)}>移除引用</button>}
<button className="icon-btn" title="移出队列" onClick={() => store.removeOutbox(item.id)}><Icon name="x" size={12} /></button>
</li>
))}
@@ -495,7 +489,7 @@ export function NoticeMessage({ msg }: { msg: Message }) {
return (
<div className="msg notice" data-msg-id={msg.id}>
<Collapsible id={`notice:${msg.id}`} triggerClassName="notice-summary"
trigger={<span className="notice-label">通知 · {preview}</span>}>
trigger={<><span className="notice-label">通知 · {preview}</span><MessageTimestamp value={msg.createdAt} /></>}>
<div className="notice-body">{text}</div>
</Collapsible>
</div>
@@ -524,6 +518,7 @@ function ConversationSession() {
const cards = useStore(s => s.cards)
const currentProjectId = useStore(s => s.currentProjectId)
const cardActionStates = useStore(s => s.cardActionStates)
const workReference = useStore(s => s.workReference)
const workPaneOwnsCards = useStore(s => !s.ui.rightCollapsed && s.ui.rightTab === 'work')
const [text, setText] = useState('')
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
@@ -553,8 +548,9 @@ function ConversationSession() {
})
const busy = isShellBusy(phase)
const showStop = phase === 'running'
// 慢通道在跑的后台子代理:前台回合结束后仍在执行,完成后经通知回到对话汇报。
// Background work stays in its own card and does not insert another chat turn.
const bgRunning = runs.filter(r => r.kind === 'subagent' && !isTerminal(r.status)).length
const unreadResults = cards.filter(card => card.work?.result && card.work.resultVersion > card.work.readVersion)
useEffect(() => {
if (!stickRef.current) return
@@ -588,12 +584,18 @@ function ConversationSession() {
const t = text
const ids = attachmentIds
const names = attachmentNames
setText(''); setAttachmentIds([]); setAttachmentNames([])
if (busy) {
store.enqueueOutbox(t, ids, names)
const reference = workReference ?? undefined
if (reference && ids.length > 0) {
store.toast('这条消息已引用工作结果,请先移除引用或附件再发送')
return
}
await store.sendMessage(t, ids)
setText(''); setAttachmentIds([]); setAttachmentNames([])
store.clearWorkReference()
if (busy) {
store.enqueueOutbox(t, ids, names, reference)
return
}
await store.sendMessage(t, ids, undefined, reference)
}
if (!currentConvId) {
@@ -656,13 +658,6 @@ function ConversationSession() {
const stick = dist < 80
stickRef.current = stick
setShowJump(prev => (prev === !stick ? prev : !stick))
const ratio = scrollRatio(src)
if (ratio === null) return
const dst = document.querySelector('[data-scroll-sync="inbox"]')
if (!dst) return
const top = scrollTopForRatio(dst, ratio)
if (top === null) return
dst.scrollTop = top
}}>
{foldRunErrors(messages.filter(m => m.role !== 'assistant' || !m.runId || runs.find(r => r.id === m.runId)?.kind !== 'subagent')).map(m => {
if (m.role === 'user') {
@@ -672,6 +667,7 @@ function ConversationSession() {
<div className={`msg user${m.excluded ? ' is-excluded' : ''}`} key={m.id} data-msg-id={m.id}>
<div className="msg-role">
<span className="role-mark"><Avatar name={myName} size="sm" /></span>你
<MessageTimestamp value={m.createdAt} />
</div>
<div className="bubble">{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}</div>
{currentConvId && <MessageTools msg={m} convId={currentConvId} />}
@@ -716,16 +712,23 @@ function ConversationSession() {
<div className="input-bar">
<OutboxPanel />
{unreadResults.length > 0 && <button type="button" className="work-result-notice" onClick={() => store.openInbox(unreadResults[0].id)}>
<Icon name="check" size={14} /><span>{unreadResults.length} 项工作有新结果</span><span>查看工作 ↗</span>
</button>}
{workReference && <div className="composer-work-reference" role="group" aria-label="本条消息引用的工作结果">
<span>基于「{workReference.title}」的第 {workReference.resultVersion} 版结果</span>
<button type="button" className="icon-btn" aria-label="移除工作结果引用" onClick={() => store.clearWorkReference()}><Icon name="x" size={12} /></button>
</div>}
{!connected && <div className="companion-reconnect" role="status">正在重新连接,最新进展待确认</div>}
{/* 轻量状态提示(方案:快慢通道连续体验):忙碌时可继续输入排队;
后台任务在回合结束后仍在执行,完成后回到对话汇报。 */}
后台工作完成后更新原卡,用户按需打开。 */}
{(busy || bgRunning > 0) && (
<div className="input-status" data-testid="input-status">
<span className="live-dot" />
{busy
? <span className="mk-sweep">助手正在处理{bgRunning > 0 ? ` · ${bgRunning} 项后台工作` : ''}</span>
: <span>{bgRunning} 项后台工作</span>}
<span className="input-status-note">{busy ? '新消息会排队,当前回合结束后发送' : '你可以继续对话,完成后会在这里汇报'}</span>
<span className="input-status-note">{busy ? '新消息会排队,当前回合结束后发送' : '你可以继续对话,结果会更新到工作卡'}</span>
</div>
)}
{attachmentNames.length > 0 && (
+14 -6
View File
@@ -1,7 +1,9 @@
import { useEffect, useMemo, useState } from 'react'
import { ActivityCardView, Collapsible, activitySummary, pendingCount, type ActivityCard } from '@ui'
import { ActivityCardView, Collapsible, activitySummary, groupActivityCards, pendingCount, type ActivityCard } from '@ui'
import { store, useStore } from '../store'
import { docBridge } from './Workbench'
import type { WorkCardAction } from '../contracts'
import { WorkExecutionRecord } from './WorkExecutionRecord'
/** 对话与辅助工作栏共享对象、动作和唯一文件预览,不另持有异步状态。 */
export function ConversationWork() {
@@ -12,6 +14,7 @@ export function ConversationWork() {
const actionStates = useStore(s => s.cardActionStates)
const rightCollapsed = useStore(s => s.ui.rightCollapsed)
const rightTab = useStore(s => s.ui.rightTab)
const record = useStore(s => s.workRecord)
const detailHere = rightCollapsed || rightTab !== 'work'
const [summaryOpen, setSummaryOpen] = useState(detailHere)
useEffect(() => setSummaryOpen(detailHere), [detailHere])
@@ -26,22 +29,27 @@ export function ConversationWork() {
if (card.kind === 'confirm' && card.anchorMessageId
&& messages.some(message => message.role === 'user' && message.id === card.anchorMessageId)) continue
const summary = activitySummary(card)
if (card.status === 'action_required' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card)
if (card.status === 'action_required' || card.work?.decision?.state === 'open' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card)
else remaining.push(card)
}
// Preserve the host's newest-first order for completed results and history.
const results = groupActivityCards(remaining).find(group => group.key === 'results')?.cards ?? []
const focused = remaining.find(card => card.pinned && card.status === 'active')
?? remaining.find(card => card.status === 'active')
?? remaining.find(card => card.status === 'done') ?? remaining[0]
return { current, required, focused, history: remaining.filter(card => card !== focused) }
?? results[0]
return { current, required, focused, history: remaining.filter(card => card !== focused).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) }
}, [cards, messages, conversationId, projectId])
if (current.length === 0 || !detailHere) return null
if (!detailHere) return null
if (record) return <div className="conversation-work" data-conversation-work><WorkExecutionRecord /></div>
if (current.length === 0) return null
const pending = pendingCount(current)
const active = current.filter(card => card.status === 'active').length
const results = current.filter(card => card.status === 'done' || card.status === 'failed').length
const summary = [pending ? `${pending} 项待处理` : '', active ? `${active} 项工作` : '', results ? `${results} 项结果` : ''].filter(Boolean).join(' · ')
const callbacks = {
onAction: (card: ActivityCard, actionId: string) => { void store.cardAction(card, actionId) },
onAction: (card: ActivityCard, actionId: string, payload?: WorkCardAction) => { void store.cardAction(card, actionId, payload) },
onOpenRecord: (card: ActivityCard) => store.openWorkRecord(card),
onReference: (card: ActivityCard) => store.referenceWork(card),
actionState: (card: ActivityCard) => actionStates?.[card.id],
onPin: (card: ActivityCard, pinned: boolean) => { void store.cardPin(card, pinned) },
onDismiss: (card: ActivityCard) => { void store.cardDismiss(card) },
+8
View File
@@ -0,0 +1,8 @@
/** Stable server time, with the full local date available to keyboard and assistive readers. */
export function MessageTimestamp({ value }: { value?: string }) {
const date = value ? new Date(value) : null
if (!date || !Number.isFinite(date.getTime())) return null
const full = date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
const label = date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
return <time className="message-timestamp" dateTime={date.toISOString()} title={full} aria-label={full}>{label}</time>
}
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useMemo, useState } from 'react'
import { SafeMarkdown } from '@ui'
import type { StreamEvent } from '../contracts'
import { loadRunEvents } from '../runEvents'
import { store, useStore } from '../store'
import { runEventsToTimeline } from '../live'
import { mergeProcessTimeline } from '../messageparts'
import { ProcessTimeline } from './ProcessTimeline'
import { MessageTimestamp } from './MessageTimestamp'
/** The work pane and inline fallback share one selection. Only this detail host
* reads that run's authorized durable events; opening a record never adds chat. */
export function WorkExecutionRecord() {
const selected = useStore(s => s.workRecord)
const conversationId = useStore(s => s.currentConvId)
const messages = useStore(s => s.messages)
const run = useStore(s => s.runs.find(r => r.id === selected?.runId))
const [result, setResult] = useState<{ runId: string; events: StreamEvent[] } | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
if (!selected || selected.conversationId !== conversationId) return
const controller = new AbortController()
let active = true
setResult(null); setFailed(false)
void (async () => {
try {
const events = await loadRunEvents(selected.conversationId, selected.runId, controller.signal)
if (active) setResult({ runId: selected.runId, events })
} catch { if (active) setFailed(true) }
})()
return () => { active = false; controller.abort() }
}, [selected?.runId, selected?.conversationId, conversationId, run?.status, run?.endedAt])
const parts = useMemo(() => {
if (!selected || result?.runId !== selected.runId) return []
const saved = messages.filter(message => message.runId === selected.runId && message.role === 'assistant')
.flatMap(message => message.parts).map(part => ({ ...part, process: true }))
return mergeProcessTimeline(saved, runEventsToTimeline(result.events, selected.runId, true), [])
}, [messages, result, selected?.runId])
if (!selected || selected.conversationId !== conversationId) return null
return <section className="work-execution-record" aria-label={`${selected.title}的执行记录`}>
<div className="work-record-heading"><strong>{selected.title} · 执行记录</strong>
<button type="button" className="btn ghost" onClick={() => store.closeWorkRecord()}>返回工作</button></div>
<MessageTimestamp value={run?.createdAt} />
{failed ? <p role="status">完整记录暂不可用,请稍后重新打开</p>
: !result ? <p role="status">正在读取已保存的记录…</p>
: parts.length === 0 ? <p>这项工作没有已保存的过程输出</p>
: <ProcessTimeline parts={parts} live={false} idKey={`work-record:${selected.runId}`}
renderPart={part => <SafeMarkdown className="part-text" markdown={part.text ?? ''} breaks />} />}
</section>
}
+11 -4
View File
@@ -2,13 +2,14 @@
// 工作 = 当前任务卡 + 收件箱(待处理事项、后台任务卡、执行记录);
// 文件 = 文件列表(搜索/上传)与多标签预览(预览、编辑、下载)——预览不再是独立第四栏。
import { store, useStore } from '../store'
import type { Run } from '../contracts'
import type { Run, WorkCardAction } from '../contracts'
import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui'
import { Icon, SurfaceView } from '@ui'
import { previewTabsKey } from '../previewtabs'
import { surfaceKey } from '../surfaceKey'
import { ArtifactsPanel } from './ArtifactsPanel'
import { PreviewPane } from './PreviewPane'
import { WorkExecutionRecord } from './WorkExecutionRecord'
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
// 打开语义走 requestPreview 唯一入口(第三栏「文件」入口)。
@@ -21,7 +22,9 @@ export const docBridge: DocBridge = {
}
const inboxCallbacks = {
onAction: (c: ActivityCard, id: string) => store.cardAction(c, id),
onAction: (c: ActivityCard, id: string, payload?: WorkCardAction) => store.cardAction(c, id, payload),
onOpenRecord: (c: ActivityCard) => store.openWorkRecord(c),
onReference: (c: ActivityCard) => store.referenceWork(c),
actionState: (c: ActivityCard) => store.getState().cardActionStates[c.id],
onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p),
onDismiss: (c: ActivityCard) => store.cardDismiss(c),
@@ -64,6 +67,7 @@ export function Workbench() {
const filter = useStore(s => s.ui.inboxFilter)
const visible = inboxScope === 'project' ? projectCards : cards
const pending = pendingCount(visible)
const unread = visible.filter(card => card.work && card.work.resultVersion > card.work.readVersion).length
// 离开文件入口时收回全屏展开,避免出现「工作」铺满主区的怪态。
const switchTab = (tab: 'work' | 'files') =>
store.setUI({ rightTab: tab, previewExpanded: tab === 'files' ? store.getState().ui.previewExpanded : false })
@@ -71,7 +75,7 @@ export function Workbench() {
<>
<div className="tabs">
<button className={`tab ${rightTab === 'work' ? 'active' : ''}`} onClick={() => switchTab('work')}
title="待处理事项、后台任务与执行记录">工作{pending > 0 ? ` ${pending}` : ''}</button>
title="待处理事项、后台任务与执行记录">工作{pending > 0 ? ` ${pending}` : ''}{unread > 0 ? ` · ${unread} 项新结果` : ''}</button>
<button className={`tab ${rightTab === 'files' ? 'active' : ''}`} onClick={() => switchTab('files')}
title="文件搜索、预览、编辑与下载">文件</button>
<span className="spacer" style={{ flex: 1 }} />
@@ -108,10 +112,12 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
projectCards: ActivityCard[]
cards: ActivityCard[]
}) {
const record = useStore(s => s.workRecord)
const showCurrentTask = !hasActiveProgressCard(cards)
return (
<>
<div className="tab-body" data-scroll-sync="inbox">
<div className="tab-body" data-work-scroll>
{record ? <WorkExecutionRecord /> : <>
{showCurrentTask ? <CurrentTaskCard plan={plan} todos={todos} /> : null}
<div className="inbox-scope" role="tablist" aria-label="收件箱范围与筛选">
<button className={inboxScope === 'conv' ? 'active' : ''} onClick={() => store.setInboxScope('conv')}>本会话</button>
@@ -126,6 +132,7 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
? <ProjectInbox cards={projectCards} filter={filter} />
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} filter={filter} />}
<WorkspaceSurfaces />
</>}
</div>
</>
)
+15 -1
View File
@@ -151,7 +151,21 @@ export interface ActivityCard {
sourceRunId?: ID; anchorMessageId?: ID;
kind: CardKind; title: string; status: CardStatus; tone?: string;
body?: UINode[]; actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger'; command?: string }[];
pinned?: boolean; createdAt: string; updatedAt: string;
pinned?: boolean; createdAt: string; updatedAt: string; work?: WorkCard;
}
export interface WorkCard {
result?: string; resultVersion: number; readVersion: number; completedAt?: string;
decision?: WorkDecision;
}
export interface WorkDecision {
runId?: ID;
mode: 'single' | 'multiple'; state: 'open' | 'deferred' | 'dispatching' | 'submitted';
options?: { id: string; label: string }[];
selectedIds?: string[]; freeText?: string; respondedAt?: string;
}
export interface WorkCardAction {
resultVersion: number; selectedIds?: string[]; freeText?: string;
}
export interface CardPayload { card: ActivityCard }
+32 -8
View File
@@ -13,7 +13,7 @@ export type ToolLiveState = 'composing' | 'running' | 'done'
export type LiveItem =
| { kind: 'thinking'; text: string; startedAt: number; endedAt: number }
| { kind: 'text'; index: number; text: string }
| { kind: 'agent'; text: string }
| { kind: 'agent'; text: string; agentRun?: string; agentPhase?: 'spawn' | 'progress' | 'result' }
| {
kind: 'tool'
callId?: string
@@ -151,6 +151,27 @@ export function applyCheck(items: LiveItem[], passed: boolean, reason: string):
return [...items, { kind: 'check', passed, reason }]
}
/** A child streams cumulative previews. Replace that child's latest preview in
* place, while preserving real phase changes and the order of other children. */
function applyAgentProgress(items: LiveItem[], agentRun: string, note: string): LiveItem[] {
const text = note.trim()
if (!text) return items
for (let i = items.length - 1; i >= 0; i--) {
const previous = items[i]
if (previous.kind !== 'agent' || previous.agentRun !== agentRun) continue
if (previous.agentPhase !== 'progress') break
const value = previous.text.slice('协作进展:'.length)
if (value === text || value.startsWith(text)) return items
if (text.startsWith(value) && !/^调用\s/.test(text)) {
const next = items.slice()
next[i] = { ...previous, text: `协作进展:${text}` }
return next
}
break
}
return [...items, { kind: 'agent', text: `协作进展:${text}`, agentRun, agentPhase: 'progress' }]
}
// applyFinalizing 标记收尾开始:此前的正文都是过程,此后的正文才是给用户的回复。
export function applyFinalizing(items: LiveItem[]): LiveItem[] {
if (items.some(it => it.kind === 'final')) return items
@@ -225,11 +246,12 @@ export function liveToMessage(live: LiveState): Message {
parts.push({ type: 'text', text: it.text, process: textProcess.get(i) || undefined })
}
}
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' }
const createdAt = live.startedAt > 0 && Number.isFinite(live.startedAt) ? new Date(live.startedAt).toISOString() : ''
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt }
}
/** Rebuild one run's process from its durable ordered events, without the live preview's output-tail limit. */
export function runEventsToTimeline(events: StreamEvent[], parentRunId = events[0]?.runId): ReplayPart[] {
export function runEventsToTimeline(events: StreamEvent[], parentRunId = events[0]?.runId, completeRecord = false): ReplayPart[] {
let items: LiveItem[] = []
let finalizing = false
for (const event of [...events].sort((a, b) => a.seq - b.seq)) {
@@ -238,19 +260,21 @@ export function runEventsToTimeline(events: StreamEvent[], parentRunId = events[
if (event.runId !== parentRunId && event.type !== 'agent.progress' && event.type !== 'agent.result') continue
switch (event.type) {
case 'agent.spawn':
if (typeof p?.name === 'string') items.push({ kind: 'agent', text: `协作代理「${p.name}」已开始` })
if (typeof p?.name === 'string') items.push({ kind: 'agent', text: `协作代理「${p.name}」已开始`,
agentRun: String(p.agentRun ?? event.runId ?? ''), agentPhase: 'spawn' })
break
case 'agent.progress':
if (typeof p?.note === 'string' && p.note.trim()) items.push({ kind: 'agent', text: `协作进展:${p.note}` })
if (typeof p?.note === 'string') items = applyAgentProgress(items, String(p.agentRun ?? event.runId ?? ''), p.note)
break
case 'agent.result':
items.push({ kind: 'agent', text: `协作结束:${typeof p?.summary === 'string' && p.summary.trim() ? p.summary : String(p?.status ?? '已结束')}` })
items.push({ kind: 'agent', text: `协作结束:${typeof p?.summary === 'string' && p.summary.trim() ? p.summary : String(p?.status ?? '已结束')}`,
agentRun: String(p?.agentRun ?? event.runId ?? ''), agentPhase: 'result' })
break
case 'thinking.delta':
if (typeof p?.text === 'string') items = applyThinkingDelta(items, p.text, now)
break
case 'message.delta':
if (!finalizing && typeof p?.text === 'string') items = applyTextDelta(items, typeof p.index === 'number' ? p.index : 0, p.text)
if ((!finalizing || completeRecord) && typeof p?.text === 'string') items = applyTextDelta(items, typeof p.index === 'number' ? p.index : 0, p.text)
break
case 'tool.call.delta':
if (typeof p?.delta === 'string') items = applyToolCallDelta(items, typeof p.index === 'number' ? p.index : 0,
@@ -278,7 +302,7 @@ export function runEventsToTimeline(events: StreamEvent[], parentRunId = events[
}
}
const live: LiveState = { runId: events[0]?.runId ?? '', phase: 'done', items, agentNotes: {}, startedAt: 0 }
return splitTimeline(liveToMessage(live).parts).timeline
return completeRecord ? liveToMessage(live).parts : splitTimeline(liveToMessage(live).parts).timeline
}
// livePhaseLabel 用最后一个实时条目给出中文阶段,而不是后端的英文 phase。
+6 -1
View File
@@ -1,17 +1,22 @@
// 输入排队(纯数据):回合进行中先入本地队列,终态再逐条发送。
export interface WorkReference { cardId: string; resultVersion: number; title: string }
export interface QueuedMessage {
id: string
text: string
attachmentIds: string[]
attachmentNames: string[]
taskRef?: WorkReference
deliveryError?: string
}
export function newQueuedMessage(text: string, attachmentIds: string[], attachmentNames: string[]): QueuedMessage {
export function newQueuedMessage(text: string, attachmentIds: string[], attachmentNames: string[], taskRef?: WorkReference): QueuedMessage {
return {
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
text,
attachmentIds,
attachmentNames,
...(taskRef ? { taskRef } : {}),
}
}
+17
View File
@@ -0,0 +1,17 @@
import { api } from './api'
import type { StreamEvent } from './contracts'
/** Read the durable record in bounded pages; callers own cancellation and projection. */
export async function loadRunEvents(conversationId: string, runId: string, signal: AbortSignal): Promise<StreamEvent[]> {
const events: StreamEvent[] = []
let after = 0
while (true) {
const page = await api.runEventsPage(conversationId, runId, after, signal)
const rows = page.events ?? []
events.push(...rows)
if (rows.length < 500) return events
const next = rows[rows.length - 1].seq
if (next <= after) throw new Error('process event cursor did not advance')
after = next
}
}
+1 -21
View File
@@ -1,24 +1,4 @@
// 中栏 → 右栏单向滚动同步,以及互定位后的 1.2s 闪烁。
// 比例按可滚动范围计算;任一侧没有溢出时不同步,避免把静止栏拽动。
export interface ScrollBox {
scrollTop: number
scrollHeight: number
clientHeight: number
}
export function scrollRatio(box: ScrollBox): number | null {
const range = box.scrollHeight - box.clientHeight
if (range <= 0) return null
return box.scrollTop / range
}
export function scrollTopForRatio(box: Pick<ScrollBox, 'scrollHeight' | 'clientHeight'>, ratio: number): number | null {
const range = box.scrollHeight - box.clientHeight
if (range <= 0) return null
const clamped = Math.min(1, Math.max(0, ratio))
return clamped * range
}
// Explicit message/card location feedback. The middle and work panes scroll independently.
/** 给已定位的节点加上 mk-flash,1.2s 后摘掉。 */
export function flashElement(el: Element | null) {
+76 -30
View File
@@ -6,7 +6,7 @@ import { auth } from './auth'
import { projectActionError, projectWorkError } from './projectmeta'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
import { closePreviewTab, mergeOrphanPreviewTabs, openPreviewTab, previewTabsKey, type PreviewTab } from './previewtabs'
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage, type WorkReference } from './outbox'
import { canFlushOutbox, deriveShellPhase } from './runstate'
import { flashElement } from './scrollsync'
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
@@ -34,7 +34,8 @@ import type {
StreamEvent,
SurfaceMeta,
SurfaceSnapshot,
TodoList
TodoList,
WorkCardAction
} from './contracts'
export interface ContextObs {
@@ -113,6 +114,8 @@ interface AppState {
projectCards: ActivityCard[]
/** 同一操作的双入口反馈;未知结果必须先对账,不重新发送副作用。 */
cardActionStates: Record<string, 'submitting' | 'unknown'>
workRecord: { cardId: string; runId: string; conversationId: string; title: string } | null
workReference: WorkReference | null
inboxScope: 'conv' | 'project'
/** runtime.mock:只控制演示指令提示,不改变对话能力。 */
runtimeMock: boolean
@@ -193,7 +196,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: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
serverWorkspaces: [],
rdmAuditAvailable: false,
rdmExportAvailable: false,
@@ -306,6 +309,7 @@ let cardPatches: Record<string, Pick<ActivityCard, 'status' | 'tone'>> = {}
let projectCardPatches: Record<string, Pick<ActivityCard, 'status' | 'tone'>> = {}
const actionInflight = new Set<string>()
const actionFeedbackOwners: Record<string, symbol> = {}
const workActionExpectations = new Map<string, { actionId: string; payload: WorkCardAction }>()
function observeDetail(observations: Record<string, DetailObservation>, id: string, seq = state.lastSeq, terminal = false) {
const version = ++detailVersion
@@ -366,7 +370,7 @@ function actionFeedback(key: string, value?: 'submitting' | 'unknown', ids: stri
if (value ? current[id] === value : !(id in current)) continue
states ??= { ...state.cardActionStates }
if (value) states[id] = value
else { delete states[id]; delete actionFeedbackOwners[id] }
else { delete states[id]; delete actionFeedbackOwners[id]; workActionExpectations.delete(id) }
}
if (states) set({ cardActionStates: states })
}
@@ -381,13 +385,22 @@ function startActionFeedback(key: string, ids: string[] = []) {
function settleCardFeedback(cards: ActivityCard[]) {
let states: AppState['cardActionStates'] | undefined
for (const card of cards) {
if (card.status === 'action_required') continue
const expected = workActionExpectations.get(card.id)
if (expected) {
const decision = card.work?.decision?.state
const settled = expected.actionId === 'work.read' ? (card.work?.readVersion ?? 0) >= expected.payload.resultVersion
: expected.actionId === 'work.defer' ? decision === 'deferred'
: expected.actionId === 'work.reopen' ? decision === 'open' : decision === 'submitted'
if (!settled) continue
}
if (!expected && card.status === 'action_required') continue
const source = hitlSource(card)
for (const id of [card.id, ...(source ? [`hitl:${source}`] : [])]) {
if (!(id in (states ?? state.cardActionStates))) continue
states ??= { ...state.cardActionStates }
delete states[id]
delete actionFeedbackOwners[id]
workActionExpectations.delete(id)
}
}
if (states) set({ cardActionStates: states })
@@ -419,6 +432,7 @@ export const store = {
cardVersions = {}; projectCardVersions = {}; cardPatches = {}; projectCardPatches = {}
runObservations = {}; messageObservations = {}; hitlObservations = {}
actionInflight.clear()
workActionExpectations.clear()
for (const key of Object.keys(actionFeedbackOwners)) delete actionFeedbackOwners[key]
for (const key of Object.keys(surfaceActionsInflight)) delete surfaceActionsInflight[key]
convRestoreDone = true
@@ -426,7 +440,7 @@ export const store = {
set({ projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
contextObs: {}, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [],
cards: [], projectCards: [], cardActionStates: {}, inboxScope: 'conv',
cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv',
runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], adminSections: [],
rdmAuditAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
@@ -513,7 +527,7 @@ export const store = {
conversationEpoch++; streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
set({ currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
cards: [], projectCards: [], plan: null, todos: null, surfaces: [], projectSurfaces: [],
cards: [], projectCards: [], workRecord: null, workReference: null, plan: null, todos: null, surfaces: [], projectSurfaces: [],
artifacts: [], outbox: [], outboxDraining: false, posting: false })
setUI({ previewExpanded: false, filesView: 'list' })
}
@@ -603,7 +617,7 @@ export const store = {
const epoch = ++conversationEpoch
streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], surfaces: [], projectSurfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], workRecord: null, workReference: null, surfaces: [], projectSurfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
setUI({ previewExpanded: false })
persistConvId(convId)
// 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。
@@ -716,6 +730,9 @@ export const store = {
if (e.runId && liveEventTypes.has(e.type)) observeDetail(runObservations, e.runId, e.seq)
const now = eventTime(e)
const project = (mutate: (l: LiveState) => void) => touchLive(e.runId ?? '', mutate, now)
const belongsToLive = (agentRun: string) => !!state.live && (e.runId === state.live.runId
|| Object.prototype.hasOwnProperty.call(state.live.agentNotes, agentRun)
|| state.runs.some(run => run.id === agentRun && run.parentRunId === state.live?.runId))
switch (e.type) {
case 'message.delta': {
const p = e.payload as { index: number; text: string }
@@ -777,17 +794,17 @@ export const store = {
}
case 'agent.spawn': {
const p = e.payload as { agentRun: string; name: string; task: string }
if (state.live) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: `已派生 ${p.name}` } } })
if (state.live && belongsToLive(p.agentRun)) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: `已派生 ${p.name}` } } })
break
}
case 'agent.progress': {
const p = e.payload as { agentRun: string; note?: string }
if (state.live && p.note) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: p.note } } })
if (state.live && p.note && belongsToLive(p.agentRun)) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: p.note } } })
break
}
case 'agent.result': {
const p = e.payload as { agentRun: string; summary?: string }
if (state.live) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: p.summary ?? '任务已结束' } } })
if (state.live && belongsToLive(p.agentRun)) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: p.summary ?? '任务已结束' } } })
store.reconcile()
break
}
@@ -888,7 +905,7 @@ export const store = {
}
},
async sendMessage(text: string, attachmentIds: string[], requestId?: string): Promise<'ok' | 'busy'> {
async sendMessage(text: string, attachmentIds: string[], requestId?: string, taskRef?: WorkReference): Promise<'ok' | 'busy' | 'rejected'> {
const convId = state.currentConvId
const epoch = conversationEpoch
const sameContext = () => state.currentConvId === convId && conversationEpoch === epoch
@@ -896,7 +913,7 @@ export const store = {
const id = requestId ?? newQueuedMessage(text, attachmentIds, []).id
set({ posting: true })
try {
const out = await api.sendMessage(convId, text, attachmentIds, id)
const out = await (taskRef ? api.sendMessage(convId, text, attachmentIds, id, taskRef) : api.sendMessage(convId, text, attachmentIds, id))
if (!sameContext()) return 'ok'
if (out.command) {
store.toast(out.reply ?? `命令 ${out.command} 已执行`)
@@ -922,8 +939,8 @@ export const store = {
// 竞态保护:POST 返回前 SSE 事件可能已先行到达并累积(同 run),
// 此时保留已累积的 live,只补挂用户消息;否则初始化空 live。
const live = !state.live || state.live.runId !== started.id
? { runId: started.id, phase: 'thinking', items: [], agentNotes: {}, startedAt: Date.now() }
: state.live
? { runId: started.id, phase: 'thinking', items: [], agentNotes: {}, startedAt: eventTime({ ts: started.createdAt }) }
: { ...state.live, startedAt: eventTime({ ts: started.createdAt }, state.live.startedAt) }
set({ live, runs, messages })
store.connectIfNeeded()
}
@@ -931,19 +948,22 @@ export const store = {
} catch (e) {
if (!sameContext()) return 'ok'
if (isConversationBusy(e)) {
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds, attachmentNames: [] }) })
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds, attachmentNames: [], ...(taskRef ? { taskRef } : {}) }) })
return 'busy'
}
store.toast(`发送失败:${e}`)
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds,
attachmentNames: attachmentIds.map(attachmentId => state.artifacts.find(artifact => artifact.id === attachmentId)?.name ?? '已添加材料'),
...(taskRef ? { taskRef } : {}), deliveryError: '发送未成功,内容已保留;可修改后重试或移除' }) })
await store.reconcile()
return 'ok'
return 'rejected'
} finally {
if (sameContext()) set({ posting: false })
}
},
enqueueOutbox(text: string, attachmentIds: string[], attachmentNames: string[]) {
set({ outbox: enqueueMessage(state.outbox, newQueuedMessage(text, attachmentIds, attachmentNames)) })
enqueueOutbox(text: string, attachmentIds: string[], attachmentNames: string[], taskRef?: WorkReference) {
set({ outbox: enqueueMessage(state.outbox, newQueuedMessage(text, attachmentIds, attachmentNames, taskRef)) })
},
updateOutbox(id: string, text: string) {
@@ -954,6 +974,10 @@ export const store = {
set({ outbox: removeQueuedMessage(state.outbox, id) })
},
clearOutboxReference(id: string) {
set({ outbox: state.outbox.map(item => item.id === id ? { ...item, taskRef: undefined } : item) })
},
async sendOutboxNow(id: string) {
const item = state.outbox.find(q => q.id === id)
if (!item) return
@@ -964,23 +988,23 @@ export const store = {
return
}
set({ outbox: rest })
await store.sendMessage(item.text, item.attachmentIds, deliveryRequestId(item))
await store.sendMessage(item.text, item.attachmentIds, deliveryRequestId(item), item.taskRef)
},
async flushOutbox() {
if (state.outboxDraining) return
if (!canFlushOutbox(shellPhase())) return
const next = state.outbox[0]
const next = state.outbox.find(item => !item.deliveryError)
if (!next) return
set({ outbox: state.outbox.slice(1), outboxDraining: true })
let result: 'ok' | 'busy' = 'ok'
set({ outbox: removeQueuedMessage(state.outbox, next.id), outboxDraining: true })
let result: 'ok' | 'busy' | 'rejected' = 'ok'
try {
result = await store.sendMessage(next.text, next.attachmentIds, deliveryRequestId(next))
result = await store.sendMessage(next.text, next.attachmentIds, deliveryRequestId(next), next.taskRef)
} finally {
set({ outboxDraining: false })
}
if (result === 'busy') return
if (canFlushOutbox(shellPhase()) && state.outbox.length > 0) {
if (canFlushOutbox(shellPhase()) && state.outbox.some(item => !item.deliveryError)) {
await store.flushOutbox()
}
},
@@ -1065,7 +1089,8 @@ export const store = {
// 卡片动作:确认投影卡(来源 hitl:)路由回 HITL 解析(恢复挂起 run,服务端同步销项);
// 其余卡走卡片动作 API,并把服务端持久化的回执消息追加进本地时间线(id 一致,对账不重复)。
async cardAction(card: ActivityCard, actionId: string, freeText?: string) {
async cardAction(card: ActivityCard, actionId: string, payload?: string | WorkCardAction) {
const freeText = typeof payload === 'object' ? payload.freeText : payload
const source = hitlSource(card)
if (source) {
await store.resolveHITL(source, actionId ? { choiceId: actionId } : { freeText })
@@ -1080,8 +1105,9 @@ export const store = {
const sameContext = () => identity === identityEpoch && state.currentProjectId === pid && (!card.conversationId || (state.currentConvId === convId && conversationEpoch === epoch))
actionInflight.add(card.id)
const feedbackOwner = startActionFeedback(card.id)
if (actionId.startsWith('work.') && typeof payload === 'object') workActionExpectations.set(card.id, { actionId, payload })
try {
const out = await api.cardAction(card.id, actionId, freeText)
const out = await api.cardAction(card.id, actionId, payload)
if (!sameContext()) return
const newer = (card.conversationId === state.currentConvId ? cardVersions[card.id] : projectCardVersions[card.id]) > startedVersion
if (!newer && card.conversationId === state.currentConvId) store.upsertCard(out.card)
@@ -1090,7 +1116,7 @@ export const store = {
const cards = state.projectCards
set({ projectCards: cards.some(c => c.id === out.card.id) ? cards.map(c => c.id === out.card.id ? out.card : c) : [out.card, ...cards] })
}
if (out.receipt && out.receipt.conversationId === state.currentConvId && !state.messages.some(m => m.id === out.receipt?.id)) {
if (!actionId.startsWith('work.') && out.receipt && out.receipt.conversationId === state.currentConvId && !state.messages.some(m => m.id === out.receipt?.id)) {
observeDetail(messageObservations, out.receipt.id)
set({ messages: [...state.messages, out.receipt as Message] })
}
@@ -1101,7 +1127,7 @@ export const store = {
}
if (sameContext()) {
const current = [...state.cards, ...state.projectCards].find(c => c.id === card.id)
actionFeedback(card.id, actionId !== 'recheck' && current?.status === 'action_required' ? 'unknown' : undefined, [], feedbackOwner)
actionFeedback(card.id, !actionId.startsWith('work.') && actionId !== 'recheck' && current?.status === 'action_required' ? 'unknown' : undefined, [], feedbackOwner)
}
} catch (e) {
const rejected = /^4\d\d:/.test(String(e instanceof Error ? e.message : e))
@@ -1327,6 +1353,7 @@ export const store = {
setUI({ rightCollapsed: false, rightTab: 'work', inboxFilter: 'todo' })
},
openInbox(cardId?: string) {
if (state.workRecord) set({ workRecord: null })
if (cardId) {
const scope = state.cards.some(card => card.id === cardId) ? 'conv'
: state.projectCards.some(card => card.id === cardId) ? 'project' : 'conv'
@@ -1335,6 +1362,24 @@ export const store = {
setUI({ rightCollapsed: false, rightTab: 'work', ...(cardId ? { inboxFilter: 'all' as const } : {}) })
if (cardId) setTimeout(() => flashLocate('.right-panel', 'data-card-id', cardId), 60)
},
openWorkRecord(card: ActivityCard) {
if (!card.sourceRunId || !card.conversationId || card.conversationId !== state.currentConvId || card.projectId !== state.currentProjectId) {
store.toast('请先打开这项工作所属的对话,再查看执行记录')
return
}
set({ workRecord: { cardId: card.id, runId: card.sourceRunId, conversationId: card.conversationId, title: card.title } })
},
closeWorkRecord() { set({ workRecord: null }) },
referenceWork(card: ActivityCard) {
if (card.projectId !== state.currentProjectId || card.conversationId !== state.currentConvId || !card.work?.result?.trim()) {
store.toast('请在这项工作所属的对话中引用结果')
return
}
set({ workReference: { cardId: card.id, resultVersion: card.work.resultVersion, title: card.title } })
if (card.work.resultVersion > card.work.readVersion) void store.cardAction(card, 'work.read', { resultVersion: card.work.resultVersion })
if (typeof document !== 'undefined') document.querySelector<HTMLTextAreaElement>('.input-row textarea')?.focus()
},
clearWorkReference() { set({ workReference: null }) },
/** 左栏/收起轨的「文件」入口:打开第三栏文件入口(列表或既有预览)。 */
openFiles() { setUI({ rightTab: 'files', rightCollapsed: false }) },
// 卡→消息互定位另一半(D32):从收件箱卡滚回产生它的对话消息。
@@ -1424,7 +1469,8 @@ auth.onIdentityChange(() => store.resetIdentity())
function touchLive(_runId: string, mutate: (l: LiveState) => void, now = Date.now()): LiveState | null {
if (state.runs.find(r => r.id === _runId)?.kind === 'subagent') return state.live
if (state.live && state.live.runId !== _runId && !isTerminal(state.runs.find(r => r.id === state.live?.runId)?.status)) return state.live
const base = state.live ?? { runId: _runId, phase: 'thinking', items: [], agentNotes: {}, startedAt: now }
const base = state.live ?? { runId: _runId, phase: 'thinking', items: [], agentNotes: {},
startedAt: eventTime({ ts: state.runs.find(run => run.id === _runId)?.createdAt }, now) }
const next: LiveState = { ...base, agentNotes: { ...base.agentNotes } }
mutate(next)
return next