Compare commits

...
Author SHA1 Message Date
weichen c2ff4fcc8e Stabilize the nonblocking choice browser baseline
Why: Wait for the unrelated foreground reply before measuring duplicate replies.

Evidence: All 24 scenarios passed against deployed 8788; independent replay passed the targeted scenario.

Impact: Test synchronization only; the duplicate-reply assertion is unchanged.

Refs: Agent-System-Frontend #89; Agent-System-Dev #565
2026-10-15 08:37:23 +00:00
weichen 03db4b81be Merge pull request 'Keep chat ordered while background results update independently' (#89) from feature/workbench-flow-completion into main 2026-10-15 08:18:46 +00:00
weichen 3555f815af Keep late background progress out of a newer conversation turn
Why: Completion or progress from an earlier task could appear in the process of an unrelated newer answer.

Evidence: The new ownership regression fails before the guard and passes afterward; the frontend suite, TypeScript and unchanged export gate pass.

Impact: Project child progress only into its originating live turn while still reconciling background results.

Refs: feature/workbench-flow-completion chronological display requirement
2026-10-15 07:51:41 +00:00
weichen 11f66aee9d Verify complete saved records beyond one event page
Why: Full execution history must retain outputs after the 500-event API page boundary.

Evidence: Ordered 502-event replay and nonadvancing-cursor rejection tests pass.

Impact: Add regression coverage only; production source is unchanged.

Refs: feature/workbench-flow-completion review testing gap
2026-10-15 07:45:31 +00:00
weichen 83c22681f9 Preserve rejected message drafts and keep later chat unblocked
Why: A rejected result-reference request could discard the user text or uploads after the composer cleared.

Evidence: 286 frontend tests, TypeScript and the unchanged unused-export gate pass; mixed-input browser regression preserves the draft.

Impact: Validate incompatible inputs before clearing, retain failed pending input with its request identity, and require explicit retry while other messages can continue.

Refs: feature/workbench-flow-completion review frontend-races finding
2026-10-15 07:24:13 +00:00
weichen 0cd1f9954a Keep the replay progress helper private
Why: The frontend unused-export gate rejected a helper used only within its module.

Evidence: The unchanged knip baseline and TypeScript checks pass.

Impact: Remove an unnecessary export without changing replay behavior.

Refs: feature/workbench-flow-completion
2026-10-15 07:15:05 +00:00
weichen 92c390a08a Synchronize guarded work dispatch state from Core
Why: Keep persisted task decisions aligned across Go and TypeScript.

Evidence: make contracts generated both consumers from Core 1a5ef6b.

Impact: Include the reserved run identity and typed dispatching state.

Refs: feature/workbench-flow-completion
2026-10-15 07:06:10 +00:00
weichen 06fa1a40a6 Keep conversations ordered while background work updates independently
Why: Repeated progress and coupled scrolling obscured chat, and task choices lacked a reliable independent interaction path.

Evidence: 285 frontend unit tests and 23 browser scenarios pass locally before the shared event-loader extraction; final combination checks follow in Dev.

Impact: Coalesce cumulative progress, retain execution records, show timestamps and quiet notifications, support versioned work actions and explicit result references without automatic chat backfill.

Refs: feature/workbench-flow-completion
2026-10-15 07:05:57 +00:00
weichen 9242cee89d Synchronize the work card contract
Why: Consume the Core work result and optional decision snapshot.

Evidence: Generated with make contracts from Core 00f1826.

Impact: Add optional types without changing existing cards.

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