diff --git a/e2e/work-companion.playwright.ts b/e2e/work-companion.playwright.ts index c1dd667..4aad6b7 100644 --- a/e2e/work-companion.playwright.ts +++ b/e2e/work-companion.playwright.ts @@ -1,5 +1,5 @@ import { test, expect, type Page } from '@playwright/test' -import type { ActivityCard, Artifact, Conversation, Part, StreamEvent } from '../src/contracts' +import type { ActivityCard, Artifact, Conversation, Part, StreamEvent, WorkCardAction } from '../src/contracts' test.skip(!process.env.WORK_COMPANION_BROWSER, 'Run explicitly against the task-owned UI preview') @@ -27,7 +27,8 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo let actionCount = 0 let releaseAction: (() => void) | undefined let holdAction = false - let sent: { text: string; attachmentIds: string[] } | undefined + let sent: { text: string; attachmentIds: string[]; taskRef?: { cardId: string; resultVersion: number } } | undefined + const workActions: { cardId: string; actionId: string; payload: WorkCardAction }[] = [] let uploadNumber = 0 const errors: string[] = [] page.on('pageerror', error => errors.push(error.message)) @@ -91,6 +92,22 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo else if (/\/messages$/.test(path) && request.method() === 'POST') { sent = request.postDataJSON() body = { message: { id: 'sent', conversationId: 'c1', role: 'user', parts: [{ type: 'text', text: sent?.text ?? '' }], createdAt: stamp }, run: { id: 'run1', conversationId: 'c1', projectId: 'p1', kind: 'conversation', status: 'running', createdAt: stamp } } + } else if (/^\/api\/cards\/[^/]+\/actions\/work\./.test(path)) { + const cardId = path.split('/')[3] + const actionId = path.split('/').at(-1)! + const payload = request.postDataJSON() as WorkCardAction + workActions.push({ cardId, actionId, payload }) + cards = cards.map(card => { + if (card.id !== cardId || !card.work) return card + const next = { ...card, work: { ...card.work } } + if (actionId === 'work.read') next.work.readVersion = payload.resultVersion + else if (next.work.decision) next.work.decision = { ...next.work.decision, + state: actionId === 'work.defer' ? 'deferred' : actionId === 'work.reopen' ? 'open' : 'submitted', + ...(actionId === 'work.submit' ? { selectedIds: payload.selectedIds, freeText: payload.freeText, respondedAt: stamp } : {}), + } + return next + }) + body = { card: cards.find(card => card.id === cardId) } } else if (path === '/api/cards/confirm1/actions/confirm') { actionCount++ if (holdAction) await new Promise(resolve => { releaseAction = resolve }) @@ -102,7 +119,7 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo await page.goto('/') await expect(page.locator('.chat-title')).toHaveText('整理项目材料') if (initialCards.length) await expect(page.locator('[data-conversation-work]')).toBeVisible() - return { errors, get actionCount() { return actionCount }, get sent() { return sent }, hold() { holdAction = true }, release() { releaseAction?.() }, + return { errors, workActions, get actionCount() { return actionCount }, get sent() { return sent }, hold() { holdAction = true }, release() { releaseAction?.() }, async emit(type: StreamEvent['type'], payload: unknown, seq: number) { await page.evaluate(({ type, payload, seq }) => { const win = window as typeof window & { __workStreams: { url: string; emit: (e: unknown) => void }[] } @@ -114,6 +131,143 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo } } +function optionalWork(id = 'optional-work', mode: 'single' | 'multiple' = 'single'): ActivityCard { + return work(id, 'done', { title: '说明书材料整理', sourceRunId: 'run-history', + work: { resultVersion: 2, readVersion: 0, completedAt: stamp, + result: '# 说明书重点\n\n已整理软件结构、角色权限与操作流程,原文件保留。\n\n' + + '- **使用角色**:管理员、医生和患者\n- **软件结构**:患者端与医生端连接同一服务端\n- **核心内容**:安装维护、训练操作与安全说明\n\n' + + '## 可继续整理的内容\n\n可以先阅读摘要了解全貌,也可以单独提取性能参数、设备要求与需要进一步确认的事项。' + + '整理结果仅依据当前上传的说明书;缺少的材料和不能确认的内容会单独列出,不会补写成已确认的结论。' + + '如果继续生成交付物,新的结果会更新到独立工作卡,您可以同时讨论其他话题。' + + '每项结论都应保留材料范围和来源,便于后续复核。\n\n' + + '## 后续核对\n\n在正式采用整理结果前,建议把产品版本、适用设备、使用人员与预期交付形式分别确认。' + + '材料中直接写明的内容和整理时发现的问题会分开记录。对没有原文依据的数字、范围或归属,整理结果会保持待确认状态。' + + '需要比较其他版本时,可补充相应文件,并说明希望重点比较的部分。\n\n最后一条:需要确认使用场景。', + decision: { mode, state: 'open', options: [{ id: 'summary', label: '生成摘要' }, { id: 'facts', label: '提取参数' }] }, + }, + }) +} + +test('keeps optional choices nonblocking and supports defer, reopen, free input and saved answers', async ({ page }) => { + const state = await fixture(page, [optionalWork('optional-work', 'multiple')]) + const inline = page.locator('[data-conversation-work] [data-card-id="optional-work"]') + await expect(inline.getByRole('checkbox', { name: '生成摘要', exact: true })).toBeVisible() + await inline.getByRole('button', { name: '先不选择', exact: true }).click() + await expect(inline.getByText('已暂缓选择,您可以继续对话')).toBeVisible() + await expect(inline.getByRole('checkbox')).toHaveCount(0) + await page.getByRole('textbox', { name: '工作目标或消息', exact: true }).fill('先聊一下本周的安排') + await page.getByRole('button', { name: '发送', exact: true }).click() + await expect.poll(() => state.sent?.text).toBe('先聊一下本周的安排') + expect(state.sent?.taskRef).toBeUndefined() + const assistantCount = await page.locator('.msg.assistant').count() + await inline.getByRole('button', { name: '继续选择', exact: true }).click() + await inline.getByRole('checkbox', { name: '生成摘要', exact: true }).check() + await inline.getByRole('checkbox', { name: '提取参数', exact: true }).check() + await inline.getByRole('textbox', { name: '补充要求或直接输入需求' }).fill('保留原文中的来源和限制') + await inline.getByRole('button', { name: '开始处理', exact: true }).click() + await expect(inline.getByText('已提交需求', { exact: true })).toBeVisible() + await expect(inline.getByText('保留原文中的来源和限制', { exact: true })).toBeVisible() + await expect(inline.getByRole('textbox')).toHaveCount(0) + expect(state.workActions.filter(action => action.actionId === 'work.submit')).toEqual([{ + cardId: 'optional-work', actionId: 'work.submit', payload: { resultVersion: 2, selectedIds: ['summary', 'facts'], freeText: '保留原文中的来源和限制' }, + }]) + await expect(page.locator('.msg.assistant')).toHaveCount(assistantCount) + expect(state.errors).toEqual([]) +}) + +test('opens full results in one place, preserves unread until explicit action and references a version in the composer', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 1050 }) + const state = await fixture(page, [optionalWork()]) + await expect(page.locator('.work-result-notice')).toContainText('1 项工作有新结果') + await page.locator('.work-result-notice').click() + const card = page.locator('.right-panel [data-card-id="optional-work"]') + await expect(page.locator('[data-conversation-work]')).toHaveCount(0) + await expect(card.getByRole('radio', { name: '生成摘要', exact: true })).toBeVisible() + if (process.env.WORKBENCH_FLOW_SCREENSHOT) await page.screenshot({ path: process.env.WORKBENCH_FLOW_SCREENSHOT, fullPage: true }) + await card.getByRole('button', { name: '查看完整结果', exact: true }).click() + await expect(card.getByText('最后一条:需要确认使用场景。', { exact: false })).toBeVisible() + expect(state.workActions.filter(action => action.actionId === 'work.read')).toHaveLength(0) + await card.getByRole('button', { name: '基于此结果继续', exact: true }).click() + await expect(page.getByRole('group', { name: '本条消息引用的工作结果' })).toContainText('第 2 版结果') + await expect.poll(() => state.workActions.filter(action => action.actionId === 'work.read').length).toBe(1) + await page.getByRole('textbox', { name: '工作目标或消息', exact: true }).fill('解释一下需要确认的事项') + await page.getByRole('button', { name: '发送', exact: true }).click() + await expect.poll(() => state.sent?.taskRef).toEqual({ cardId: 'optional-work', resultVersion: 2 }) + expect(state.sent?.text).toBe('解释一下需要确认的事项') + await expect(page.getByRole('group', { name: '本条消息引用的工作结果' })).toHaveCount(0) + await expect(page.locator('.msg.user time[datetime]')).toHaveCount(2) + await expect(page.locator('.msg.assistant time[datetime]')).toHaveCount(2) + expect(state.errors).toEqual([]) +}) + +test('retains text, uploads and the result reference when mixed input is not supported', async ({ page }) => { + const state = await fixture(page, [optionalWork()]) + await page.locator('.work-result-notice').click() + await page.getByRole('button', { name: '基于此结果继续', exact: true }).click() + await page.getByLabel('添加材料', { exact: true }).setInputFiles({ name: '补充材料.txt', mimeType: 'text/plain', buffer: Buffer.from('补充要求') }) + const input = page.getByRole('textbox', { name: '工作目标或消息', exact: true }) + await input.fill('结合补充材料解释重点') + await page.getByRole('button', { name: '发送', exact: true }).click() + await expect(input).toHaveValue('结合补充材料解释重点') + await expect(page.locator('.attach-chip')).toContainText('补充材料.txt') + await expect(page.getByRole('group', { name: '本条消息引用的工作结果' })).toBeVisible() + expect(state.sent).toBeUndefined() +}) + +test('keeps middle and right scrolling independent while retaining full execution output', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 800 }) + const events: StreamEvent[] = [ + { seq: 1, type: 'thinking.delta', payload: { text: '已保存的核对思路' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 }, + { seq: 2, type: 'tool.call', payload: { callId: 'call-record', name: 'fs_read_file', arguments: '{"path":"说明书"}' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 }, + { seq: 3, type: 'tool.output', payload: { callId: 'call-record', text: '完整的文档读取记录' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 }, + { seq: 4, type: 'tool.result', payload: { callId: 'call-record', status: 'ok' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 }, + ] + const cards = [optionalWork(), ...Array.from({ length: 15 }, (_, index) => work(`active-${index}`, 'active', { title: `其他工作 ${index + 1}` }))] + const state = await fixture(page, cards, 14, { events, savedParts: [{ type: 'text', text: '继续讨论当前主题。\n\n'.repeat(100) }] }) + await page.getByRole('button', { name: '打开工作', exact: true }).click() + const right = page.locator('.right-panel [data-work-scroll]') + const chat = page.locator('.chat-body') + await right.evaluate(element => { element.scrollTop = 180 }) + const rightTop = await right.evaluate(element => element.scrollTop) + await chat.evaluate(element => { element.scrollTop = 80; element.dispatchEvent(new Event('scroll', { bubbles: true })) }) + expect(await right.evaluate(element => element.scrollTop)).toBe(rightTop) + const chatTop = await chat.evaluate(element => element.scrollTop) + await right.evaluate(element => { element.scrollTop = 240; element.dispatchEvent(new Event('scroll', { bubbles: true })) }) + expect(await chat.evaluate(element => element.scrollTop)).toBe(chatTop) + await page.locator('.right-panel [data-card-id="optional-work"]').getByRole('button', { name: '查看完整执行记录', exact: true }).click() + const record = page.getByRole('region', { name: '说明书材料整理的执行记录' }) + await expect(record.getByText('已保存的核对思路', { exact: true })).toBeVisible() + await record.getByRole('button', { name: /查看记录/ }).click() + await expect(record.getByText('完整的文档读取记录', { exact: true })).toBeVisible() + await expect(page.locator('.work-execution-record')).toHaveCount(1) + await record.getByRole('button', { name: '返回工作', exact: true }).click() + await expect(page.locator('.right-panel [data-card-id="optional-work"]')).toBeVisible() + expect(state.errors).toEqual([]) +}) + +test('folds read task history newest first and retains submitted choices after reload', async ({ page }) => { + const older = optionalWork('older') + const latest = optionalWork('latest') + older.title = '旧材料整理'; latest.title = '新材料整理' + older.work = { ...older.work!, readVersion: 2, decision: { mode: 'single', state: 'submitted', selectedIds: ['summary'], freeText: '保留参数表', respondedAt: stamp, + options: [{ id: 'summary', label: '生成摘要' }] } } + latest.work = { ...latest.work!, readVersion: 2, decision: { mode: 'single', state: 'deferred' } } + latest.updatedAt = '2026-10-13T10:00:00Z' + const state = await fixture(page, [older, latest]) + await page.getByRole('button', { name: '打开工作', exact: true }).click() + await expect(page.locator('.right-panel [data-card-id]')).toHaveCount(0) + await page.locator('.right-panel').getByRole('button', { name: '工作历史 · 2', exact: true }).click() + await expect(page.locator('.right-panel [data-card-id]')).toHaveCount(2) + expect(await page.locator('.right-panel [data-card-id]').evaluateAll(elements => elements.map(element => element.getAttribute('data-card-id')))).toEqual(['latest', 'older']) + await expect(page.locator('.right-panel [data-card-id="older"]').getByText('保留参数表', { exact: true })).toBeVisible() + await page.reload() + await page.getByRole('button', { name: '打开工作', exact: true }).click() + await page.locator('.right-panel').getByRole('button', { name: '工作历史 · 2', exact: true }).click() + await expect(page.locator('.right-panel [data-card-id="older"]').getByText('保留参数表', { exact: true })).toBeVisible() + await expect(page.locator('.right-panel [data-card-id="older"]').getByRole('textbox')).toHaveCount(0) + expect(state.errors).toEqual([]) +}) + test('keeps real summary and details accessible with right pane closed', async ({ page }) => { const state = await fixture(page) await expect(page.locator('.right-panel')).toBeHidden() diff --git a/src/api.ts b/src/api.ts index c681518..fd89c4c 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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)), diff --git a/src/companion-conversation.css b/src/companion-conversation.css index fcc3259..2a0f58e 100644 --- a/src/companion-conversation.css +++ b/src/companion-conversation.css @@ -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); diff --git a/src/components/Conversation.tsx b/src/components/Conversation.tsx index c032217..74d20fd 100644 --- a/src/components/Conversation.tsx +++ b/src/components/Conversation.tsx @@ -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 store.openInbox(ref)} docBridge={docBridge} /> } // 插件内嵌卡片部件(cardlink 互定位,D32):点击 ↗ 定位收件箱对应卡(从 data 解析 card:)。 @@ -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
助手 + {live && }
{(split.timeline.length > 0 || !!msg.runId) && ( @@ -462,18 +454,19 @@ function OutboxPanel() { if (outbox.length === 0) return null return ( 排队 {outbox.length}} + trigger={待发送 {outbox.length}} triggerClassName="outbox-trigger">
    {outbox.map(item => (
  • + {item.deliveryError && {item.deliveryError}} {editing === item.id ? ( setDraft(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { store.updateOutbox(item.id, draft); setEditing(null) } }} /> ) : ( - {item.text.trim() || '(仅附件)'}{item.attachmentNames.length > 0 ? ` · ${item.attachmentNames.join('、')}` : ''} + {item.text.trim() || '(仅附件)'}{item.attachmentNames.length > 0 ? ` · ${item.attachmentNames.join('、')}` : ''}{item.taskRef ? ` · 基于「${item.taskRef.title}」` : ''} )} {editing === item.id ? ( @@ -481,6 +474,7 @@ function OutboxPanel() { )} + {item.taskRef && }
  • ))} @@ -495,7 +489,7 @@ export function NoticeMessage({ msg }: { msg: Message }) { return (
    通知 · {preview}}> + trigger={<>通知 · {preview}}>
    {text}
    @@ -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([]) @@ -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() {
    你 +
    {m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}
    {currentConvId && } @@ -716,16 +712,23 @@ function ConversationSession() {
    + {unreadResults.length > 0 && } + {workReference &&
    + 基于「{workReference.title}」的第 {workReference.resultVersion} 版结果 + +
    } {!connected &&
    正在重新连接,最新进展待确认
    } {/* 轻量状态提示(方案:快慢通道连续体验):忙碌时可继续输入排队; - 后台任务在回合结束后仍在执行,完成后回到对话汇报。 */} + 后台工作完成后更新原卡,用户按需打开。 */} {(busy || bgRunning > 0) && (
    {busy ? 助手正在处理{bgRunning > 0 ? ` · ${bgRunning} 项后台工作` : ''} : {bgRunning} 项后台工作} - {busy ? '新消息会排队,当前回合结束后发送' : '你可以继续对话,完成后会在这里汇报'} + {busy ? '新消息会排队,当前回合结束后发送' : '你可以继续对话,结果会更新到工作卡'}
    )} {attachmentNames.length > 0 && ( diff --git a/src/components/ConversationWork.tsx b/src/components/ConversationWork.tsx index 6768ba9..41ce28f 100644 --- a/src/components/ConversationWork.tsx +++ b/src/components/ConversationWork.tsx @@ -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
    + 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) }, diff --git a/src/components/MessageTimestamp.tsx b/src/components/MessageTimestamp.tsx new file mode 100644 index 0000000..3d3d539 --- /dev/null +++ b/src/components/MessageTimestamp.tsx @@ -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 +} diff --git a/src/components/WorkExecutionRecord.tsx b/src/components/WorkExecutionRecord.tsx new file mode 100644 index 0000000..170a12e --- /dev/null +++ b/src/components/WorkExecutionRecord.tsx @@ -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
    +
    {selected.title} · 执行记录 +
    + + {failed ?

    完整记录暂不可用,请稍后重新打开

    + : !result ?

    正在读取已保存的记录…

    + : parts.length === 0 ?

    这项工作没有已保存的过程输出

    + : } />} +
    +} diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx index f05b372..966667b 100644 --- a/src/components/Workbench.tsx +++ b/src/components/Workbench.tsx @@ -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() { <>
    + title="待处理事项、后台任务与执行记录">工作{pending > 0 ? ` ${pending}` : ''}{unread > 0 ? ` · ${unread} 项新结果` : ''} @@ -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 ( <> -
    +
    + {record ? : <> {showCurrentTask ? : null}
    @@ -126,6 +132,7 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: { ? : } + }
    ) diff --git a/src/contracts.ts b/src/contracts.ts index 70348ef..f25c8ae 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -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 } diff --git a/src/live.ts b/src/live.ts index d6fca42..5c08ef9 100644 --- a/src/live.ts +++ b/src/live.ts @@ -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。 diff --git a/src/outbox.ts b/src/outbox.ts index 7599ccd..9fa48cc 100644 --- a/src/outbox.ts +++ b/src/outbox.ts @@ -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 } : {}), } } diff --git a/src/runEvents.ts b/src/runEvents.ts new file mode 100644 index 0000000..3620fa7 --- /dev/null +++ b/src/runEvents.ts @@ -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 { + 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 + } +} diff --git a/src/scrollsync.ts b/src/scrollsync.ts index b3f50c2..edfa050 100644 --- a/src/scrollsync.ts +++ b/src/scrollsync.ts @@ -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, 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) { diff --git a/src/store.ts b/src/store.ts index 7a8763e..2162ef5 100644 --- a/src/store.ts +++ b/src/store.ts @@ -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 + 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> = {} let projectCardPatches: Record> = {} const actionInflight = new Set() const actionFeedbackOwners: Record = {} +const workActionExpectations = new Map() function observeDetail(observations: Record, 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('.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 diff --git a/test/conversation-work.test.ts b/test/conversation-work.test.ts index ef70cdc..1bb4c68 100644 --- a/test/conversation-work.test.ts +++ b/test/conversation-work.test.ts @@ -69,6 +69,29 @@ describe('conversation work reading', () => { expect(html).not.toContain('data-testid="input-status"') }) + it('renders stable semantic timestamps on user and assistant messages', () => { + snapshot.state!.messages = [{ ...reply([{ type: 'text', text: '请整理材料' }]), id: 'user', role: 'user' }, reply([{ type: 'text', text: '材料已收到' }])] + const html = render() + expect(html.match(/