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