Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e10e447dab | ||
|
|
b175506ebd | ||
|
|
7afae7f20b | ||
|
|
099a92022d | ||
|
|
5313cfad4e |
@@ -1,5 +1,5 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import type { ActivityCard, Artifact, Conversation, StreamEvent } from '../src/contracts'
|
||||
import type { ActivityCard, Artifact, Conversation, Part, StreamEvent } from '../src/contracts'
|
||||
|
||||
test.skip(!process.env.WORK_COMPANION_BROWSER, 'Run explicitly against the task-owned UI preview')
|
||||
|
||||
@@ -21,7 +21,8 @@ const confirmation = work('confirm1', 'action_required', { kind: 'confirm', titl
|
||||
actions: [{ id: 'confirm', label: '确认生成', style: 'primary' }, { id: 'cancel', label: '暂不生成' }],
|
||||
})
|
||||
|
||||
async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fontSize = 14) {
|
||||
async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fontSize = 14,
|
||||
history?: { events: StreamEvent[]; cardRef?: string; savedParts?: Part[] }) {
|
||||
let cards = [...initialCards]
|
||||
let actionCount = 0
|
||||
let releaseAction: (() => void) | undefined
|
||||
@@ -64,8 +65,21 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo
|
||||
body = { conversation: conversations.find(c => c.id === id), messages: id === 'c1' ? [
|
||||
{ id: 'm1', conversationId: id, role: 'user', parts: [{ type: 'text', text: '请整理这三份材料,看看需要优先处理什么' }], createdAt: stamp },
|
||||
{ id: 'm2', conversationId: id, role: 'assistant', parts: [{ type: 'text', text: '先核对时间安排,再补齐验收条件。其他内容仍在整理。' }], createdAt: stamp },
|
||||
] : [], runs: [], activeHitl: [], lastSeq: 10 }
|
||||
...(history ? [{ id: 'm3', conversationId: id, role: 'user', runId: 'run-history', parts: [{ type: 'text', text: '继续核对' }], createdAt: stamp },
|
||||
{ id: 'm4', conversationId: id, role: 'assistant', runId: 'run-history', parts: history.savedParts ?? [
|
||||
{ type: 'thinking', process: true, text: '先核对日期' },
|
||||
{ type: 'text', process: true, text: '开始读取材料' },
|
||||
{ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' } },
|
||||
{ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok', summary: '读取成功' } },
|
||||
{ type: 'text', text: '最终回复已完成' },
|
||||
...(history.cardRef ? [{ type: 'card', card: { cardId: history.cardRef, kind: 'result', data: [] } }] : []),
|
||||
], createdAt: stamp }] : []),
|
||||
] : [], runs: history ? [{ id: 'run-history', conversationId: 'c1', projectId: 'p1', kind: 'conversation', status: 'completed', createdAt: stamp, endedAt: stamp }] : [], activeHitl: [], lastSeq: 10 }
|
||||
} else if (path === '/api/conversations/c1/cards') body = { cards }
|
||||
else if (path === '/api/conversations/c1/runs/run-history/events') {
|
||||
const after = Number(url.searchParams.get('after') ?? 0)
|
||||
body = { events: (history?.events ?? []).filter(event => event.seq > after).slice(0, 500) }
|
||||
}
|
||||
else if (path === '/api/conversations/c2/cards') body = { cards: [] }
|
||||
else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null
|
||||
else if (path === '/api/projects/p1/artifacts') body = [artifact]
|
||||
@@ -114,13 +128,15 @@ test('keeps real summary and details accessible with right pane closed', async (
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('keeps one expanded ordinary summary when the desktop work pane opens', async ({ page }) => {
|
||||
test('places the full work card only in the desktop work pane', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 960 })
|
||||
const state = await fixture(page)
|
||||
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||
await expect(page.locator('.right-panel .acard')).toHaveCount(1)
|
||||
await expect(page.locator('[data-conversation-work] .acard')).toHaveCount(0)
|
||||
await expect(page.locator('[data-conversation-work]').getByRole('button', { name: /整理项目材料 · 查看摘要/ })).toBeVisible()
|
||||
await expect(page.locator('[data-conversation-work]')).toHaveCount(0)
|
||||
await expect(page.locator('.companion-topbar').getByRole('button', { name: '打开工作' })).toHaveCount(0)
|
||||
await expect(page.locator('.right-panel').getByRole('button', { name: /工作/ }).first()).toBeVisible()
|
||||
if (process.env.WORK_COMPANION_SCREENSHOT) await page.screenshot({ path: process.env.WORK_COMPANION_SCREENSHOT, fullPage: true })
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
@@ -160,19 +176,117 @@ test('preserves the selected material, physical scroll and focus when background
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('shares submitting feedback between conversation and work confirmations', async ({ page }) => {
|
||||
test('keeps one confirmation action location when the work pane is visible', async ({ page }) => {
|
||||
const state = await fixture(page, [confirmation])
|
||||
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||
state.hold()
|
||||
await page.locator('[data-conversation-work]').getByRole('button', { name: '确认生成', exact: true }).click()
|
||||
await expect(page.locator('[data-conversation-work]')).toHaveCount(0)
|
||||
await page.locator('.right-panel').getByRole('button', { name: '确认生成', exact: true }).click()
|
||||
await expect(page.locator('.right-panel').getByRole('button', { name: '确认生成', exact: true })).toBeDisabled()
|
||||
await expect(page.locator('[data-conversation-work]').getByRole('button', { name: '暂不生成', exact: true })).toBeDisabled()
|
||||
await expect(page.locator('.chat-body').getByRole('button', { name: '确认生成', exact: true })).toHaveCount(0)
|
||||
expect(state.actionCount).toBe(1)
|
||||
state.release()
|
||||
await expect(page.getByRole('button', { name: '确认生成', exact: true })).toHaveCount(0)
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('keeps each run process folded and reloadable after the final reply', async ({ page }) => {
|
||||
const event = (seq: number, type: StreamEvent['type'], payload: unknown): StreamEvent =>
|
||||
({ seq, type, payload, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 })
|
||||
const events = [
|
||||
event(1, 'thinking.delta', { index: 0, text: '先核对日期' }),
|
||||
event(2, 'message.delta', { index: 0, text: '开始读取材料' }),
|
||||
event(3, 'tool.call', { callId: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' }),
|
||||
event(4, 'tool.output', { callId: 'call-1', text: '完整工具输出' }),
|
||||
event(5, 'tool.result', { callId: 'call-1', status: 'ok', summary: '读取成功' }),
|
||||
event(6, 'run.phase', { phase: 'finalizing' }),
|
||||
event(7, 'message.delta', { index: 1, text: '最终回复已完成' }),
|
||||
]
|
||||
const state = await fixture(page, [], 14, { events })
|
||||
const message = page.locator('[data-msg-id="m4"]')
|
||||
await expect(message.getByText('最终回复已完成')).toBeVisible()
|
||||
await expect(message.getByText('开始读取材料')).toHaveCount(0)
|
||||
await message.getByRole('button', { name: /执行记录/ }).click()
|
||||
await expect(message.getByText('先核对日期')).toBeVisible()
|
||||
await expect(message.getByText('开始读取材料')).toBeVisible()
|
||||
await message.locator('.tool-block-head').click()
|
||||
await expect(message.getByText('完整工具输出')).toBeVisible()
|
||||
await page.reload()
|
||||
const restored = page.locator('[data-msg-id="m4"]')
|
||||
await expect(restored.getByText('最终回复已完成')).toBeVisible()
|
||||
await restored.getByRole('button', { name: /执行记录/ }).click()
|
||||
await expect(restored.getByText('先核对日期')).toBeVisible()
|
||||
await restored.locator('.tool-block-head').click()
|
||||
await expect(restored.getByText('完整工具输出')).toBeVisible()
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('replay enriches saved notes and keeps collaboration progress after completion', async ({ page }) => {
|
||||
const event = (seq: number, type: StreamEvent['type'], payload: unknown, runId = 'run-history'): StreamEvent =>
|
||||
({ seq, type, payload, runId, conversationId: 'c1', ts: stamp, v: 1 })
|
||||
const events = [
|
||||
event(1, 'message.delta', { index: 0, text: '<think>已解析的思考</think>' }),
|
||||
event(2, 'message.delta', { index: 1, text: '开始读取' }),
|
||||
event(3, 'tool.call', { callId: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' }),
|
||||
event(4, 'tool.output', { callId: 'call-1', text: '完整输出留存' }),
|
||||
event(5, 'tool.result', { callId: 'call-1', status: 'ok', summary: '读取成功' }),
|
||||
event(6, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }),
|
||||
event(7, 'run.phase', { phase: 'finalizing' }),
|
||||
event(8, 'message.delta', { index: 1, text: '最终回复已完成' }),
|
||||
event(9, 'agent.progress', { agentRun: 'child', note: '正在核对附录' }, 'child'),
|
||||
event(10, 'agent.result', { agentRun: 'child', status: 'completed', summary: '附录已核对' }, 'child'),
|
||||
]
|
||||
const savedParts: Part[] = [
|
||||
{ type: 'text', process: true, text: '模型在输出前失败,正在重试' },
|
||||
{ type: 'thinking', process: true, text: '已解析的思考' },
|
||||
{ type: 'text', process: true, text: '开始读取材料' },
|
||||
{ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'run_shell' } },
|
||||
{ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok' } },
|
||||
{ type: 'text', text: '最终回复已完成' },
|
||||
]
|
||||
await fixture(page, [], 14, { events, savedParts })
|
||||
const message = page.locator('[data-msg-id="m4"]')
|
||||
await message.getByRole('button', { name: /执行记录/ }).click()
|
||||
await expect(message.getByText('模型在输出前失败,正在重试')).toBeVisible()
|
||||
await expect(message.getByText('已解析的思考')).toBeVisible()
|
||||
await expect(message.getByText('开始读取材料')).toBeVisible()
|
||||
await expect(message.getByText('开始读取', { exact: true })).toHaveCount(0)
|
||||
await expect(message.getByText('协作进展:正在核对附录')).toBeVisible()
|
||||
await expect(message.getByText('协作结束:附录已核对')).toBeVisible()
|
||||
await expect(message.getByText('<think>已解析的思考</think>')).toHaveCount(0)
|
||||
await message.locator('.tool-block-head').click()
|
||||
await expect(message.getByText('完整输出留存')).toBeVisible()
|
||||
})
|
||||
|
||||
test('keeps a child completion note when the final reply uses the same status words', async ({ page }) => {
|
||||
const event = (seq: number, type: StreamEvent['type'], payload: unknown, runId = 'run-history'): StreamEvent =>
|
||||
({ seq, type, payload, runId, conversationId: 'c1', ts: stamp, v: 1 })
|
||||
await fixture(page, [], 14, { events: [
|
||||
event(1, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }),
|
||||
event(2, 'run.phase', { phase: 'finalizing' }),
|
||||
event(3, 'message.delta', { index: 0, text: '已完成' }),
|
||||
event(4, 'agent.result', { agentRun: 'child', status: 'completed', summary: '已完成' }, 'child'),
|
||||
], savedParts: [{ type: 'text', process: true, text: '记录已完成' }, { type: 'text', text: '已完成' }] })
|
||||
const message = page.locator('[data-msg-id="m4"]')
|
||||
await message.getByRole('button', { name: /执行记录/ }).click()
|
||||
await expect(message.getByText('协作结束:已完成')).toBeVisible()
|
||||
})
|
||||
|
||||
test('locates a completed card even when work is filtered to project pending items', async ({ page }) => {
|
||||
const result = work('result1', 'done', { kind: 'result', title: '材料核对结果',
|
||||
body: [{ type: 'markdown', markdown: '完整结论在这里' }] })
|
||||
await fixture(page, [result], 14, { events: [], cardRef: 'result1' })
|
||||
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||
const workPane = page.locator('.right-panel')
|
||||
await workPane.locator('.inbox-scope').getByRole('button', { name: '项目' }).click()
|
||||
await workPane.locator('.inbox-scope').getByRole('button', { name: '待处理' }).click()
|
||||
await expect(workPane.getByText('完整结论在这里')).toHaveCount(0)
|
||||
await page.locator('[data-msg-id="m4"] .card-anchor-locate').click()
|
||||
await expect(workPane.getByText('完整结论在这里')).toBeVisible()
|
||||
await expect(workPane.locator('.inbox-scope').getByRole('button', { name: '本会话' })).toHaveClass(/active/)
|
||||
await expect(workPane.locator('.inbox-scope').getByRole('button', { name: '全部' })).toHaveClass(/active/)
|
||||
})
|
||||
|
||||
test('opens the one file preview and preserves reading during background updates', async ({ page }) => {
|
||||
const result = work('result1', 'done', { kind: 'result', title: '材料摘要已准备好', body: [{ type: 'doc-ref', docId: 'doc1' }] })
|
||||
const state = await fixture(page, [result])
|
||||
|
||||
@@ -86,6 +86,10 @@ export const api = {
|
||||
conversationDetail: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`).then(r =>
|
||||
json<{ conversation: Conversation; messages: Message[]; runs: Run[]; activeHitl: HITLCard[]; lastSeq: number; resumeSeq?: number }>(r)),
|
||||
|
||||
runEventsPage: (convId: string, runId: string, after: number, signal?: AbortSignal) =>
|
||||
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 }) })
|
||||
.then(async r => {
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
.work-companion-chat .chat-title { font-size: var(--fs-ui-xl); font-weight: 650; letter-spacing: -0.02em; }
|
||||
.work-companion-chat .chat-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.work-companion-chat .chat-header > .mk-col-root { position: relative; }
|
||||
.work-companion-chat .chat-header > .mk-col-root > .mk-col {
|
||||
@@ -64,10 +65,13 @@
|
||||
.companion-example:hover { color: var(--text); background: var(--bg-hover); }
|
||||
.conversation-work { margin: 24px 0 16px; min-width: 0; }
|
||||
.conversation-work-heading { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin-bottom: 12px; color: var(--text-dim); font-size: var(--fs-ui-caption); }
|
||||
.conversation-work-heading > span:first-child { color: var(--text); font-weight: 600; }
|
||||
.conversation-work-heading > span:first-child { color: var(--text); font-size: var(--fs-ui-lg); font-weight: 650; }
|
||||
.conversation-work-current, .conversation-work-history { display: grid; gap: 12px; min-width: 0; }
|
||||
.conversation-work-more { padding: 12px 0; color: var(--text-dim); font-size: var(--fs-ui-caption); }
|
||||
.conversation-work .acard { margin: 0; }
|
||||
.conversation-action-anchor { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; width: 100%; margin-top: 8px; padding: 8px 12px; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-panel); color: var(--text); text-align: left; font-size: var(--fs-ui-caption); cursor: pointer; }
|
||||
.conversation-action-anchor > span:last-child { color: var(--accent-text); }
|
||||
.conversation-action-anchor:hover { background: var(--bg-hover); }
|
||||
.work-companion-chat .live-bar { flex-wrap: wrap; }
|
||||
.work-companion-chat .part-error-detail, .work-companion-chat .hitl-detail { overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
@media (max-width: 540px) {
|
||||
|
||||
@@ -18,7 +18,15 @@
|
||||
.shell-context-content { display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0; }
|
||||
.companion-app .companion-topbar { height: auto; min-height: 44px; gap: 8px; padding: 4px 12px; }
|
||||
.topbar-identity { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.companion-topbar .topbar-name { font-size: var(--fs-ui-caption); white-space: nowrap; }
|
||||
.companion-topbar .topbar-name { font-size: var(--fs-ui-base); font-weight: 650; white-space: nowrap; }
|
||||
.companion-app .right-panel .tabs { padding: 4px 12px 0; }
|
||||
.companion-app .right-panel .tab { font-size: var(--fs-ui-base); font-weight: 600; }
|
||||
.companion-app .right-panel .tab-body { padding: 16px; }
|
||||
.companion-app .right-panel .inbox-zone-label { color: var(--text); font-size: var(--fs-ui-caption); font-weight: 600; margin-top: 16px; }
|
||||
.companion-app .right-panel .acard.status-active { border-left: 3px solid var(--work-focus-border); background: var(--work-focus-surface); }
|
||||
.companion-app .right-panel .acard-title { color: var(--text); font-size: var(--fs-ui-lg); }
|
||||
.companion-app .right-panel .acard-summary-row p,
|
||||
.companion-app .right-panel .acard-findings li { color: var(--text); font-size: var(--fs-ui-base); }
|
||||
.companion-topbar .brand-mark {
|
||||
background: var(--primary-sidebar);
|
||||
color: var(--primary-sidebar-foreground);
|
||||
|
||||
@@ -6,12 +6,12 @@ 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, showProcessInline } from '../live'
|
||||
import { liveToMessage, livePhaseLabel, runEventsToTimeline, showProcessInline } from '../live'
|
||||
import { deriveShellPhase, isShellBusy, showLiveBubble, type ShellPhase } from '../runstate'
|
||||
import type { Message, Part, HITLCard, ActivityCard, Artifact } from '../contracts'
|
||||
import type { Message, Part, HITLCard, ActivityCard, Artifact, StreamEvent } from '../contracts'
|
||||
import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui'
|
||||
import { docBridge } from './Workbench'
|
||||
import { splitTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts'
|
||||
import { splitTimeline, mergeProcessTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts'
|
||||
import { ProcessTimeline } from './ProcessTimeline'
|
||||
import { ConversationWork } from './ConversationWork'
|
||||
import '../companion-conversation.css'
|
||||
@@ -25,6 +25,7 @@ function hasFiles(dt: DataTransfer | null): boolean {
|
||||
|
||||
// Part 渲染器注册表:按类型查表;未知类型静默忽略(前向兼容)。
|
||||
function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
||||
const workPaneOwnsCards = useStore(s => !s.ui.rightCollapsed && s.ui.rightTab === 'work')
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return <SafeMarkdown className={live ? 'part-text cursor-blink' : 'part-text'} breaks
|
||||
@@ -46,12 +47,12 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
||||
case 'plan':
|
||||
return null // 计划在右栏 Plan 标签呈现
|
||||
case 'card': {
|
||||
// 卡流锚点(D39):cardId 非空 = submit_card 引用部件——中栏自足(可展开全文)+ 跳收件箱。
|
||||
// 右栏工作可见时保留定位入口;详细正文只在右栏。关闭右栏时中栏可展开全文。
|
||||
if (part.card?.cardId) {
|
||||
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={card}
|
||||
return <CardAnchor title={card?.title ?? part.card.kind} chars={bodyChars} card={workPaneOwnsCards ? undefined : card}
|
||||
onLocate={() => store.openInbox(ref)} docBridge={docBridge} />
|
||||
}
|
||||
// 插件内嵌卡片部件(cardlink 互定位,D32):点击 ↗ 定位收件箱对应卡(从 data 解析 card:<id>)。
|
||||
@@ -197,6 +198,8 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
|
||||
const autoCollapse = useStore(s => s.ui.autoCollapseProcess)
|
||||
const ctxObs = useStore(s => (msg.runId ? s.contextObs[msg.runId] : undefined))
|
||||
const run = useStore(s => (msg.runId ? s.runs.find(r => r.id === msg.runId) : undefined))
|
||||
const childStamp = useStore(s => msg.runId ? s.runs.filter(r => r.parentRunId === msg.runId)
|
||||
.map(r => `${r.id}:${r.status}:${r.endedAt ?? ''}`).join('|') : '')
|
||||
const parts = msg.parts ?? []
|
||||
useLiveClock(!!live)
|
||||
const finished = !live && !!run && run.status !== 'running' && run.status !== 'pending' && run.status !== 'waiting_hitl'
|
||||
@@ -211,6 +214,36 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
|
||||
useEffect(() => {
|
||||
if (!touchedProcess.has(memKey)) setOpen(showProcess)
|
||||
}, [memKey, showProcess])
|
||||
const [replayed, setReplayed] = useState<Part[] | null>(null)
|
||||
const [processError, setProcessError] = useState(false)
|
||||
useEffect(() => { setReplayed(null); setProcessError(false) }, [childStamp])
|
||||
useEffect(() => {
|
||||
if (!open) { setReplayed(null); setProcessError(false) }
|
||||
}, [open])
|
||||
useEffect(() => {
|
||||
if (!open || live || !msg.runId || !convId || replayed || processError) return
|
||||
const controller = new AbortController()
|
||||
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
|
||||
}
|
||||
if (active) setReplayed(runEventsToTimeline(events, msg.runId))
|
||||
} catch {
|
||||
if (active) setProcessError(true)
|
||||
}
|
||||
})()
|
||||
return () => { active = false; controller.abort() }
|
||||
}, [open, live, msg.runId, convId, replayed, processError])
|
||||
const extras = live ? [] : citedArtifactCards(split.final)
|
||||
const cards = live ? [] : dedupeDeliverables(split.final)
|
||||
const lastText = (() => {
|
||||
@@ -219,10 +252,11 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
|
||||
})()
|
||||
const streamingText = !!(live && live.phase === 'responding' && lastText >= 0
|
||||
&& !live.items.some(it => it.kind === 'tool' && it.state !== 'done'))
|
||||
const toolCount = split.timeline.filter(p => p.type === 'tool_call').length
|
||||
const processParts = replayed === null ? split.timeline : mergeProcessTimeline(split.timeline, replayed, split.final)
|
||||
const toolCount = processParts.filter(p => p.type === 'tool_call').length
|
||||
const headline = live ? '执行记录' : `执行记录 · ${processSummary(toolCount, cards.length, run?.createdAt, run?.endedAt)}`
|
||||
const timeline = (
|
||||
<ProcessTimeline parts={split.timeline} live={!!live} idKey={msg.id}
|
||||
<ProcessTimeline parts={processParts} live={!!live} idKey={msg.id}
|
||||
renderPart={(part, liveText) => <PartView part={part} live={liveText} />} />
|
||||
)
|
||||
const toggleProcess = (next: boolean) => {
|
||||
@@ -237,14 +271,18 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
|
||||
<span>助手</span>
|
||||
{live && <LiveBar live={live} />}
|
||||
</div>
|
||||
{split.timeline.some(part => part.type !== 'thinking') && (
|
||||
{(split.timeline.length > 0 || !!msg.runId) && (
|
||||
<div className="process-collapse">
|
||||
<Collapsible id={memKey} open={open} onToggle={toggleProcess} triggerClassName="process-collapse-summary" unmountDelay={300}
|
||||
trigger={<>
|
||||
<span className="process-collapse-chev">▸</span>
|
||||
<span className={live ? 'mk-sweep' : ''}>{headline}</span>
|
||||
</>}>
|
||||
<div className="body">{timeline}</div>
|
||||
<div className="body">
|
||||
{replayed === null && !live && !processError && <div className="process-load-note">正在读取完整记录…</div>}
|
||||
{processError && <div className="process-load-note">完整记录暂不可用,以下为已保存的摘要</div>}
|
||||
{processParts.length > 0 ? timeline : replayed && <div className="process-load-note">这轮没有可回看的过程记录</div>}
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)}
|
||||
@@ -486,6 +524,7 @@ function ConversationSession() {
|
||||
const cards = useStore(s => s.cards)
|
||||
const currentProjectId = useStore(s => s.currentProjectId)
|
||||
const cardActionStates = useStore(s => s.cardActionStates)
|
||||
const workPaneOwnsCards = useStore(s => !s.ui.rightCollapsed && s.ui.rightTab === 'work')
|
||||
const [text, setText] = useState('')
|
||||
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
|
||||
const [attachmentNames, setAttachmentNames] = useState<string[]>([])
|
||||
@@ -636,13 +675,16 @@ function ConversationSession() {
|
||||
</div>
|
||||
<div className="bubble">{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}</div>
|
||||
{currentConvId && <MessageTools msg={m} convId={currentConvId} />}
|
||||
{anchored.map(c => (
|
||||
<InlineConfirmCard key={c.id} card={c} callbacks={{
|
||||
onAction: (card, actionId) => store.cardAction(card, actionId),
|
||||
actionState: card => cardActionStates?.[card.id],
|
||||
onLocate: card => store.openInbox(card.id),
|
||||
}} />
|
||||
))}
|
||||
{anchored.map(c => workPaneOwnsCards
|
||||
? <button type="button" key={c.id} className="conversation-action-anchor" onClick={() => store.openInbox(c.id)}>
|
||||
<span>{c.title}</span><span>在工作栏处理 ↗</span>
|
||||
</button>
|
||||
: <InlineConfirmCard key={c.id} card={c} callbacks={{
|
||||
onAction: (card, actionId) => store.cardAction(card, actionId),
|
||||
actionState: card => cardActionStates?.[card.id],
|
||||
onLocate: card => store.openInbox(card.id),
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,8 +11,10 @@ export function ConversationWork() {
|
||||
const messages = useStore(s => s.messages)
|
||||
const actionStates = useStore(s => s.cardActionStates)
|
||||
const rightCollapsed = useStore(s => s.ui.rightCollapsed)
|
||||
const [summaryOpen, setSummaryOpen] = useState(rightCollapsed)
|
||||
useEffect(() => setSummaryOpen(rightCollapsed), [rightCollapsed])
|
||||
const rightTab = useStore(s => s.ui.rightTab)
|
||||
const detailHere = rightCollapsed || rightTab !== 'work'
|
||||
const [summaryOpen, setSummaryOpen] = useState(detailHere)
|
||||
useEffect(() => setSummaryOpen(detailHere), [detailHere])
|
||||
const { current, required, focused, history } = useMemo(() => {
|
||||
const current = cards.filter(card => card.status !== 'dismissed'
|
||||
&& card.conversationId === conversationId && card.projectId === projectId)
|
||||
@@ -33,7 +35,7 @@ export function ConversationWork() {
|
||||
?? remaining.find(card => card.status === 'done') ?? remaining[0]
|
||||
return { current, required, focused, history: remaining.filter(card => card !== focused) }
|
||||
}, [cards, messages, conversationId, projectId])
|
||||
if (current.length === 0) return null
|
||||
if (current.length === 0 || !detailHere) 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 执行记录按工作动作分组;展开状态由调用方控制。
|
||||
import type { ReactNode } from 'react'
|
||||
import { ToolBlock } from '@ui'
|
||||
import { Collapsible, ToolBlock } from '@ui'
|
||||
import type { Part } from '../contracts'
|
||||
import { formatDuration, humanSize } from '../messageparts'
|
||||
import { timelineEntries, toolGroupLabel, type ToolGroupItem } from '../toolkind'
|
||||
@@ -15,8 +15,12 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: {
|
||||
return (
|
||||
<div className="tl">
|
||||
{rows.map((row, i) => {
|
||||
// 模型内部思考不是用户工作内容,也不属于可展开的执行记录。
|
||||
if (row.kind === 'thinking') return null
|
||||
if (row.kind === 'thinking') {
|
||||
return row.text && <div className="tl-thinking" key={`${idKey}:th:${i}`}>
|
||||
<span className="tl-thinking-label">思考过程</span>
|
||||
<div className="tl-thinking-body">{row.text}</div>
|
||||
</div>
|
||||
}
|
||||
if (row.kind === 'text') {
|
||||
return <div className="part" key={`${idKey}:tx:${i}`}>{renderPart({ type: 'text', text: row.text }, false)}</div>
|
||||
}
|
||||
@@ -38,6 +42,11 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: {
|
||||
<span className="tl-tools-name">{it.headline}</span>
|
||||
{it.status === 'error' && <span className="status-error">失败</span>}
|
||||
{it.summary && <span className="tl-tools-sum">{it.summary}</span>}
|
||||
{(it.arguments || it.output) && <Collapsible id={`tool-record:${idKey}:${it.id}:${j}`}
|
||||
triggerClassName="tl-tool-detail" trigger={<span>查看记录 ▸</span>}>
|
||||
{it.arguments && <pre className="tool-preview">{it.arguments}</pre>}
|
||||
{it.output && <pre className="tool-preview">{it.output}</pre>}
|
||||
</Collapsible>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -36,7 +36,7 @@ export function TopBar({ compact = false, activePane = 'conversation', showRetur
|
||||
<Icon name="message" size={15} /><span>对话</span>
|
||||
</button>
|
||||
</>}
|
||||
<button className="shell-nav-button" aria-label="打开工作"
|
||||
{(compact || ui.rightCollapsed) && <><button className="shell-nav-button" aria-label="打开工作"
|
||||
aria-pressed={compact ? activePane === 'work' : !ui.rightCollapsed && ui.rightTab === 'work'}
|
||||
title={pending > 0 ? `${pending} 项待处理` : '工作'} onClick={onWork}>
|
||||
<Icon name="inbox" size={15} /><span>工作</span>
|
||||
@@ -45,7 +45,7 @@ export function TopBar({ compact = false, activePane = 'conversation', showRetur
|
||||
<button className="shell-nav-button" aria-label="打开文件"
|
||||
aria-pressed={compact ? activePane === 'files' : !ui.rightCollapsed && ui.rightTab === 'files'} onClick={onFiles}>
|
||||
<Icon name="folder" size={15} /><span>文件</span>
|
||||
</button>
|
||||
</button></>}
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
|
||||
+61
-4
@@ -3,8 +3,9 @@
|
||||
// 部件,事件到达序即落库序,故合成消息与落库部件同构,终态对账不重排。
|
||||
// 文本段是否为过程(process)由「本段之后、下一个思考/文本段之前是否出现工具」判定。
|
||||
// 参数增量(编写中)一到即算工具,解说不用等工具真正开始执行才离开回复区。
|
||||
import type { Message, Part } from './contracts'
|
||||
import type { Message, Part, StreamEvent } from './contracts'
|
||||
import type { LiveState } from './store'
|
||||
import { splitTimeline, type ReplayPart } from './messageparts'
|
||||
|
||||
export type ToolLiveState = 'composing' | 'running' | 'done'
|
||||
|
||||
@@ -12,6 +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: 'tool'
|
||||
callId?: string
|
||||
@@ -118,13 +120,13 @@ export function applyToolCall(items: LiveItem[], name: string, callId?: string,
|
||||
}
|
||||
|
||||
// applyToolOutput 把执行中的输出追加到对应工具,只保留尾部,避免长日志撑住内存。
|
||||
export function applyToolOutput(items: LiveItem[], callId: string | undefined, text: string): LiveItem[] {
|
||||
export function applyToolOutput(items: LiveItem[], callId: string | undefined, text: string, maxChars = OUTPUT_TAIL): LiveItem[] {
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
const it = items[i]
|
||||
if (!isTool(it) || it.state === 'done') continue
|
||||
if (callId && it.callId && it.callId !== callId) continue
|
||||
let output = it.output + text
|
||||
if (output.length > OUTPUT_TAIL) output = output.slice(output.length - OUTPUT_TAIL)
|
||||
if (output.length > maxChars) output = output.slice(output.length - maxChars)
|
||||
const out = items.slice()
|
||||
out[i] = { ...it, output }
|
||||
return out
|
||||
@@ -193,7 +195,9 @@ export function liveToMessage(live: LiveState): Message {
|
||||
parts.push({ type: 'check', process: true, check: { passed: it.passed, reason: it.reason || undefined } })
|
||||
continue
|
||||
}
|
||||
if (it.kind === 'thinking') {
|
||||
if (it.kind === 'agent') {
|
||||
parts.push({ type: 'text', text: it.text, process: true, replaySource: 'agent' } as ReplayPart)
|
||||
} else if (it.kind === 'thinking') {
|
||||
const durationMs = it.endedAt - it.startedAt
|
||||
if (it.text) parts.push({
|
||||
type: 'thinking', text: it.text, process: true,
|
||||
@@ -224,6 +228,59 @@ export function liveToMessage(live: LiveState): Message {
|
||||
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[] {
|
||||
let items: LiveItem[] = []
|
||||
let finalizing = false
|
||||
for (const event of [...events].sort((a, b) => a.seq - b.seq)) {
|
||||
const p = event.payload as Record<string, unknown> | undefined
|
||||
const now = Date.parse(event.ts) || 0
|
||||
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}」已开始` })
|
||||
break
|
||||
case 'agent.progress':
|
||||
if (typeof p?.note === 'string' && p.note.trim()) items.push({ kind: 'agent', text: `协作进展:${p.note}` })
|
||||
break
|
||||
case 'agent.result':
|
||||
items.push({ kind: 'agent', text: `协作结束:${typeof p?.summary === 'string' && p.summary.trim() ? p.summary : String(p?.status ?? '已结束')}` })
|
||||
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)
|
||||
break
|
||||
case 'tool.call.delta':
|
||||
if (typeof p?.delta === 'string') items = applyToolCallDelta(items, typeof p.index === 'number' ? p.index : 0,
|
||||
p.delta, typeof p.callId === 'string' ? p.callId : undefined, typeof p.name === 'string' ? p.name : undefined, now)
|
||||
break
|
||||
case 'tool.call':
|
||||
items = applyToolCall(items, typeof p?.name === 'string' ? p.name : '工具',
|
||||
typeof p?.callId === 'string' ? p.callId : undefined,
|
||||
typeof p?.arguments === 'string' ? p.arguments : undefined, now)
|
||||
break
|
||||
case 'tool.output':
|
||||
if (typeof p?.text === 'string') items = applyToolOutput(items,
|
||||
typeof p.callId === 'string' ? p.callId : undefined, p.text, Number.POSITIVE_INFINITY)
|
||||
break
|
||||
case 'tool.result':
|
||||
items = applyToolResult(items, typeof p?.callId === 'string' ? p.callId : undefined,
|
||||
typeof p?.status === 'string' ? p.status : 'ok', typeof p?.summary === 'string' ? p.summary : undefined, now)
|
||||
break
|
||||
case 'run.phase':
|
||||
if (p?.phase === 'finalizing') { finalizing = true; items = applyFinalizing(items) }
|
||||
break
|
||||
case 'run.check':
|
||||
items = applyCheck(items, !!p?.passed, typeof p?.reason === 'string' ? p.reason : '')
|
||||
break
|
||||
}
|
||||
}
|
||||
const live: LiveState = { runId: events[0]?.runId ?? '', phase: 'done', items, agentNotes: {}, startedAt: 0 }
|
||||
return splitTimeline(liveToMessage(live).parts).timeline
|
||||
}
|
||||
|
||||
// livePhaseLabel 用最后一个实时条目给出中文阶段,而不是后端的英文 phase。
|
||||
export function livePhaseLabel(live: LiveState, now = Date.now()): string {
|
||||
if (live.phase === 'finalizing') return '整理回复中'
|
||||
|
||||
+75
-10
@@ -1,5 +1,4 @@
|
||||
// 消息切分:技术记录留在过程时间线,正文、错误与文件入口始终直接可读。
|
||||
// 两区各自保留落库顺序,后续工具或终轮检查不再把解说藏入折叠区。
|
||||
// 消息切分:每轮过程按落库顺序留在可回看的时间线,终轮回复直接可读。
|
||||
import type { Part } from './contracts'
|
||||
|
||||
export interface TimelineSplit {
|
||||
@@ -7,20 +6,86 @@ export interface TimelineSplit {
|
||||
final: Part[]
|
||||
}
|
||||
|
||||
/** 最后一个 process 部件之前的技术记录留在时间线,其余为可见内容。
|
||||
* 保留旧 promoteLastText 参数以兼容调用方;解说现在始终可见,不再依赖终态提升。 */
|
||||
export function splitTimeline(parts: Part[], _promoteLastText = false): TimelineSplit {
|
||||
/** Client-only provenance. It is never written to the Core message contract. */
|
||||
export interface ReplayPart extends Part { replaySource?: 'agent' }
|
||||
|
||||
/** 最后一个 process 部件之前的记录属于该轮过程;错误与文件入口始终可见。
|
||||
* 终态没有回复时,只把最后一段过程解说提升为可见回复。 */
|
||||
export function splitTimeline(parts: Part[], promoteLastText = false): TimelineSplit {
|
||||
let last = -1
|
||||
for (let i = 0; i < parts.length; i++) if (parts[i].process) last = i
|
||||
if (last < 0) return { timeline: [], final: parts }
|
||||
// text 是公开解说;thinking、工具参数与结果、终轮检查不提升为正文。
|
||||
const visible = (part: Part) => part.type === 'text' || part.type === 'error' || part.type === 'artifact_ref'
|
||||
const before = parts.slice(0, last + 1)
|
||||
const timeline = before.filter(part => !visible(part))
|
||||
const final = [...before.filter(visible), ...parts.slice(last + 1)]
|
||||
const timeline: Part[] = []
|
||||
const final: Part[] = []
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i]
|
||||
if (i > last || part.type === 'error' || part.type === 'artifact_ref') final.push(part)
|
||||
else timeline.push(part)
|
||||
}
|
||||
if (promoteLastText && !final.some(part => part.type === 'text' || part.type === 'error')) {
|
||||
let idx = -1
|
||||
for (let i = timeline.length - 1; i >= 0; i--) {
|
||||
if (timeline[i].type === 'text') { idx = i; break }
|
||||
}
|
||||
if (idx >= 0) final.unshift(...timeline.splice(idx, 1))
|
||||
}
|
||||
return { timeline, final }
|
||||
}
|
||||
|
||||
const normalized = (text?: string) => (text ?? '').replace(/\s+/g, ' ').trim()
|
||||
|
||||
function sameProcessPart(saved: Part, replayed: Part): boolean {
|
||||
if (saved.type !== replayed.type) return false
|
||||
switch (saved.type) {
|
||||
case 'tool_call': return !!saved.toolCall?.id && saved.toolCall.id === replayed.toolCall?.id
|
||||
case 'tool_result': return !!saved.toolResult?.callId && saved.toolResult.callId === replayed.toolResult?.callId
|
||||
case 'check': return saved.check?.passed === replayed.check?.passed && saved.check?.reason === replayed.check?.reason
|
||||
case 'thinking':
|
||||
case 'text': return normalized(saved.text) === normalized(replayed.text)
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep the settled message as the content authority; add only event-only records
|
||||
* and full tool output. Event deltas can be missing, while terminal parts can
|
||||
* contain retry notes or parsed inline thinking that the event stream cannot reconstruct. */
|
||||
export function mergeProcessTimeline(saved: Part[], replayed: ReplayPart[], final: Part[]): Part[] {
|
||||
if (replayed.length === 0) return saved
|
||||
const finalTexts = final.filter(part => part.type === 'text').map(part => normalized(part.text)).filter(Boolean)
|
||||
const merged: Part[] = []
|
||||
let cursor = 0
|
||||
let pending: Part[] = []
|
||||
for (const part of replayed) {
|
||||
const text = normalized(part.text)
|
||||
const agentNote = part.replaySource === 'agent'
|
||||
if (part.type === 'text' && !agentNote && ((text && finalTexts.some(value => value.includes(text) || text.includes(value)))
|
||||
|| /<\/?think\b/i.test(part.text ?? ''))) continue
|
||||
let match = -1
|
||||
for (let i = cursor; i < saved.length; i++) {
|
||||
if (sameProcessPart(saved[i], part)) { match = i; break }
|
||||
}
|
||||
if (match < 0) {
|
||||
// A missing delta batch can leave only a prefix in events. The complete
|
||||
// terminal part is authoritative; never render that prefix beside it.
|
||||
if (!agentNote && (part.type === 'text' || part.type === 'thinking') && text && saved.some(item => {
|
||||
if (item.type !== part.type) return false
|
||||
const value = normalized(item.text)
|
||||
return value && (value.includes(text) || text.includes(value))
|
||||
})) continue
|
||||
pending.push(part)
|
||||
continue
|
||||
}
|
||||
merged.push(...saved.slice(cursor, match), ...pending)
|
||||
pending = []
|
||||
const output = (part as Part & { toolOutput?: string }).toolOutput
|
||||
merged.push(output && saved[match].type === 'tool_call'
|
||||
? { ...saved[match], toolOutput: output } as Part : saved[match])
|
||||
cursor = match + 1
|
||||
}
|
||||
merged.push(...saved.slice(cursor), ...pending)
|
||||
return merged
|
||||
}
|
||||
|
||||
/** 同一轮里同名工件只留最新一张,updates 是被折叠掉的次数。 */
|
||||
export function dedupeDeliverables(parts: Part[]): { artifact: NonNullable<Part['artifact']>; updates: number }[] {
|
||||
const order: string[] = []
|
||||
|
||||
+6
-1
@@ -1327,7 +1327,12 @@ export const store = {
|
||||
setUI({ rightCollapsed: false, rightTab: 'work', inboxFilter: 'todo' })
|
||||
},
|
||||
openInbox(cardId?: string) {
|
||||
setUI({ rightCollapsed: false, rightTab: 'work' })
|
||||
if (cardId) {
|
||||
const scope = state.cards.some(card => card.id === cardId) ? 'conv'
|
||||
: state.projectCards.some(card => card.id === cardId) ? 'project' : 'conv'
|
||||
set({ inboxScope: scope })
|
||||
}
|
||||
setUI({ rightCollapsed: false, rightTab: 'work', ...(cardId ? { inboxFilter: 'all' as const } : {}) })
|
||||
if (cardId) setTimeout(() => flashLocate('.right-panel', 'data-card-id', cardId), 60)
|
||||
},
|
||||
/** 左栏/收起轨的「文件」入口:打开第三栏文件入口(列表或既有预览)。 */
|
||||
|
||||
@@ -194,6 +194,10 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.tl-tools-sum { margin-left: 8px; color: var(--text-dim); }
|
||||
.tl-check { color: var(--text-dim); font-size: var(--fs-ui-sm); margin: 4px 0; }
|
||||
.tl-tools .status-error { margin-left: 8px; color: var(--err-text); }
|
||||
.tl-tool-detail { margin-top: 4px; color: var(--text-dim); font-size: var(--fs-ui-sm); }
|
||||
.tl-thinking { margin: 8px 0; padding: 8px 12px; border-left: 2px solid var(--border-strong); color: var(--text-dim); }
|
||||
.tl-thinking-label { display: block; margin-bottom: 4px; font-size: var(--fs-ui-sm); font-weight: 600; }
|
||||
.tl-thinking-body { white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--fs-ui-sm); line-height: 1.6; }
|
||||
|
||||
/* 第三栏「文件」入口内的多标签预览(外壳样式见 .right-panel .preview-pane) */
|
||||
.preview-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); overflow: hidden; }
|
||||
@@ -226,6 +230,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.process-collapse .process-collapse-meta { color: var(--text-dim); }
|
||||
.process-collapse .process-collapse-excerpt { color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 40%; }
|
||||
.process-collapse .body { padding: 4px 12px 10px; }
|
||||
.process-load-note { padding: 8px 0; color: var(--text-dim); font-size: var(--fs-ui-sm); }
|
||||
|
||||
/* 流式状态行(呼吸/扫光走 mk-* 类;已工作计时弱色等宽) */
|
||||
.live-bar { display: flex; align-items: center; gap: 8px; color: var(--text-faint); font-size: var(--fs-ui-sm); margin-bottom: 6px; }
|
||||
|
||||
@@ -114,25 +114,30 @@ describe('conversation work reading', () => {
|
||||
if (pinned) expect(html).not.toContain('data-card-id="work_active"')
|
||||
})
|
||||
|
||||
it('keeps the newest completed result as a compact anchor with the auxiliary pane open', () => {
|
||||
it('leaves completed results to the visible right work pane', () => {
|
||||
snapshot.state!.ui.rightCollapsed = false
|
||||
snapshot.state!.cards = completedResults()
|
||||
const html = renderWork()
|
||||
expect(html).toContain('最新整理成果 · 查看摘要')
|
||||
expect(html).not.toContain('最新成果的主要结论')
|
||||
expect(html).not.toContain('先前整理成果')
|
||||
expect(html).toBe('')
|
||||
})
|
||||
|
||||
it('keeps a compact work anchor when the same summary is visible in the right pane', () => {
|
||||
it('keeps the work card in one detail location when the right work pane is visible', () => {
|
||||
snapshot.state!.ui.rightCollapsed = false
|
||||
snapshot.state!.cards = [card()]
|
||||
const html = render()
|
||||
expect(html).toContain('整理项目材料')
|
||||
expect(html).not.toContain('data-conversation-work')
|
||||
expect(html).not.toContain('核对会议结论')
|
||||
snapshot.state!.ui.rightCollapsed = true
|
||||
expect(render()).toContain('核对会议结论')
|
||||
})
|
||||
|
||||
it('keeps the conversation detail when the right pane is reading files', () => {
|
||||
snapshot.state!.ui.rightCollapsed = false
|
||||
snapshot.state!.ui.rightTab = 'files'
|
||||
snapshot.state!.cards = [card()]
|
||||
expect(render()).toContain('核对会议结论')
|
||||
})
|
||||
|
||||
it('keeps secondary message operations available through progressive disclosure', () => {
|
||||
snapshot.state!.messages = [reply([{ type: 'text', text: '已经整理好要点' }])]
|
||||
const html = render()
|
||||
@@ -190,6 +195,29 @@ describe('conversation work reading', () => {
|
||||
expect(html).toContain('确认新增')
|
||||
})
|
||||
|
||||
it('shows only a location link for anchored confirmation when the work pane owns its actions', () => {
|
||||
snapshot.state!.ui.rightCollapsed = false
|
||||
snapshot.state!.messages = [{ ...reply([{ type: 'text', text: '请整理资料' }]), role: 'user' }]
|
||||
snapshot.state!.cards = [card({ kind: 'confirm', status: 'action_required', anchorMessageId: 'msg_1',
|
||||
title: '新增一份项目草稿', actions: [{ id: 'confirm', label: '确认新增', style: 'primary' }] })]
|
||||
const html = render()
|
||||
expect(html).toContain('新增一份项目草稿')
|
||||
expect(html).toContain('在工作栏处理')
|
||||
expect(html).not.toContain('确认新增')
|
||||
})
|
||||
|
||||
it('keeps a submitted card as a location link when its full body is in the work pane', () => {
|
||||
snapshot.state!.ui.rightCollapsed = false
|
||||
snapshot.state!.cards = [card({ id: 'result_1', kind: 'result', title: '材料核对结果', status: 'done',
|
||||
body: [{ type: 'markdown', markdown: '材料核对的完整正文' }] })]
|
||||
snapshot.state!.messages = [reply([{ type: 'card', card: { cardId: 'result_1', kind: 'result', data: [] } }])]
|
||||
const html = render()
|
||||
expect(html).toContain('材料核对结果')
|
||||
expect(html).not.toContain('展开全文')
|
||||
snapshot.state!.ui.rightCollapsed = true
|
||||
expect(render()).toContain('展开全文')
|
||||
})
|
||||
|
||||
it('keeps a required confirmation accessible when its anchor is an assistant message', () => {
|
||||
snapshot.state!.messages = [reply([{ type: 'text', text: '准备新增项目草稿' }])]
|
||||
snapshot.state!.cards = [card({ kind: 'confirm', status: 'action_required', anchorMessageId: 'msg_1',
|
||||
@@ -197,12 +225,26 @@ describe('conversation work reading', () => {
|
||||
expect(render()).toContain('确认新增')
|
||||
})
|
||||
|
||||
it('does not reveal internal thinking when records are deliberately opened', () => {
|
||||
it('keeps a thinking-only record available for deliberate review', () => {
|
||||
const html = renderToStaticMarkup(createElement(ProcessTimeline, {
|
||||
parts: [{ type: 'thinking', process: true, text: 'private-internal-deliberation' }],
|
||||
live: true, idKey: 'thinking-test', renderPart: () => null,
|
||||
}))
|
||||
expect(html).not.toContain('private-internal-deliberation')
|
||||
expect(html).toContain('private-internal-deliberation')
|
||||
snapshot.state!.messages = [reply([{ type: 'thinking', process: true, text: 'private-internal-deliberation' },
|
||||
{ type: 'text', text: '已完成核对' }])]
|
||||
expect(render()).toContain('执行记录')
|
||||
})
|
||||
|
||||
it('lets readers reopen the full output of a grouped read action', () => {
|
||||
setPersistedOpen('tool-record:read-history:call-read:0', true)
|
||||
const html = renderToStaticMarkup(createElement(ProcessTimeline, {
|
||||
parts: [{ type: 'tool_call', process: true,
|
||||
toolCall: { id: 'call-read', name: 'fs_read_file', arguments: '{"path":"材料.txt"}' },
|
||||
toolOutput: 'full-read-output' } as Message['parts'][number]],
|
||||
live: false, idKey: 'read-history', renderPart: () => null,
|
||||
}))
|
||||
expect(html).toContain('full-read-output')
|
||||
})
|
||||
|
||||
it('blocks repeated HITL choices when the shared action result is unknown', () => {
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated,
|
||||
applyCheck, applyFinalizing,
|
||||
hasSettledReply, liveToMessage, livePhaseLabel, showProcessInline, type LiveItem,
|
||||
runEventsToTimeline,
|
||||
} from '../src/live'
|
||||
import type { StreamEvent } from '../src/contracts'
|
||||
import type { LiveState } from '../src/store'
|
||||
|
||||
function live(over: Partial<LiveState> = {}): LiveState {
|
||||
@@ -159,6 +161,40 @@ describe('liveToMessage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reopens the ordered complete process from persisted run events', () => {
|
||||
const output = 'x'.repeat(5000)
|
||||
const event = (seq: number, type: StreamEvent['type'], payload: unknown): StreamEvent =>
|
||||
({ seq, type, payload, runId: 'r1', conversationId: 'c1', ts: '2026-10-14T10:00:00Z', v: 1 })
|
||||
const parts = runEventsToTimeline([
|
||||
event(1, 'thinking.delta', { index: 0, text: '先核对' }),
|
||||
event(2, 'message.delta', { index: 0, text: '开始读取材料' }),
|
||||
event(3, 'tool.call', { callId: 'call1', name: 'fs_read_file', arguments: '{"path":"a.txt"}' }),
|
||||
event(4, 'tool.output', { callId: 'call1', text: output }),
|
||||
event(5, 'tool.result', { callId: 'call1', status: 'ok', summary: '读取成功' }),
|
||||
event(6, 'run.phase', { phase: 'finalizing' }),
|
||||
event(7, 'message.delta', { index: 1, text: '最终回复' }),
|
||||
])
|
||||
expect(parts.map(part => part.type)).toEqual(['thinking', 'text', 'tool_call', 'tool_result'])
|
||||
expect((parts[2] as typeof parts[number] & { toolOutput: string }).toolOutput).toBe(output)
|
||||
expect(parts.some(part => part.text === '最终回复')).toBe(false)
|
||||
})
|
||||
|
||||
it('retains collaboration notes from a child after the parent has replied', () => {
|
||||
const event = (seq: number, type: StreamEvent['type'], payload: unknown, runId = 'parent'): StreamEvent =>
|
||||
({ seq, type, payload, runId, conversationId: 'c1', ts: '2026-10-14T10:00:00Z', v: 1 })
|
||||
const parts = runEventsToTimeline([
|
||||
event(1, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }),
|
||||
event(2, 'run.phase', { phase: 'finalizing' }),
|
||||
event(3, 'message.delta', { index: 0, text: '最终回复' }),
|
||||
event(4, 'agent.progress', { agentRun: 'child', note: '正在核对附录' }, 'child'),
|
||||
event(5, 'agent.result', { agentRun: 'child', status: 'completed', summary: '附录已核对' }, 'child'),
|
||||
], 'parent')
|
||||
expect(parts.map(part => part.text)).toEqual([
|
||||
'协作代理「资料核对」已开始', '协作进展:正在核对附录', '协作结束:附录已核对',
|
||||
])
|
||||
expect(parts.every(part => part.replaySource === 'agent')).toBe(true)
|
||||
})
|
||||
|
||||
describe('livePhaseLabel', () => {
|
||||
it('names the current activity in Chinese', () => {
|
||||
let items: LiveItem[] = applyThinkingDelta([], '想', 0)
|
||||
|
||||
+54
-19
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { splitTimeline, humanSize, artifactIcon, artifactIdsIn, markMissingArtifacts, workedLabel, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../src/messageparts'
|
||||
import { splitTimeline, mergeProcessTimeline, humanSize, artifactIcon, artifactIdsIn, markMissingArtifacts, workedLabel, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../src/messageparts'
|
||||
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolOutput, liveToMessage, showProcessInline } from '../src/live'
|
||||
import type { Part } from '../src/contracts'
|
||||
|
||||
@@ -8,7 +8,7 @@ function p(partial: Partial<Part> & { type: Part['type'] }): Part {
|
||||
}
|
||||
|
||||
describe('splitTimeline', () => {
|
||||
it('keeps public Markdown before, between and after tools in reading order', () => {
|
||||
it('keeps intermediate narration with its tools in reading order and the reply outside', () => {
|
||||
const parts: Part[] = [
|
||||
p({ type: 'text', text: '我会先核对**两份可读材料**。', process: true }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'read' } }),
|
||||
@@ -18,12 +18,11 @@ describe('splitTimeline', () => {
|
||||
p({ type: 'text', text: '接下来确认日期冲突。' }),
|
||||
]
|
||||
const split = splitTimeline(parts)
|
||||
expect(split.final.map(part => part.text)).toEqual([
|
||||
'我会先核对**两份可读材料**。',
|
||||
'评审意见未能读取,完整结论尚未生成。',
|
||||
'接下来确认日期冲突。',
|
||||
expect(split.timeline.map(part => part.type)).toEqual(['text', 'tool_call', 'tool_result', 'text', 'tool_call'])
|
||||
expect(split.timeline.filter(part => part.type === 'text').map(part => part.text)).toEqual([
|
||||
'我会先核对**两份可读材料**。', '评审意见未能读取,完整结论尚未生成。',
|
||||
])
|
||||
expect(split.timeline.map(part => part.type)).toEqual(['tool_call', 'tool_result', 'tool_call'])
|
||||
expect(split.final.map(part => part.text)).toEqual(['接下来确认日期冲突。'])
|
||||
})
|
||||
|
||||
it('keeps live scope limits visible while execution records are folded by default', () => {
|
||||
@@ -36,9 +35,8 @@ describe('splitTimeline', () => {
|
||||
expect(message.parts[1].process).toBe(true)
|
||||
expect(showProcessInline(live, true)).toBe(false)
|
||||
const split = splitTimeline(message.parts)
|
||||
expect(split.final.map(part => part.text)).toEqual(['**范围限制:**目前仅整理两份材料,评审意见尚未读取。'])
|
||||
expect(split.final.every(part => part.type === 'text')).toBe(true)
|
||||
expect(split.timeline.map(part => part.type)).toEqual(['thinking', 'tool_call'])
|
||||
expect(split.final).toEqual([])
|
||||
expect(split.timeline.map(part => part.type)).toEqual(['thinking', 'text', 'tool_call'])
|
||||
})
|
||||
|
||||
it('keeps finalizing checks and internal thinking out of visible text without duplicating explanations', () => {
|
||||
@@ -52,8 +50,8 @@ describe('splitTimeline', () => {
|
||||
]
|
||||
for (const finished of [false, true]) {
|
||||
const split = splitTimeline(parts, finished)
|
||||
expect(split.final.map(part => part.text)).toEqual(['评审意见尚未读取。', '已整理两份材料,请补充清晰的评审意见。'])
|
||||
expect(split.timeline.map(part => part.type)).toEqual(['thinking', 'tool_call', 'tool_result', 'check'])
|
||||
expect(split.final.map(part => part.text)).toEqual(['已整理两份材料,请补充清晰的评审意见。'])
|
||||
expect(split.timeline.map(part => part.type)).toEqual(['text', 'thinking', 'tool_call', 'tool_result', 'check'])
|
||||
expect(split.final.length + split.timeline.length).toBe(parts.length)
|
||||
}
|
||||
})
|
||||
@@ -69,7 +67,7 @@ describe('splitTimeline', () => {
|
||||
expect(split.timeline.map(x => x.type)).toEqual(['tool_call'])
|
||||
expect(split.final.map(x => x.type)).toEqual(['error', 'artifact_ref', 'text'])
|
||||
})
|
||||
it('keeps only execution records folded when narration precedes the last process part', () => {
|
||||
it('keeps narration beside execution records when it precedes the last process part', () => {
|
||||
const parts: Part[] = [
|
||||
p({ type: 'text', text: '先找资料', process: true }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }),
|
||||
@@ -78,8 +76,8 @@ describe('splitTimeline', () => {
|
||||
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }),
|
||||
]
|
||||
const split = splitTimeline(parts)
|
||||
expect(split.timeline.map(x => x.type)).toEqual(['tool_call', 'tool_result'])
|
||||
expect(split.final.map(x => x.type)).toEqual(['text', 'text', 'artifact_ref'])
|
||||
expect(split.timeline.map(x => x.type)).toEqual(['text', 'tool_call', 'tool_result'])
|
||||
expect(split.final.map(x => x.type)).toEqual(['text', 'artifact_ref'])
|
||||
})
|
||||
|
||||
it('keeps a card sandwiched between process parts inside the timeline', () => {
|
||||
@@ -94,7 +92,7 @@ describe('splitTimeline', () => {
|
||||
expect(split.final.map(x => x.text)).toEqual(['最终'])
|
||||
})
|
||||
|
||||
it('keeps all narrations visible even when a finished run has no final text', () => {
|
||||
it('promotes only the last narration when a finished run has no final text', () => {
|
||||
const parts: Part[] = [
|
||||
p({ type: 'text', text: '先写一版', process: true }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'run_python' } }),
|
||||
@@ -102,9 +100,9 @@ describe('splitTimeline', () => {
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c2', name: 'run_python' } }),
|
||||
]
|
||||
const split = splitTimeline(parts, true)
|
||||
expect(split.final.map(x => x.text)).toEqual(['先写一版', '纪要已经整理好'])
|
||||
expect(split.timeline.map(x => x.type)).toEqual(['tool_call', 'tool_call'])
|
||||
expect(splitTimeline(parts).final).toEqual(split.final)
|
||||
expect(split.final.map(x => x.text)).toEqual(['纪要已经整理好'])
|
||||
expect(split.timeline.map(x => x.type)).toEqual(['text', 'tool_call', 'tool_call'])
|
||||
expect(splitTimeline(parts).final).toEqual([])
|
||||
})
|
||||
|
||||
it('puts a message with no process parts entirely in the final section', () => {
|
||||
@@ -115,6 +113,43 @@ describe('splitTimeline', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('enriches saved process without losing retry notes or parsed inline thinking', () => {
|
||||
const saved: Part[] = [
|
||||
p({ type: 'text', process: true, text: '模型在输出前失败,正在重试' }),
|
||||
p({ type: 'thinking', process: true, text: '检查附件边界' }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' } }),
|
||||
p({ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok' } }),
|
||||
]
|
||||
const replayed: Part[] = [
|
||||
p({ type: 'text', process: true, text: '<think>检查附件边界</think>' }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' }, toolOutput: '完整输出' } as Part),
|
||||
p({ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok' } }),
|
||||
p({ type: 'text', process: true, text: '最终回复' }),
|
||||
p({ type: 'text', process: true, text: '协作进展:附录已核对' }),
|
||||
]
|
||||
const merged = mergeProcessTimeline(saved, replayed, [p({ type: 'text', text: '最终回复' })])
|
||||
expect(merged.map(part => part.text).filter(Boolean)).toEqual([
|
||||
'模型在输出前失败,正在重试', '检查附件边界', '协作进展:附录已核对',
|
||||
])
|
||||
expect((merged[2] as Part & { toolOutput: string }).toolOutput).toBe('完整输出')
|
||||
expect(merged.filter(part => part.type === 'tool_call')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not repeat a partial event delta beside the complete saved narration', () => {
|
||||
const saved = [p({ type: 'text', process: true, text: '开始读取材料' }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' } })]
|
||||
const replayed = [p({ type: 'text', process: true, text: '开始读取' }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' } })]
|
||||
expect(mergeProcessTimeline(saved, replayed, []).map(part => part.text).filter(Boolean)).toEqual(['开始读取材料'])
|
||||
})
|
||||
|
||||
it('keeps a collaboration result even when the final reply contains its short status', () => {
|
||||
const note = p({ type: 'text', process: true, text: '协作结束:已完成', replaySource: 'agent' } as Part)
|
||||
const merged = mergeProcessTimeline([p({ type: 'text', process: true, text: '完成' })], [note],
|
||||
[p({ type: 'text', text: '已完成' })])
|
||||
expect(merged.map(part => part.text)).toEqual(['完成', '协作结束:已完成'])
|
||||
})
|
||||
|
||||
describe('artifact ids in reply text', () => {
|
||||
it('extracts unique ids in order', () => {
|
||||
expect(artifactIdsIn('见 art_0123456789ab 和 `art_abcdefabcdef`,再提 art_0123456789ab'))
|
||||
|
||||
Reference in New Issue
Block a user