diff --git a/e2e/work-companion.playwright.ts b/e2e/work-companion.playwright.ts index d3e7211..6220ae5 100644 --- a/e2e/work-companion.playwright.ts +++ b/e2e/work-companion.playwright.ts @@ -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[] }) { let cards = [...initialCards] let actionCount = 0 let releaseAction: (() => void) | undefined @@ -64,8 +65,20 @@ 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: [ + { 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: '最终回复已完成' }, + ], 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 +127,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 +175,51 @@ 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('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]) diff --git a/src/api.ts b/src/api.ts index d8bb8f4..c681518 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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 => { diff --git a/src/companion-conversation.css b/src/companion-conversation.css index 5402689..ce559b4 100644 --- a/src/companion-conversation.css +++ b/src/companion-conversation.css @@ -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) { diff --git a/src/companion-shell.css b/src/companion-shell.css index 3ee248d..ee52d3e 100644 --- a/src/companion-shell.css +++ b/src/companion-shell.css @@ -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); diff --git a/src/components/Conversation.tsx b/src/components/Conversation.tsx index 2cbd1a6..c2c368e 100644 --- a/src/components/Conversation.tsx +++ b/src/components/Conversation.tsx @@ -6,9 +6,9 @@ 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' @@ -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 n + (node.markdown?.length ?? 0), 0) - return store.openInbox(ref)} docBridge={docBridge} /> } // 插件内嵌卡片部件(cardlink 互定位,D32):点击 ↗ 定位收件箱对应卡(从 data 解析 card:)。 @@ -211,6 +212,32 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat useEffect(() => { if (!touchedProcess.has(memKey)) setOpen(showProcess) }, [memKey, showProcess]) + const [replayed, setReplayed] = useState(null) + const [processError, setProcessError] = useState(false) + 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)) + } 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 +246,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?.length ? replayed : split.timeline + const toolCount = processParts.filter(p => p.type === 'tool_call').length const headline = live ? '执行记录' : `执行记录 · ${processSummary(toolCount, cards.length, run?.createdAt, run?.endedAt)}` const timeline = ( - } /> ) const toggleProcess = (next: boolean) => { @@ -237,14 +265,18 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat 助手 {live && } - {split.timeline.some(part => part.type !== 'thinking') && ( + {(split.timeline.length > 0 || !!msg.runId) && (
▸ {headline} }> -
{timeline}
+
+ {replayed === null && !live && !processError &&
正在读取完整记录…
} + {processError &&
完整记录暂不可用,以下为已保存的摘要
} + {processParts.length > 0 ? timeline : replayed &&
这轮没有可回看的过程记录
} +
)} @@ -486,6 +518,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([]) const [attachmentNames, setAttachmentNames] = useState([]) @@ -636,13 +669,16 @@ function ConversationSession() {
{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}
{currentConvId && } - {anchored.map(c => ( - store.cardAction(card, actionId), - actionState: card => cardActionStates?.[card.id], - onLocate: card => store.openInbox(card.id), - }} /> - ))} + {anchored.map(c => workPaneOwnsCards + ? + : store.cardAction(card, actionId), + actionState: card => cardActionStates?.[card.id], + onLocate: card => store.openInbox(card.id), + }} /> + )} ) } diff --git a/src/components/ConversationWork.tsx b/src/components/ConversationWork.tsx index b547141..6768ba9 100644 --- a/src/components/ConversationWork.tsx +++ b/src/components/ConversationWork.tsx @@ -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 diff --git a/src/components/ProcessTimeline.tsx b/src/components/ProcessTimeline.tsx index 9ecd4d4..d2be111 100644 --- a/src/components/ProcessTimeline.tsx +++ b/src/components/ProcessTimeline.tsx @@ -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 (
{rows.map((row, i) => { - // 模型内部思考不是用户工作内容,也不属于可展开的执行记录。 - if (row.kind === 'thinking') return null + if (row.kind === 'thinking') { + return row.text &&
+ 思考过程 +
{row.text}
+
+ } if (row.kind === 'text') { return
{renderPart({ type: 'text', text: row.text }, false)}
} @@ -38,6 +42,11 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: { {it.headline} {it.status === 'error' && 失败} {it.summary && {it.summary}} + {(it.arguments || it.output) && 查看记录 ▸}> + {it.arguments &&
{it.arguments}
} + {it.output &&
{it.output}
} +
} ))} diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 4cd83ab..898ef46 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -36,7 +36,7 @@ export function TopBar({ compact = false, activePane = 'conversation', showRetur 对话 } - + } ) diff --git a/src/live.ts b/src/live.ts index 160a626..938e918 100644 --- a/src/live.ts +++ b/src/live.ts @@ -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 } from './messageparts' export type ToolLiveState = 'composing' | 'running' | 'done' @@ -118,13 +119,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 @@ -224,6 +225,48 @@ 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[]): Part[] { + let items: LiveItem[] = [] + for (const event of [...events].sort((a, b) => a.seq - b.seq)) { + const p = event.payload as Record | undefined + const now = Date.parse(event.ts) || 0 + switch (event.type) { + case 'thinking.delta': + if (typeof p?.text === 'string') items = applyThinkingDelta(items, p.text, now) + break + case 'message.delta': + if (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') 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, true).timeline +} + // livePhaseLabel 用最后一个实时条目给出中文阶段,而不是后端的英文 phase。 export function livePhaseLabel(live: LiveState, now = Date.now()): string { if (live.phase === 'finalizing') return '整理回复中' diff --git a/src/messageparts.ts b/src/messageparts.ts index e504126..1aa08b9 100644 --- a/src/messageparts.ts +++ b/src/messageparts.ts @@ -1,5 +1,4 @@ -// 消息切分:技术记录留在过程时间线,正文、错误与文件入口始终直接可读。 -// 两区各自保留落库顺序,后续工具或终轮检查不再把解说藏入折叠区。 +// 消息切分:每轮过程按落库顺序留在可回看的时间线,终轮回复直接可读。 import type { Part } from './contracts' export interface TimelineSplit { @@ -7,17 +6,26 @@ export interface TimelineSplit { final: Part[] } -/** 最后一个 process 部件之前的技术记录留在时间线,其余为可见内容。 - * 保留旧 promoteLastText 参数以兼容调用方;解说现在始终可见,不再依赖终态提升。 */ -export function splitTimeline(parts: Part[], _promoteLastText = false): TimelineSplit { +/** 最后一个 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 } } diff --git a/src/styles.css b/src/styles.css index c20c68e..54016a1 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; } diff --git a/test/conversation-work.test.ts b/test/conversation-work.test.ts index fdb64d2..ef70cdc 100644 --- a/test/conversation-work.test.ts +++ b/test/conversation-work.test.ts @@ -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', () => { diff --git a/test/live.test.ts b/test/live.test.ts index bedb453..e49a78f 100644 --- a/test/live.test.ts +++ b/test/live.test.ts @@ -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 { @@ -159,6 +161,24 @@ 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) +}) + describe('livePhaseLabel', () => { it('names the current activity in Chinese', () => { let items: LiveItem[] = applyThinkingDelta([], '想', 0) diff --git a/test/messageparts.test.ts b/test/messageparts.test.ts index e37103f..d913d5c 100644 --- a/test/messageparts.test.ts +++ b/test/messageparts.test.ts @@ -8,7 +8,7 @@ function p(partial: Partial & { 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', () => {