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:
+6
-5
@@ -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)),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user