@@ -212,17 +191,27 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
助手
{live && }
- {blocks.map((b, i) => b.kind === 'process'
- ? (showProcess
- ?
- :
)
+ {split.timeline.length > 0 && (showProcess
+ ?
{timeline}
: (
- // 最终输出契约:终轮正文是对用户的回复,一律完整可见(CF0 长回复折叠
- // 已废除——过程进折叠区,回复不再折叠);交付卡片钉在正文之后。
-
-
+
+
+ ▸
+ {workedLabel(run?.createdAt, run?.endedAt)}
+ >}>
+ {timeline}
+
))}
+ {split.final.map((part, i) => (
+
+ ))}
+ {extras.map(art => (
+
+ ))}
{live &&
}
{!live && ctxObs &&
}
diff --git a/src/components/PreviewPane.tsx b/src/components/PreviewPane.tsx
new file mode 100644
index 0000000..f185bc7
--- /dev/null
+++ b/src/components/PreviewPane.tsx
@@ -0,0 +1,50 @@
+// 对话右侧预览区:多标签、可关、全屏与下载。宽度由 App 的分隔条控制。
+import { FilePreview, TabStrip, Icon } from '@ui'
+import { api } from '../api'
+import { store, useStore } from '../store'
+
+export function PreviewPane() {
+ const convId = useStore(s => s.currentConvId)
+ const tabs = useStore(s => (convId ? s.previewTabs[convId] : undefined) ?? [])
+ const activeId = useStore(s => (convId ? s.previewActive[convId] : null) ?? null)
+ const artifacts = useStore(s => s.artifacts)
+ const active = tabs.find(t => t.id === activeId) ?? tabs[0]
+ if (!active) return null
+ const art = artifacts.find(a => a.id === active.id)
+ return (
+
+
({ id: t.id, label: t.name }))}
+ activeId={active.id}
+ onActivate={id => store.activatePreview(id)}
+ onClose={id => store.closePreview(id)}
+ />
+
+
{active.name}
+
+ {art && (
+ <>
+
+
+
+
+ >
+ )}
+
+
+ {art ? (
+
+ ) :
这个文件已不在当前项目里
}
+
+
+ )
+}
diff --git a/src/components/ProcessTimeline.tsx b/src/components/ProcessTimeline.tsx
new file mode 100644
index 0000000..7f1c31a
--- /dev/null
+++ b/src/components/ProcessTimeline.tsx
@@ -0,0 +1,57 @@
+// 工作时间线:思考、对用户的解说、同类工具合并成一行。运行中展开;
+// 终轮结束后由调用方套进「已工作 X」折叠。
+import type { ReactNode } from 'react'
+import { Collapsible } from '@ui'
+import type { Part } from '../contracts'
+import { formatDuration } from '../messageparts'
+import { timelineEntries, toolGroupLabel } from '../toolkind'
+
+export function ProcessTimeline({ parts, live, idKey, renderPart }: {
+ parts: Part[]
+ live: boolean
+ idKey: string
+ renderPart: (part: Part, liveText: boolean) => ReactNode
+}) {
+ const rows = timelineEntries(parts, live)
+ return (
+
+ {rows.map((row, i) => {
+ if (row.kind === 'thinking') {
+ const dur = formatDuration(row.durationMs)
+ const label = row.active ? '思考中' : (dur ? `思考 · 持续了 ${dur}` : '思考')
+ return (
+
+
▸
+ {label}>}>
+ {row.text}
+
+
+ )
+ }
+ if (row.kind === 'text') {
+ return
{renderPart({ type: 'text', text: row.text }, false)}
+ }
+ if (row.kind === 'tools') {
+ const running = row.items.some(it => it.running)
+ return (
+
+
{toolGroupLabel(row.toolKind, row.items.length, running)}
+
+ {row.items.map((it, j) => (
+ -
+ {it.headline}
+ {it.status === 'error' && 失败}
+ {it.summary && {it.summary}}
+
+ ))}
+
+
+ )
+ }
+ return
{renderPart(row.part, false)}
+ })}
+
+ )
+}
diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx
index e9172c4..9d8549d 100644
--- a/src/components/Workbench.tsx
+++ b/src/components/Workbench.tsx
@@ -1,14 +1,12 @@
-// 右栏终态(提案 T4/D25 → 预览双落位):收件箱/预览两页签 + 「当前任务」常驻卡。
-// 预览页签(裁定 D4)承载右栏轻预览:FilePreview compact 模式,边聊边看。
+// 右栏:收件箱与文件。文件预览在对话右侧的预览区,不再占右栏页签。
import { store, useStore } from '../store'
import type { Run } from '../contracts'
-import { InboxList, pendingCount, FilePreview, type ActivityCard, type DocBridge } from '@ui'
+import { InboxList, pendingCount, type ActivityCard, type DocBridge } from '@ui'
import { Icon } from '@ui'
-import { api } from '../api'
import { ArtifactsPanel } from './ArtifactsPanel'
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
-// 打开语义走 requestPreview 唯一入口(阶段③ 收敛:默认右栏轻预览)。
+// 打开语义走 requestPreview 唯一入口(预览区)。
export const docBridge: DocBridge = {
resolveDoc: id => store.getState().artifacts.find(a => a.id === id),
onOpenDoc: id => {
@@ -56,47 +54,20 @@ export function Workbench() {
const projectCards = useStore(s => s.projectCards)
const pending = pendingCount(cards) + pendingCount(projectCards)
const rightTab = useStore(s => s.ui.rightTab)
- const railId = useStore(s => s.ui.railPreviewId)
- const railArtifact = useStore(s => (railId ? s.artifacts.find(a => a.id === railId) : undefined))
- const showPreview = rightTab === 'preview'
const showFiles = rightTab === 'files'
return (
<>
-
- {showPreview ? (
-
- {railArtifact ? (
- <>
-
-
{railArtifact.name}
-
-
store.requestPreview(railArtifact, 'full')}>
-
-
store.railPreview(null)}>
-
-
- >
- ) :
点消息里的工件卡片或右侧工件面板——文件会在这里边聊边看(md/图片/pdf/Office/代码均可)
}
-
- ) : showFiles ? (
+ {showFiles ? (
diff --git a/src/contracts.ts b/src/contracts.ts
index 294e977..5ec5349 100644
--- a/src/contracts.ts
+++ b/src/contracts.ts
@@ -36,6 +36,7 @@ export interface Part {
card?: { kind: string; data: UINode[]; cardId?: ID }; // cardId 非空 = 收件箱卡片引用部件(submit_card)
error?: { code: string; message: string };
process?: boolean; // run 终态后折叠为摘要行
+ durationMs?: number; // thinking 部件的思考时长(毫秒);工具耗时在 toolCall.durationMs
}
export interface Message {
diff --git a/src/live.ts b/src/live.ts
index 14a2ceb..2b59be1 100644
--- a/src/live.ts
+++ b/src/live.ts
@@ -11,20 +11,21 @@ import type { LiveState } from './store'
// 到达序部件流的元素。
export type LiveItem =
- | { kind: 'thinking'; text: string }
+ | { kind: 'thinking'; text: string; startedAt: number; endedAt: number }
| { kind: 'text'; index: number; text: string }
| { kind: 'tool'; callId?: string; name: string; status?: string; summary?: string }
| { kind: 'artifact'; artifact: { artifactId: string; name: string; kind?: string } }
// applyThinkingDelta 并入思考增量:末段是思考则追加,否则开启新思考段(新轮开始)。
-export function applyThinkingDelta(items: LiveItem[], delta: string): LiveItem[] {
+// now 供测试固定首尾时间;缺省用墙上时钟,durationMs = endedAt - startedAt。
+export function applyThinkingDelta(items: LiveItem[], delta: string, now = Date.now()): LiveItem[] {
const last = items[items.length - 1]
if (last && last.kind === 'thinking') {
const out = items.slice()
- out[out.length - 1] = { kind: 'thinking', text: last.text + delta }
+ out[out.length - 1] = { kind: 'thinking', text: last.text + delta, startedAt: last.startedAt, endedAt: now }
return out
}
- return [...items, { kind: 'thinking', text: delta }]
+ return [...items, { kind: 'thinking', text: delta, startedAt: now, endedAt: now }]
}
// applyTextDelta 并入正文增量:末段是同 index 的文本段则追加,否则开启新文本段。
@@ -78,7 +79,11 @@ export function liveToMessage(live: LiveState): Message {
for (let i = 0; i < live.items.length; i++) {
const it = live.items[i]
if (it.kind === 'thinking') {
- if (it.text) parts.push({ type: 'thinking', text: it.text, process: true })
+ const durationMs = it.endedAt - it.startedAt
+ if (it.text) parts.push({
+ type: 'thinking', text: it.text, process: true,
+ ...(durationMs > 0 ? { durationMs } : {}),
+ })
} else if (it.kind === 'tool') {
parts.push({ type: 'tool_call', process: true, toolCall: { id: it.callId ?? '', name: it.name } })
if (it.status !== undefined) {
@@ -97,9 +102,8 @@ export function liveToMessage(live: LiveState): Message {
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' }
}
-// showProcessInline:流式期间过程区是否展开——thinking/acting 实时展开;一旦进入
-// responding(最终回复开始流式)立即折叠,只显示回复(Cursor 式),工具恢复再展开。
-// 非 live 消息随「自动折叠过程」偏好。
+// showProcessInline:运行中时间线始终展开(不能靠 responding 提前折,那一轮文字
+// 后面还可能再调用工具)。run 结束后随「自动折叠过程」偏好收成一行。
export function showProcessInline(live: LiveState | null, autoCollapse: boolean): boolean {
- return live ? live.phase !== 'responding' : !autoCollapse
+ return live != null || !autoCollapse
}
diff --git a/src/messageparts.ts b/src/messageparts.ts
index 9275a30..f20b0a8 100644
--- a/src/messageparts.ts
+++ b/src/messageparts.ts
@@ -1,30 +1,51 @@
-// 消息部件顺序保真(渲染顺序 = 落库顺序):后端 llm.go 按时间交错落库 parts
-// (text → tool_call → tool_result → artifact_ref → 下一轮 text…),渲染端不得
-// 按 process 标志二分重排——只允许把"连续的"过程部件就地聚成一块折叠。
+// 消息切分:过程时间线 = 从开头到最后一个 process 部件(中间夹着的内容部件留在时间线里);
+// 最终段 = 它之后的正文与交付卡片。渲染顺序仍是落库顺序,只在这一刀上折叠。
import type { Part } from './contracts'
-export type PartBlock =
- | { kind: 'process'; parts: Part[] } // 连续过程段(thinking/tool_call/tool_result…)
- | { kind: 'content'; part: Part } // 原位内容部件(text/artifact_ref/card/hitl…)
+export interface TimelineSplit {
+ timeline: Part[]
+ final: Part[]
+}
+
+/** 最后一个 process 部件之前(含自身)是工作时间线,其余是最终回复。 */
+export function splitTimeline(parts: Part[]): 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 }
+ return { timeline: parts.slice(0, last + 1), final: parts.slice(last + 1) }
+}
+
+const ART_ID = /art_[0-9a-f]{12}/g
+
+/** 正文里出现的工件 ID,去重且保持出现顺序。 */
+export function artifactIdsIn(text: string): string[] {
+ return [...new Set(text.match(ART_ID) ?? [])]
+}
/**
- * 按原始顺序把 parts 切成「过程段 / 内容」交替的块序列。
- * 例:[text, tool_call, tool_result, text, artifact_ref]
- * → [content(text), process(tool_call,tool_result), content(text), content(artifact_ref)]
- * 连续的过程部件合并为一个折叠单元,不改变相对顺序。
+ * 把正文里项目中不存在的工件 ID 换成弱化标记。存在的 ID 原样保留,由调用方补卡片。
+ * ID 只有十六进制,替换片段不含用户可控的 HTML。
*/
-export function groupParts(parts: Part[]): PartBlock[] {
- const out: PartBlock[] = []
- for (const p of parts) {
- if (p.process) {
- const last = out[out.length - 1]
- if (last && last.kind === 'process') last.parts.push(p)
- else out.push({ kind: 'process', parts: [p] })
- } else {
- out.push({ kind: 'content', part: p })
- }
- }
- return out
+export function markMissingArtifacts(text: string, exists: (id: string) => boolean): string {
+ return text.replace(/`?(art_[0-9a-f]{12})`?/g, (raw, id: string) => (
+ exists(id) ? raw : '
未找到该文件'
+ ))
+}
+
+/** 毫秒转「X 分 Y 秒」;不足 1 秒按 1 秒,无效值返回空串。 */
+export function formatDuration(ms: number | undefined): string {
+ if (ms == null || !Number.isFinite(ms) || ms <= 0) return ''
+ const s = Math.max(1, Math.round(ms / 1000))
+ const m = Math.floor(s / 60)
+ return m > 0 ? `${m} 分 ${s % 60} 秒` : `${s} 秒`
+}
+
+/** run 起止时间 → 「已工作 X」。缺时间时只给「已工作」。 */
+export function workedLabel(createdAt?: string, endedAt?: string): string {
+ if (!createdAt || !endedAt) return '已工作'
+ const ms = new Date(endedAt).getTime() - new Date(createdAt).getTime()
+ const text = formatDuration(ms)
+ return text ? `已工作 ${text}` : '已工作'
}
/** 文件大小人类可读(工件面板/DocViewer 共用)。 */
diff --git a/src/previewsetup.ts b/src/previewsetup.ts
index 2ee393b..4c94b17 100644
--- a/src/previewsetup.ts
+++ b/src/previewsetup.ts
@@ -39,9 +39,17 @@ const docxLoader = async (el: HTMLElement, source: { contentUrl?: string; name?:
renderTextFallback(el, buf, source.name, 'DOCX')
return
}
+ try {
+ const { renderAsync } = await import('docx-preview')
+ el.innerHTML = ''
+ await renderAsync(buf, el, el, { className: 'docx', inWrapper: true, breakPages: true })
+ return
+ } catch (err) {
+ console.error('[preview:docx] page render failed, falling back', source.name, err)
+ }
const mammoth = await import('mammoth')
const { value } = await mammoth.convertToHtml({ arrayBuffer: buf })
- el.innerHTML = value
+ el.innerHTML = `
${value}
`
}
const xlsxLoader = async (el: HTMLElement, source: { contentUrl?: string; name?: string }) => {
diff --git a/src/previewtabs.ts b/src/previewtabs.ts
new file mode 100644
index 0000000..79ed08d
--- /dev/null
+++ b/src/previewtabs.ts
@@ -0,0 +1,27 @@
+// 预览区标签的纯操作:打开已有标签只激活,关闭激活标签时选中相邻的一个。
+
+export interface PreviewTab {
+ id: string
+ name: string
+}
+
+export function openPreviewTab(tabs: PreviewTab[], tab: PreviewTab): { tabs: PreviewTab[]; activeId: string } {
+ const i = tabs.findIndex(t => t.id === tab.id)
+ if (i >= 0) {
+ const next = tabs.slice()
+ next[i] = { ...next[i], name: tab.name || next[i].name }
+ return { tabs: next, activeId: tab.id }
+ }
+ return { tabs: [...tabs, tab], activeId: tab.id }
+}
+
+export function closePreviewTab(
+ tabs: PreviewTab[], activeId: string | null, id: string,
+): { tabs: PreviewTab[]; activeId: string | null } {
+ const i = tabs.findIndex(t => t.id === id)
+ if (i < 0) return { tabs, activeId }
+ const next = tabs.filter(t => t.id !== id)
+ if (activeId !== id) return { tabs: next, activeId }
+ const neighbor = next[Math.min(i, next.length - 1)] ?? null
+ return { tabs: next, activeId: neighbor?.id ?? null }
+}
diff --git a/src/store.ts b/src/store.ts
index d42feee..9ef07e8 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -3,6 +3,7 @@
import { useSyncExternalStore } from 'react'
import { api, connectEvents } from './api'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolResult, applyArtifactCreated, type LiveItem } from './live'
+import { closePreviewTab, openPreviewTab, type PreviewTab } from './previewtabs'
import { enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList,
@@ -36,8 +37,8 @@ export interface UIState {
settingsOpen: boolean
trackerRunId: string | null
viewingArtifact: Artifact | null
- /** 右栏轻预览的工件(rail 预览页签内容;与弹窗深预览 viewingArtifact 并存)。 */
- railPreviewId: string | null
+ /** 预览区宽度。标签列表按会话放在 AppState,不进本地偏好。 */
+ previewWidth: number
activeWorkspace: string | null
taskOrg: 'project' | 'timeline'
showArchived: boolean
@@ -77,6 +78,8 @@ interface AppState {
toast: string | null
outbox: QueuedMessage[]
outboxDraining: boolean
+ previewTabs: Record
+ previewActive: Record
}
const DEFAULT_UI: UIState = {
@@ -91,7 +94,7 @@ const DEFAULT_UI: UIState = {
settingsOpen: false,
trackerRunId: null,
viewingArtifact: null,
- railPreviewId: null,
+ previewWidth: 640,
taskOrg: 'project',
showArchived: false,
lastProjectId: null,
@@ -116,6 +119,7 @@ let state: AppState = {
serverWorkspaces: [],
ui: loadUI(), connected: false, error: null, toast: null,
outbox: [], outboxDraining: false,
+ previewTabs: {}, previewActive: {},
}
const listeners = new Set<() => void>()
@@ -125,6 +129,21 @@ function set(patch: Partial) {
listeners.forEach(l => l())
}
+// 预览区打开前右栏是否收起。null = 当前没有因为预览而改过右栏。
+let previewRightWasCollapsed: boolean | null = null
+
+function syncPreviewRail(hasTabs: boolean) {
+ if (hasTabs) {
+ if (previewRightWasCollapsed === null) previewRightWasCollapsed = state.ui.rightCollapsed
+ if (!state.ui.rightCollapsed) setUI({ rightCollapsed: true })
+ return
+ }
+ if (previewRightWasCollapsed === null) return
+ const restore = previewRightWasCollapsed
+ previewRightWasCollapsed = null
+ setUI({ rightCollapsed: restore })
+}
+
function setUI(patch: Partial) {
const ui = { ...state.ui, ...patch }
state = { ...state, ui }
@@ -244,6 +263,7 @@ export const store = {
async openConversation(convId: string) {
if (sse) { sse.close(); sse = null }
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], inboxScope: 'conv', outbox: [], outboxDraining: false })
+ syncPreviewRail((state.previewTabs[convId] ?? []).length > 0)
await store.reconcile()
await store.connectLive()
},
@@ -650,19 +670,32 @@ export const store = {
},
openTracker(runId: string) { setUI({ trackerRunId: runId }) },
viewArtifact(a: Artifact | null) { setUI({ viewingArtifact: a }) },
- /** 右栏轻预览:打开指定工件并切到 rail 预览页签(双落位中的"轻"侧)。 */
- railPreview(a: Artifact | null) {
- if (!a) { setUI({ railPreviewId: null, rightTab: 'inbox' }); return }
- setUI({ railPreviewId: a.id, rightTab: 'preview', rightCollapsed: false })
- },
/**
- * 工件预览唯一入口(重设计方案 阶段③ 收敛,Command 语义):一切预览请求只经这里,
- * 落位由本方法派生——默认右栏轻预览(不打断对话),hint 'full' 落位全屏深读
- * (DocViewer,全项目唯一"大窗"形态)。下载语义同理由各落位只保留一个按钮。
+ * 工件预览唯一入口。默认在对话右侧预览区打开或激活标签;hint 'full' 落位全屏
+ * DocViewer。打开第一张标签时收起右栏,关掉最后一张时恢复。
*/
requestPreview(a: Artifact, hint?: 'full') {
if (hint === 'full') { setUI({ viewingArtifact: a }); return }
- setUI({ railPreviewId: a.id, rightTab: 'preview', rightCollapsed: false })
+ const conv = state.currentConvId ?? '_'
+ const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: a.id, name: a.name })
+ set({
+ previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
+ previewActive: { ...state.previewActive, [conv]: opened.activeId },
+ })
+ syncPreviewRail(true)
+ },
+ activatePreview(id: string) {
+ const conv = state.currentConvId ?? '_'
+ set({ previewActive: { ...state.previewActive, [conv]: id } })
+ },
+ closePreview(id: string) {
+ const conv = state.currentConvId ?? '_'
+ const next = closePreviewTab(state.previewTabs[conv] ?? [], state.previewActive[conv] ?? null, id)
+ set({
+ previewTabs: { ...state.previewTabs, [conv]: next.tabs },
+ previewActive: { ...state.previewActive, [conv]: next.activeId },
+ })
+ syncPreviewRail(next.tabs.length > 0)
},
openSettings(open: boolean) { setUI({ settingsOpen: open }) },
openProjectSettings(id: string | null) { setUI({ projectSettingsId: id }) },
diff --git a/src/styles.css b/src/styles.css
index bd5adba..2e0b1fa 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -112,22 +112,46 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.artifact-chip .ac-act { display: inline-flex; align-items: center; padding: 5px 8px; color: var(--text-dim); border-left: 1px solid var(--border-soft); background: none; cursor: pointer; }
.artifact-chip .ac-act:hover { color: var(--accent); background: var(--bg-hover); }
-/* 交付物卡片(对话内直接给出;整卡点击 = 右栏预览,动作只剩下载) */
-.deliverable-card { display: flex; align-items: center; gap: 12px; width: fit-content; max-width: 100%; padding: 10px 12px 10px 10px; margin: 6px 0 2px;
- background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-lg); cursor: pointer; user-select: none;
- transition: border-color .12s, background .12s; }
-.deliverable-card:hover { border-color: var(--accent); background: var(--bg-hover); }
+/* 交付物卡片:主区域与「预览」打开预览区,下载收在菜单里。 */
+.deliverable-card { position: relative; display: flex; align-items: center; gap: 8px; width: fit-content; max-width: 100%; padding: 8px 8px 8px 8px; margin: 6px 0 2px;
+ background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-lg); }
+.deliverable-card:hover { border-color: var(--accent); }
+.deliverable-card .dc-open { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 0; border: none; background: none; color: inherit; font: inherit; cursor: pointer; text-align: left; }
.deliverable-card .dc-icon { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; border-radius: var(--radius); flex: none;
background: var(--bg-inset); color: var(--accent); border: 1px solid var(--border-soft); }
.deliverable-card .dc-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.deliverable-card .dc-name { color: var(--text); font-weight: 600; font-size: var(--fs-ui-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 380px; }
.deliverable-card .dc-meta { color: var(--text-faint); font-size: var(--fs-ui-sm); white-space: nowrap; }
-.deliverable-card .dc-act { flex: none; margin-left: auto; padding: 5px 12px; border-radius: var(--radius-sm); color: var(--accent);
- font-size: var(--fs-ui-caption); border: 1px solid var(--border); background: var(--bg-panel); }
-.deliverable-card:hover .dc-act { border-color: var(--accent); }
-.deliverable-card .dc-dl { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-sm);
- color: var(--text-dim); border: 1px solid var(--border-soft); }
-.deliverable-card .dc-dl:hover { color: var(--accent); border-color: var(--accent); background: var(--bg-hover); }
+.deliverable-card .dc-act { flex: none; margin-left: 8px; padding: 5px 12px; border-radius: var(--radius-sm); color: var(--accent);
+ font-size: var(--fs-ui-caption); border: 1px solid var(--border); background: var(--bg-panel); cursor: pointer; }
+.deliverable-card .dc-act:hover:not(:disabled) { border-color: var(--accent); }
+.deliverable-card .dc-act:disabled { opacity: .45; cursor: default; }
+.deliverable-card .dc-more { flex: none; width: 28px; height: 28px; border: none; border-radius: var(--radius-sm); background: none; color: var(--text-dim); cursor: pointer; }
+.deliverable-card .dc-more:hover { background: var(--bg-hover); color: var(--text); }
+.deliverable-card .dc-menu { position: absolute; right: 8px; top: calc(100% - 4px); z-index: 2; padding: 6px 12px; border-radius: var(--radius-sm);
+ background: var(--bg-panel); border: 1px solid var(--border); box-shadow: var(--shadow-md); color: var(--text); font-size: var(--fs-ui-sm); text-decoration: none; }
+.missing-art { color: var(--text-faint); border-bottom: 1px dashed var(--border-strong); }
+
+/* 工作时间线 */
+.tl-tools { margin: 4px 0; }
+.tl-tools-head { font-size: var(--fs-ui-sm); color: var(--text-dim); }
+.tl-tools-list { margin: 2px 0 0; padding: 0 0 0 16px; color: var(--text-faint); font-size: var(--fs-ui-sm); }
+.tl-tools-list li { margin: 1px 0; }
+.tl-tools-name { color: var(--text-dim); }
+.tl-tools-sum { margin-left: 8px; color: var(--text-faint); }
+.tl-tools .status-error { margin-left: 8px; color: var(--err); }
+
+/* 对话右侧预览区 */
+.preview-slot { flex-shrink: 0; min-width: 0; display: flex; }
+.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; }
+.preview-pane-bar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border-soft); }
+.preview-pane-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-ui-sm); color: var(--text-dim); }
+.preview-pane-spacer { flex: 1; }
+.preview-pane-body { flex: 1; min-height: 0; overflow: auto; }
+.preview-pane-body .ui-fp { height: 100%; }
+.preview-pane-body .ui-fp-pdf { height: 100%; min-height: 480px; border: none; }
+.docx-wrapper { background: var(--bg-inset) !important; padding: 12px; }
+.docx-wrapper > section.docx { background: var(--bg) !important; color: var(--text); box-shadow: var(--shadow-md); }
/* 过程段内联组(流式/关闭自动折叠时,按原位渲染的过程容器) */
.part-group { margin: 2px 0; }
@@ -438,11 +462,6 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.msg.user .msg-role { align-items: center; }
.ad-user-cell { display: flex; align-items: center; gap: 8px; }
-/* ---- 右栏轻预览(裁定 D4:右栏 + 弹窗双落位)---- */
-.rail-preview-head { display: flex; align-items: center; gap: 6px; padding: 0 2px 8px; border-bottom: 1px solid var(--border-soft); margin-bottom: 8px; }
-.rail-preview-head .rp-name { font-size: var(--fs-ui-caption); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 55%; }
-.tab-body[data-rail-preview] { display: flex; flex-direction: column; }
-.tab-body[data-rail-preview] .ui-fp { flex: 1; }
.artifact-chip .ac-act { border: none; background: none; }
/* 项目收件箱(CF3/D51):会话/项目范围分段控件(令牌对齐 DESIGN.md,阶段④合入主线时归一) */
diff --git a/src/toolkind.ts b/src/toolkind.ts
new file mode 100644
index 0000000..d16ef5e
--- /dev/null
+++ b/src/toolkind.ts
@@ -0,0 +1,146 @@
+// 工具调用按类别合并成时间线的一行。新增类别必须补进 ToolKind 与两处 switch,
+// 否则 never 检查会在编译期失败。
+import type { Part } from './contracts'
+
+export type ToolKind = 'read' | 'search' | 'exec' | 'write' | 'card' | 'delegate' | 'plan' | 'other'
+
+const READ = new Set(['fs_read_file', 'fs_list_dir', 'read_conversation', 'list_conversations'])
+const SEARCH = new Set(['knowledge_search', 'tavily_search', 'web_search', 'history_search'])
+const EXEC = new Set(['run_shell', 'run_python'])
+const WRITE = new Set(['write_artifact', 'register_artifact', 'fs_write_file'])
+const CARD = new Set(['submit_card', 'card_begin', 'card_append', 'card_commit'])
+const DELEGATE = new Set(['delegate_task', 'squad_orchestrate'])
+const PLAN = new Set(['update_plan', 'update_todos'])
+
+export function toolKind(name: string): ToolKind {
+ if (READ.has(name)) return 'read'
+ if (SEARCH.has(name)) return 'search'
+ if (EXEC.has(name)) return 'exec'
+ if (WRITE.has(name)) return 'write'
+ if (CARD.has(name)) return 'card'
+ if (DELEGATE.has(name)) return 'delegate'
+ if (PLAN.has(name)) return 'plan'
+ return 'other'
+}
+
+function argOf(raw: string | undefined, keys: string[]): string {
+ if (!raw) return ''
+ try {
+ const v = JSON.parse(raw) as Record
+ for (const k of keys) {
+ const x = v[k]
+ if (typeof x === 'string' && x.trim()) return x.trim()
+ }
+ } catch {
+ return ''
+ }
+ return ''
+}
+
+function firstLine(s: string): string {
+ const line = s.split('\n').map(x => x.trim()).find(Boolean) ?? ''
+ return line.length > 80 ? `${line.slice(0, 80)}…` : line
+}
+
+/** 从工具参数里取出给用户看的一行:路径、查询词或脚本首行。 */
+export function toolHeadline(name: string, args?: string): string {
+ const kind = toolKind(name)
+ switch (kind) {
+ case 'read': return argOf(args, ['path', 'file', 'conversationId']) || name
+ case 'search': return argOf(args, ['query', 'q']) || name
+ case 'exec': return firstLine(argOf(args, ['script', 'command', 'cmd'])) || name
+ case 'write': return argOf(args, ['path', 'name', 'filename']) || name
+ case 'card': return argOf(args, ['title']) || name
+ case 'delegate': return argOf(args, ['task', 'goal', 'name']) || name
+ case 'plan': return argOf(args, ['title']) || name
+ case 'other': return name
+ default: {
+ const exhaustive: never = kind
+ return exhaustive
+ }
+ }
+}
+
+export function toolGroupLabel(kind: ToolKind, count: number, running: boolean): string {
+ switch (kind) {
+ case 'read': return `查阅 · ${count} 个文件`
+ case 'search': return `搜索 · ${count} 次`
+ case 'exec': return running ? `运行命令 · ${count} 条` : `已运行命令 · ${count} 条`
+ case 'write': return `写入文件 · ${count} 个`
+ case 'card': return `卡片 · ${count} 张`
+ case 'delegate': return `委派 · ${count} 个任务`
+ case 'plan': return `更新计划 · ${count} 次`
+ case 'other': return `工具 · ${count} 次`
+ default: {
+ const exhaustive: never = kind
+ return exhaustive
+ }
+ }
+}
+
+export interface ToolGroupItem {
+ name: string
+ headline: string
+ status?: 'ok' | 'error'
+ summary?: string
+ running: boolean
+}
+
+export type TimelineEntry =
+ | { kind: 'thinking'; text: string; durationMs?: number; active: boolean }
+ | { kind: 'text'; text: string }
+ | { kind: 'tools'; toolKind: ToolKind; items: ToolGroupItem[] }
+ | { kind: 'other'; part: Part }
+
+/** 把时间线部件收成行:相邻同类工具合并,思考是否仍在进行由 live 与后续部件决定。 */
+export function timelineEntries(parts: Part[], live: boolean): TimelineEntry[] {
+ const out: TimelineEntry[] = []
+ let i = 0
+ while (i < parts.length) {
+ const p = parts[i]
+ if (p.type === 'tool_call') {
+ const kind = toolKind(p.toolCall?.name ?? '')
+ const items: ToolGroupItem[] = []
+ while (i < parts.length && parts[i].type === 'tool_call' && toolKind(parts[i].toolCall?.name ?? '') === kind) {
+ const call = parts[i]
+ i++
+ let res: Part | undefined
+ if (parts[i]?.type === 'tool_result') {
+ res = parts[i]
+ i++
+ }
+ const status = res?.toolResult?.status
+ items.push({
+ name: call.toolCall?.name ?? '工具',
+ headline: toolHeadline(call.toolCall?.name ?? '', call.toolCall?.arguments),
+ status: status === 'ok' || status === 'error' ? status : undefined,
+ summary: res?.toolResult?.summary,
+ running: live && !res,
+ })
+ }
+ out.push({ kind: 'tools', toolKind: kind, items })
+ continue
+ }
+ if (p.type === 'tool_result') { i++; continue }
+ if (p.type === 'thinking') {
+ out.push({ kind: 'thinking', text: p.text ?? '', durationMs: p.durationMs, active: false })
+ i++
+ continue
+ }
+ if (p.type === 'text') {
+ out.push({ kind: 'text', text: p.text ?? '' })
+ i++
+ continue
+ }
+ out.push({ kind: 'other', part: p })
+ i++
+ }
+ if (live) {
+ const idx = out.reduce((acc, e, n) => (e.kind === 'thinking' ? n : acc), -1)
+ const entry = idx >= 0 ? out[idx] : undefined
+ if (entry && entry.kind === 'thinking' && out.slice(idx + 1).every(e => e.kind === 'thinking')) {
+ entry.active = true
+ }
+ }
+ return out
+}
diff --git a/test/live.test.ts b/test/live.test.ts
index 50332ec..283b805 100644
--- a/test/live.test.ts
+++ b/test/live.test.ts
@@ -14,12 +14,12 @@ function live(over: Partial = {}): LiveState {
// 两轮流式事件回放:思考→解说文本→工具→思考→最终回复(与后端事件序一致)。
function twoTurnItems(): LiveItem[] {
let items: LiveItem[] = []
- items = applyThinkingDelta(items, '先想想')
- items = applyThinkingDelta(items, '一下')
+ items = applyThinkingDelta(items, '先想想', 0)
+ items = applyThinkingDelta(items, '一下', 0)
items = applyTextDelta(items, 0, '我先更新待办。')
items = applyToolCall(items, 'update_todos', 'c1')
items = applyToolResult(items, 'c1', 'ok', '已更新')
- items = applyThinkingDelta(items, '再想想')
+ items = applyThinkingDelta(items, '再想想', 0)
items = applyTextDelta(items, 1, '最终')
items = applyTextDelta(items, 1, '回复')
return items
@@ -28,18 +28,18 @@ function twoTurnItems(): LiveItem[] {
describe('applyThinkingDelta / applyTextDelta', () => {
it('merges thinking deltas into the last thinking segment', () => {
let items: LiveItem[] = []
- items = applyThinkingDelta(items, '先')
- items = applyThinkingDelta(items, '想想')
- expect(items).toEqual([{ kind: 'thinking', text: '先想想' }])
+ items = applyThinkingDelta(items, '先', 0)
+ items = applyThinkingDelta(items, '想想', 0)
+ expect(items).toEqual([{ kind: 'thinking', text: '先想想', startedAt: 0, endedAt: 0 }])
})
it('opens a new thinking segment after text/tool (turn boundary)', () => {
- let items: LiveItem[] = applyThinkingDelta([], '第一轮思考')
+ let items: LiveItem[] = applyThinkingDelta([], '第一轮思考', 0)
items = applyTextDelta(items, 0, '解说')
- items = applyThinkingDelta(items, '第二轮思考')
+ items = applyThinkingDelta(items, '第二轮思考', 0)
expect(items).toEqual([
- { kind: 'thinking', text: '第一轮思考' },
+ { kind: 'thinking', text: '第一轮思考', startedAt: 0, endedAt: 0 },
{ kind: 'text', index: 0, text: '解说' },
- { kind: 'thinking', text: '第二轮思考' },
+ { kind: 'thinking', text: '第二轮思考', startedAt: 0, endedAt: 0 },
])
})
it('appends text to the last segment when index matches', () => {
@@ -115,7 +115,7 @@ describe('liveToMessage', () => {
})
it('thinking and tool parts are always process', () => {
let items: LiveItem[] = []
- items = applyThinkingDelta(items, '想')
+ items = applyThinkingDelta(items, '想', 0)
items = applyTextDelta(items, 0, '答')
items = applyToolCall(items, 'web_search')
items = applyToolResult(items, undefined, 'ok', '命中')
@@ -138,10 +138,16 @@ describe('liveToMessage', () => {
})
describe('showProcessInline', () => {
- it('expands during thinking/acting and collapses the moment responding starts', () => {
+ it('stays expanded for the whole live run, including the responding phase', () => {
expect(showProcessInline(live({ phase: 'thinking' }), true)).toBe(true)
expect(showProcessInline(live({ phase: 'acting' }), true)).toBe(true)
- expect(showProcessInline(live({ phase: 'responding' }), true)).toBe(false)
+ expect(showProcessInline(live({ phase: 'responding' }), true)).toBe(true)
+ })
+ it('records thinking duration from the first delta to the last', () => {
+ let items: LiveItem[] = applyThinkingDelta([], '先', 1000)
+ items = applyThinkingDelta(items, '想想', 2500)
+ const msg = liveToMessage(live({ items }))
+ expect(msg.parts[0]).toMatchObject({ type: 'thinking', text: '先想想', durationMs: 1500 })
})
it('falls back to the auto-collapse preference when no live run', () => {
expect(showProcessInline(null, true)).toBe(false)
diff --git a/test/messageparts.test.ts b/test/messageparts.test.ts
index 26a41bb..3098d0f 100644
--- a/test/messageparts.test.ts
+++ b/test/messageparts.test.ts
@@ -1,15 +1,13 @@
-// 消息部件顺序保真纯函数测试(渲染顺序 = 落库顺序)。
import { describe, it, expect } from 'vitest'
-import { groupParts, humanSize, artifactIcon } from '../src/messageparts'
+import { splitTimeline, humanSize, artifactIcon, artifactIdsIn, markMissingArtifacts, workedLabel } from '../src/messageparts'
import type { Part } from '../src/contracts'
function p(partial: Partial & { type: Part['type'] }): Part {
return partial as Part
}
-describe('groupParts(顺序保真)', () => {
- it('interleaves tool blocks at their original positions', () => {
- // 后端 llm.go 的真实落库序(交付物钉尾):text(过程解说) → tool → tool_result → text(终轮回复) → artifact_ref
+describe('splitTimeline', () => {
+ it('keeps everything up to the last process part in the timeline', () => {
const parts: Part[] = [
p({ type: 'text', text: '先找资料', process: true }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }),
@@ -17,45 +15,49 @@ describe('groupParts(顺序保真)', () => {
p({ type: 'text', text: '文档已生成,请查收' }),
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }),
]
- const blocks = groupParts(parts)
- expect(blocks.map(b => b.kind)).toEqual(['process', 'content', 'content'])
- expect((blocks[0] as { parts: Part[] }).parts.map(x => x.type)).toEqual(['text', 'tool_call', 'tool_result'])
- expect((blocks[1] as { part: Part }).part.text).toBe('文档已生成,请查收')
- expect((blocks[2] as { part: Part }).part.type).toBe('artifact_ref')
+ const split = splitTimeline(parts)
+ 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('renders deliverable cards as content in legacy inline order too', () => {
- // 旧落库序(artifact_ref 夹在过程与正文之间):仍是内容部件,原位渲染不折叠。
+ it('keeps a card sandwiched between process parts inside the timeline', () => {
const parts: Part[] = [
- p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }),
- p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }),
- p({ type: 'text', text: '文档已生成' }),
- ]
- const blocks = groupParts(parts)
- expect(blocks.map(b => b.kind)).toEqual(['process', 'content', 'content'])
- expect((blocks[1] as { part: Part }).part.type).toBe('artifact_ref')
- })
-
- it('merges only consecutive process parts into one block', () => {
- const parts: Part[] = [
- p({ type: 'thinking', process: true, text: '思考' }),
- p({ type: 'text', text: '第一段' }),
+ p({ type: 'thinking', process: true, text: '想' }),
+ p({ type: 'card', card: { kind: 'result', data: [] } }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'a' } }),
- p({ type: 'tool_result', process: true, toolResult: { callId: 'c1', status: 'ok' } }),
- p({ type: 'text', text: '第二段' }),
- p({ type: 'tool_call', process: true, toolCall: { id: 'c2', name: 'b' } }),
+ p({ type: 'text', text: '最终' }),
]
- const blocks = groupParts(parts)
- expect(blocks.map(b => b.kind)).toEqual(['process', 'content', 'process', 'content', 'process'])
- expect((blocks[2] as { parts: Part[] }).parts).toHaveLength(2)
- expect((blocks[4] as { parts: Part[] }).parts).toHaveLength(1)
+ const split = splitTimeline(parts)
+ expect(split.timeline.map(x => x.type)).toEqual(['thinking', 'card', 'tool_call'])
+ expect(split.final.map(x => x.text)).toEqual(['最终'])
})
- it('handles all-process and all-content messages', () => {
- expect(groupParts([p({ type: 'thinking', process: true, text: 'x' }), p({ type: 'tool_call', process: true, toolCall: { id: '', name: '' } })]))
- .toEqual([{ kind: 'process', parts: [expect.anything(), expect.anything()] }])
- expect(groupParts([p({ type: 'text', text: 'hi' })]).map(b => b.kind)).toEqual(['content'])
- expect(groupParts([])).toEqual([])
+ it('puts a message with no process parts entirely in the final section', () => {
+ const parts = [p({ type: 'text', text: 'hi' })]
+ expect(splitTimeline(parts).timeline).toEqual([])
+ expect(splitTimeline(parts).final).toEqual(parts)
+ expect(splitTimeline([])).toEqual({ timeline: [], final: [] })
+ })
+})
+
+describe('artifact ids in reply text', () => {
+ it('extracts unique ids in order', () => {
+ expect(artifactIdsIn('见 art_0123456789ab 和 `art_abcdefabcdef`,再提 art_0123456789ab'))
+ .toEqual(['art_0123456789ab', 'art_abcdefabcdef'])
+ })
+ it('marks only ids the project does not have', () => {
+ const text = '文件 art_0123456789ab 与 art_abcdefabcdef'
+ const out = markMissingArtifacts(text, id => id === 'art_0123456789ab')
+ expect(out).toContain('art_0123456789ab')
+ expect(out).not.toContain('art_abcdefabcdef')
+ expect(out).toContain('未找到该文件')
+ })
+})
+
+describe('workedLabel', () => {
+ it('formats the run span and degrades without timestamps', () => {
+ expect(workedLabel('2026-10-01T00:00:00.000Z', '2026-10-01T00:01:05.000Z')).toBe('已工作 1 分 5 秒')
+ expect(workedLabel(undefined, undefined)).toBe('已工作')
})
})
diff --git a/test/previewtabs.test.ts b/test/previewtabs.test.ts
new file mode 100644
index 0000000..6331c87
--- /dev/null
+++ b/test/previewtabs.test.ts
@@ -0,0 +1,24 @@
+import { describe, it, expect } from 'vitest'
+import { openPreviewTab, closePreviewTab } from '../src/previewtabs'
+
+describe('preview tabs', () => {
+ it('opens a new tab and re-activates an existing one without duplicating it', () => {
+ const first = openPreviewTab([], { id: 'a', name: '甲.docx' })
+ const second = openPreviewTab(first.tabs, { id: 'b', name: '乙.pptx' })
+ expect(second.tabs.map(t => t.id)).toEqual(['a', 'b'])
+ expect(second.activeId).toBe('b')
+ const again = openPreviewTab(second.tabs, { id: 'a', name: '甲.docx' })
+ expect(again.tabs).toHaveLength(2)
+ expect(again.activeId).toBe('a')
+ })
+
+ it('closing the active tab selects the neighbor, and closing another keeps the active tab', () => {
+ const tabs = [{ id: 'a', name: '甲' }, { id: 'b', name: '乙' }, { id: 'c', name: '丙' }]
+ const closedB = closePreviewTab(tabs, 'b', 'b')
+ expect(closedB.tabs.map(t => t.id)).toEqual(['a', 'c'])
+ expect(closedB.activeId).toBe('c')
+ const closedA = closePreviewTab(closedB.tabs, 'c', 'a')
+ expect(closedA.activeId).toBe('c')
+ expect(closePreviewTab([{ id: 'a', name: '甲' }], 'a', 'a')).toEqual({ tabs: [], activeId: null })
+ })
+})
diff --git a/test/toolkind.test.ts b/test/toolkind.test.ts
new file mode 100644
index 0000000..c0d5911
--- /dev/null
+++ b/test/toolkind.test.ts
@@ -0,0 +1,62 @@
+import { describe, it, expect } from 'vitest'
+import { toolKind, toolHeadline, toolGroupLabel, timelineEntries } from '../src/toolkind'
+import type { Part } from '../src/contracts'
+
+function p(partial: Partial & { type: Part['type'] }): Part {
+ return partial as Part
+}
+
+describe('toolKind', () => {
+ it('classifies known tools and falls back to other', () => {
+ expect(toolKind('fs_read_file')).toBe('read')
+ expect(toolKind('web_search')).toBe('search')
+ expect(toolKind('run_python')).toBe('exec')
+ expect(toolKind('register_artifact')).toBe('write')
+ expect(toolKind('submit_card')).toBe('card')
+ expect(toolKind('delegate_task')).toBe('delegate')
+ expect(toolKind('update_todos')).toBe('plan')
+ expect(toolKind('time_now')).toBe('other')
+ })
+
+ it('pulls a path, a query, or the first script line out of arguments', () => {
+ expect(toolHeadline('fs_read_file', '{"path":"notes/a.md"}')).toBe('notes/a.md')
+ expect(toolHeadline('web_search', '{"query":"会议冲突"}')).toBe('会议冲突')
+ expect(toolHeadline('run_python', '{"script":"print(1)\\nprint(2)"}')).toBe('print(1)')
+ expect(toolGroupLabel('read', 2, false)).toBe('查阅 · 2 个文件')
+ expect(toolGroupLabel('exec', 1, true)).toBe('运行命令 · 1 条')
+ })
+})
+
+describe('timelineEntries', () => {
+ it('merges consecutive tools of the same kind and leaves narration in place', () => {
+ const parts: Part[] = [
+ p({ type: 'thinking', process: true, text: '先看文件', durationMs: 1500 }),
+ p({ type: 'tool_call', process: true, toolCall: { id: 'a', name: 'fs_read_file', arguments: '{"path":"a.md"}' } }),
+ p({ type: 'tool_result', process: true, toolResult: { callId: 'a', status: 'ok', summary: '12 行' } }),
+ p({ type: 'tool_call', process: true, toolCall: { id: 'b', name: 'fs_read_file', arguments: '{"path":"b.md"}' } }),
+ p({ type: 'tool_result', process: true, toolResult: { callId: 'b', status: 'ok' } }),
+ p({ type: 'text', process: true, text: '两份都读完了' }),
+ p({ type: 'tool_call', process: true, toolCall: { id: 'c', name: 'run_python', arguments: '{"script":"print(1)"}' } }),
+ ]
+ const rows = timelineEntries(parts, true)
+ expect(rows.map(r => r.kind)).toEqual(['thinking', 'tools', 'text', 'tools'])
+ const reads = rows[1]
+ if (reads.kind !== 'tools') throw new Error('expected tool group')
+ expect(reads.toolKind).toBe('read')
+ expect(reads.items.map(it => it.headline)).toEqual(['a.md', 'b.md'])
+ expect(reads.items[0].summary).toBe('12 行')
+ const thinking = rows[0]
+ if (thinking.kind !== 'thinking') throw new Error('expected thinking')
+ expect(thinking.active).toBe(false)
+ const exec = rows[3]
+ if (exec.kind !== 'tools') throw new Error('expected exec group')
+ expect(exec.items[0].running).toBe(true)
+ })
+
+ it('marks the trailing thinking row active while the run is live', () => {
+ const rows = timelineEntries([p({ type: 'thinking', process: true, text: '还在想' })], true)
+ expect(rows[0]).toMatchObject({ kind: 'thinking', active: true })
+ expect(timelineEntries([p({ type: 'thinking', process: true, text: '想完了' })], false)[0])
+ .toMatchObject({ kind: 'thinking', active: false })
+ })
+})
diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo
index 425165d..ac11937 100644
--- a/tsconfig.tsbuildinfo
+++ b/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/live.ts","./src/main.tsx","./src/messageparts.ts","./src/passwords.ts","./src/previewsetup.ts","./src/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.tsx","./src/components/AdminZone.tsx","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/ProjectSettingsModal.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./src/components/adminSections.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/design-guard.test.ts","./test/live.test.ts","./test/messageparts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"}
\ No newline at end of file
+{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/live.ts","./src/main.tsx","./src/messageparts.ts","./src/outbox.ts","./src/passwords.ts","./src/previewsetup.ts","./src/previewtabs.ts","./src/store.ts","./src/tasklist.ts","./src/toolkind.ts","./src/components/AdminUsersModal.tsx","./src/components/AdminZone.tsx","./src/components/AgentTracker.tsx","./src/components/ArtifactsPanel.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/PreviewPane.tsx","./src/components/ProcessTimeline.tsx","./src/components/ProjectSettingsModal.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./src/components/adminSections.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/design-guard.test.ts","./test/live.test.ts","./test/messageparts.test.ts","./test/outbox.test.ts","./test/previewtabs.test.ts","./test/tasklist.test.ts","./test/toolkind.test.ts"],"version":"5.9.3"}
\ No newline at end of file