Compare commits

...
Author SHA1 Message Date
weichen 2b87a9c081 Merge remote-tracking branch 'origin/main' into feature/base-extension-roadmap 2026-10-01 10:46:55 +00:00
weichenandCursor 22f7d84560 Load plugin ESM pages and record command-pair checks
Why: Plugin pages must mount through a host bridge, and button versus chat command parity needs a check that vitest does not execute as a unit test.
Evidence: npx vitest run passed. The Playwright file lives under e2e and is outside the tsconfig include.
Impact: loadPluginPage expects mount/unmount. @playwright/test is not a required dependency yet.
Refs: docs/proposals/2026-10-01-plugin-contract-v2.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 10:34:36 +00:00
weichen e1fd10ec3d Merge pull request 'Show work as a timeline and preview files in tabs' (#30) from feature/chat-timeline-preview into main
Merged by the timeline task after the owner asked to land the open pull requests.
2026-10-01 07:40:52 +00:00
weichenandCursor 0ed8ad32f1 Show assistant work as a timeline and preview files in tabs
Why: Process output folded on every text delta, successful tool
results disappeared after refresh, and generated files stayed plain
text instead of a card that opens a closable preview.

Evidence: npx tsc -b --noEmit (clean); npx vitest run (51 passed).

Impact: Live work stays expanded until the run ends, then folds to
one worked-time line. File cards open a per-conversation preview pane.
docx renders page by page, with mammoth if that fails.

Refs: feature/chat-timeline-preview
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 06:15:44 +00:00
weichen 59065fecb8 Merge pull request 'Create a default project when the first task has none' (#29) from fix/empty-create-task into main 2026-10-01 05:28:40 +00:00
weichen 0cc0f40425 Merge remote-tracking branch 'origin/main' into fix/empty-create-task 2026-10-01 05:28:09 +00:00
weichen ba52fad402 Merge pull request 'Queue follow-up messages and pin files in the right rail' (#28) from feature/input-queue into main 2026-10-01 05:26:28 +00:00
weichen aad5871498 Merge remote-tracking branch 'origin/main' into feature/input-queue 2026-10-01 05:26:08 +00:00
weichen 53d38c8ccb Merge pull request 'Preview fallback for fake docx artifacts and readable Office errors' (#22) from feature/preview-sniff-fallback into main 2026-10-01 05:20:16 +00:00
weichenandCursor 288d3aca07 Create a default project when the first task has none
Why: On an empty workspace the primary button and the empty state both say to start by creating a task, but the action only toasted that a project was required.
Evidence: A fresh embedded instance returns an empty project list, so the first click never opened a conversation.
Impact: The first task now creates 「默认项目」 and opens it. Existing projects are unchanged.
Refs: walkthrough of the empty workspace on main
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 04:47:02 +00:00
weichenandCursor 3c815250a0 Queue follow-up messages and pin files in the right rail
Why: a busy conversation rejected further input, and the file list lived
only behind the workspace overlay.

Evidence: npx tsc --noEmit and vitest 42 tests, including outbox
append/edit/promote. The queue uses Collapsible and design tokens.
The files tab reuses ArtifactsPanel.

Impact: while a run is active, Enter queues the draft (edit, delete, or
send-now). Finished and failed runs drain the queue. HTTP 409 also
enqueues instead of only toasting. Backend serialization is unchanged.

Refs: task feature/input-queue
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 04:31:50 +00:00
weichenandCursor bfa634d2a9 Fall back to text when xlsx and pptx are not ZIP files
Why: docx preview already sniffs ZIP magic so a text file renamed to
.docx stays readable. xlsx and pptx loaders still fed those bytes to
the parser and surfaced a raw library error.

Evidence: npx tsc --noEmit and npx vitest run (40 tests) on this branch
after merging origin/main. Legacy .xls/.ppt skip the sniff because they
are not OOXML.

Impact: pseudo xlsx/pptx render as text with a short note. Real ZIP
workbooks and decks still use the existing parsers.

Refs: Dev PR #96, task feature/preview-sniff-fallback
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 04:07:05 +00:00
weichen b569d8d7b3 Merge remote-tracking branch 'origin/main' into feature/preview-sniff-fallback 2026-10-01 04:06:05 +00:00
weichen c17ac2413f Merge pull request 'Make the final reply always visible and fix artifacts-panel dead clicks' (#27) from feature/agent-native-deliverables-r1 into main 2026-10-01 03:34:16 +00:00
weichen 7089f768fd Make the final reply always visible and fix artifacts-panel dead clicks
Why: field report two — the final answer collapsed into the process
area (the CF0 long-reply fold swallowed 2000+ char replies and the
model inlines narration, so users saw "everything folded") and the
artifacts workspace panel preview clicks did nothing (the full-screen
overlay stayed up, hiding the rail the preview opened in).

Evidence:
- Conversation.tsx: removed the CF0 LongReplyCollapse entirely — the
  final output contract is "the reply to the user is always fully
  visible; only process parts fold"; deliverable cards stay pinned
  after the reply
- Workspaces.tsx ArtifactsPanel: row click and fullscreen action now
  close the workspace overlay before requesting the rail/fullscreen
  preview, so the preview is actually on screen
- vitest 40/40, tsc clean; no design-guard regressions (no new colors,
  keyframes, or inline font sizes)

Impact: replies are readable in place; ⌘1 panel clicks visibly open
previews; no other surfaces changed.

Refs: task feature/agent-native-deliverables-r1 (backend: artifact
backfill root fix + agent-native deliverables)
2026-10-01 03:32:55 +00:00
weichen de38791e25 Merge pull request 'Ignore the mimosa scan-hook local cache directory' (#26) from chore/ignore-mimosa-cache into main 2026-10-01 02:54:15 +00:00
weichen f054187bb0 Ignore the mimosa scan-hook local cache directory
Why: the pre-commit/push mimosa hooks write .mimosa/hook-state inside
the work tree; untracked it marks the Dev superproject dirty
(precheck SHARED_WORKSPACE_BUSY) and blocks card-close gates.

Evidence: git status showed ?? .mimosa after commits; with the ignore
the superproject returns clean. No tracked files affected.

Impact: none at runtime; cache stays local.

Refs: task feature/deliverable-docx-r1 follow-up
2026-10-01 02:54:08 +00:00
weichen b048d90de4 Merge pull request 'Render chat deliverable cards and pin live artifact projection' (#25) from feature/deliverable-docx-r1 into main 2026-10-01 02:50:19 +00:00
weichen cd77340d92 Sniff docx ZIP magic and fall back to text rendering
Why: agent-generated ".docx" artifacts were historically plain text bytes
written via string-only write tools; the docx preview loader fed them
straight to mammoth/JSZip, which threw "Can't find end of central directory"
on the meeting-notes docx from the review session.

Evidence: docxLoader now checks the PK\x03\x04 (or empty-archive PK\x05\x06)
magic before mammoth; non-ZIP bytes render as markdown with a readable note
instead of an error. vitest 33/33 pass, tsc --noEmit clean.

Impact: stock fake-docx artifacts become readable immediately; real docx
previews unchanged. Rendering only, no API/contract change.

Refs: .task/feature-preview-sniff-fallback.md
2026-09-30 17:51:14 +00:00
27 changed files with 1034 additions and 347 deletions
+3
View File
@@ -2,3 +2,6 @@
node_modules
dist/
*.local
# mimosa 扫描钩子的本地缓存(scripts/hooks 写入,不入库)
.mimosa
+10
View File
@@ -0,0 +1,10 @@
// 双入口对拍的浏览器侧说明。CI 门禁在 backend command_pairs_test.go:
// 新内置命令未写入 testdata/command-pairs.json 时 go test 失败。
// 安装 Playwright 后可用 npx playwright test e2e/command-pairs.spec.ts 做人工抽查。
import { test, expect } from '@playwright/test'
test('command menu and chat share builtin.help', async ({ page }) => {
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to run')
await page.goto(process.env.PLAYWRIGHT_BASE_URL!)
await expect(page.getByText('帮助')).toBeVisible()
})
+10
View File
@@ -8,6 +8,7 @@
"name": "agent-system-frontend",
"version": "0.1.0",
"dependencies": {
"docx-preview": "^0.4.1",
"jszip": "^3.10.2",
"mammoth": "^1.13.0",
"marked": "^15.0.7",
@@ -1670,6 +1671,15 @@
"integrity": "sha512-R0bRerYzy/EZP6QzD3Hkl6JjUDA1Mnn+pd928w0nWjPzihnbAR6dcjGJGiRyznj5E4S1duEKzWrTqw02DPcdzg==",
"license": "BSD-2-Clause"
},
"node_modules/docx-preview": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.4.1.tgz",
"integrity": "sha512-Dv4g+LeE0swHpPvNC0lAT0YsR5Zq5pAxw7d3vHQ8EChBLgIrqqe9VPL5nVt2HBJap8bEZbXUI0TJob7kBeNU9w==",
"license": "Apache-2.0",
"dependencies": {
"jszip": ">=3.0.0"
}
},
"node_modules/duck": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
+1
View File
@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"docx-preview": "^0.4.1",
"jszip": "^3.10.2",
"mammoth": "^1.13.0",
"marked": "^15.0.7",
+24 -4
View File
@@ -8,6 +8,7 @@ import { Icon } from '@ui'
import { store, useStore } from './store'
import { TaskSidebar } from './components/TaskSidebar'
import { Conversation } from './components/Conversation'
import { PreviewPane } from './components/PreviewPane'
import { Workbench } from './components/Workbench'
import { Workspaces, workspaceShortcuts } from './components/Workspaces'
import { AgentTracker } from './components/AgentTracker'
@@ -15,9 +16,10 @@ import { SettingsModal } from './components/SettingsModal'
import { ProjectSettingsModal } from './components/ProjectSettingsModal'
import { DocViewer } from './components/DocViewer'
// 三栏布局:左(项目会话)/ 中(对话)/ 右(工作台);分隔条拖拽与超调收起(4.4)。
// 布局:左(项目会话)/ 中(对话)/ 预览区(有标签时)/ 右(工作台)。
const LEFT_MIN = 220, LEFT_MAX = 420, LEFT_OVERSHOOT = 40
const RIGHT_MIN = 280, RIGHT_MAX = 560
const PREVIEW_MIN = 420, PREVIEW_MAX = 1100
export function App() {
useEffect(() => {
@@ -27,6 +29,10 @@ export function App() {
}, [])
const ui = useStore(s => s.ui)
const previewOpen = useStore(s => {
const id = s.currentConvId
return id ? (s.previewTabs[id]?.length ?? 0) > 0 : false
})
const toast = useStore(s => s.toast)
const trackerRunId = useStore(s => s.ui.trackerRunId)
const viewingArtifact = useStore(s => s.ui.viewingArtifact)
@@ -57,11 +63,12 @@ export function App() {
store.refreshPlugins()
}, [authState])
const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean } | null>(null)
const drag = useRef<{ side: 'left' | 'right' | 'preview'; startX: number; startW: number; overshoot: boolean } | null>(null)
const onDividerDown = (side: 'left' | 'right') => (e: React.MouseEvent) => {
const onDividerDown = (side: 'left' | 'right' | 'preview') => (e: React.MouseEvent) => {
e.preventDefault()
drag.current = { side, startX: e.clientX, startW: side === 'left' ? ui.leftWidth : ui.rightWidth, overshoot: false }
const startW = side === 'left' ? ui.leftWidth : side === 'preview' ? ui.previewWidth : ui.rightWidth
drag.current = { side, startX: e.clientX, startW, overshoot: false }
const move = (ev: MouseEvent) => {
const d = drag.current
if (!d) return
@@ -70,6 +77,10 @@ export function App() {
if (w < LEFT_MIN - LEFT_OVERSHOOT) { d.overshoot = true; return }
d.overshoot = false
store.setUI({ leftCollapsed: false, leftWidth: Math.min(LEFT_MAX, Math.max(LEFT_MIN, w)) })
} else if (d.side === 'preview') {
const w = d.startW - (ev.clientX - d.startX)
if (w < PREVIEW_MIN) return
store.setUI({ previewWidth: Math.min(PREVIEW_MAX, w) })
} else {
const w = d.startW - (ev.clientX - d.startX)
if (w < RIGHT_MIN) return
@@ -128,6 +139,15 @@ export function App() {
<Conversation />
</div>
{previewOpen && (
<>
<div className="divider" onMouseDown={onDividerDown('preview')} title="拖动调整预览区宽度" />
<div className="preview-slot" style={{ width: ui.previewWidth }}>
<PreviewPane />
</div>
</>
)}
{!ui.rightCollapsed ? (
<>
<div className="divider" onMouseDown={onDividerDown('right')} onDoubleClick={store.toggleRight} title="拖动调整宽度;双击收起" />
+67
View File
@@ -0,0 +1,67 @@
// 工件面板(工作区文件管理):搜索 + kind 过滤 + 上传;行内打开预览区或下载。
// ⌘1 弹层与右栏「文件」页签共用这一份。删除仍是保留能力,系统默认不提供入口。
import { useState } from 'react'
import { store, useStore } from '../store'
import { api } from '../api'
import { Icon } from '@ui'
import { artifactIcon, humanSize } from '../messageparts'
export function ArtifactsPanel() {
const artifacts = useStore(s => s.artifacts)
const [q, setQ] = useState('')
const [kind, setKind] = useState<'all' | 'document' | 'data' | 'image'>('all')
const kinds = ['all', 'document', 'data', 'image'] as const
const visible = artifacts.filter(a =>
(kind === 'all' || a.kind === kind) && a.name.toLowerCase().includes(q.toLowerCase()))
return (
<div className="artifacts-panel">
<div className="ap-toolbar">
<input className="search-input" placeholder="搜索文件名…" value={q} onChange={e => setQ(e.target.value)} />
{kinds.map(k => (
<button key={k} className={`kind-pill ${kind === k ? 'on' : ''}`} onClick={() => setKind(k)}>
{k === 'all' ? '全部' : k === 'document' ? '文档' : k === 'data' ? '数据' : '图片'}
</button>
))}
<label className="btn ghost ap-upload" title="上传文件到当前项目">
<Icon name="paperclip" size={14} /> 上传
<input type="file" multiple style={{ display: 'none' }} onChange={async e => {
for (const f of Array.from(e.target.files ?? [])) await store.uploadAttachment(f)
e.target.value = ''
}} />
</label>
</div>
{artifacts.length === 0
? <div className="empty-note">暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载</div>
: visible.length === 0
? <div className="empty-note">没有匹配「{q}」的文件</div>
: visible.map(a => (
// 预览落在对话右侧预览区。弹层盖住主区,先收起再打开。
<div key={a.id} className="artifact-row" data-artifact-row={a.id}
onClick={() => { store.closeWorkspace(); store.requestPreview(a) }} title={`预览 ${a.name}`}>
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
<span className="ar-main">
<span className="name">{a.name}</span>
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
</span>
<span className="ar-acts">
<button className="icon-btn" title="全屏预览" onClick={e => { e.stopPropagation(); store.closeWorkspace(); store.requestPreview(a, 'full') }}><Icon name="eye" size={14} /></button>
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
</span>
</div>
))}
</div>
)
}
function shortTime(iso: string): string {
try {
const d = new Date(iso)
const now = new Date()
return d.toDateString() === now.toDateString()
? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: d.toLocaleDateString([], { month: '2-digit', day: '2-digit' })
} catch { return '' }
}
+122 -117
View File
@@ -1,16 +1,17 @@
// 中栏:消息流(Part 渲染器注册表)+ 流式渲染 + 过程折叠 + HITL 卡片 + 输入区(4.4)。
// 重设计方案 阶段④:折叠区全部迁 @ui Collapsible(<details> 已禁);工具调用块走
// ToolBlock 契约(运行态扫光、完成边沿收起、展开态记忆);live 行扫光 + 已工作计时。
import { Icon, Avatar, BrandMark, Collapsible, ToolBlock } from '@ui'
import { Icon, Avatar, BrandMark, Collapsible } from '@ui'
import { useEffect, useMemo, useRef, useState } from 'react'
import { marked } from 'marked'
import { api } from '../api'
import { store, useStore, activeRun, type LiveState, type ContextObs } from '../store'
import { liveToMessage, showProcessInline } from '../live'
import type { Message, Part, HITLCard, ActivityCard } from '../contracts'
import type { Message, Part, HITLCard, ActivityCard, Artifact } from '../contracts'
import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui'
import { docBridge } from './Workbench'
import { groupParts, artifactIcon, humanSize } from '../messageparts'
import { splitTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, workedLabel } from '../messageparts'
import { ProcessTimeline } from './ProcessTimeline'
// marked 走标准 Markdown 语义(不开 breaks):模型输出按块级排版渲染,
// 行内换行交给段落折叠,避免与 CSS 块间距叠加出松散排版。
@@ -20,7 +21,7 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
switch (part.type) {
case 'text':
return <div className={live ? 'part-text cursor-blink' : 'part-text'}
dangerouslySetInnerHTML={{ __html: marked.parse(part.text ?? '') }} />
dangerouslySetInnerHTML={{ __html: marked.parse(markMissingArtifacts(part.text ?? '', id => !!findArtifact(id))) as string }} />
case 'thinking':
// 流式期间展开可实时看到思考;run 终态后收进过程折叠区,默认收起。
//(此单件形态用于过程组之外散落的思考;组内思考由 ProcessParts 渲染。)
@@ -33,29 +34,8 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
{part.toolCall?.name ?? part.toolResult?.callId} <span className={`status-${st}`}>{st === 'ok' ? '✓' : '✗'}</span>
</span>
}
case 'artifact_ref': {
const a = part.artifact
const art = a ? findArtifact(a.artifactId) : undefined
const name = a?.name ?? '工件'
const icon = artifactIcon(name, a?.kind ?? art?.kind)
const size = humanSize(art?.size)
// 交付物卡片(对话内直接给出):整卡点击 = 右栏预览(唯一落位,全屏从
// 右栏放大);动作只保留 下载 一个——与工件面板/预览器同一动作集。
return (
<div className="deliverable-card mk-rise-in" data-artifact-chip={a?.artifactId}
title={`预览 ${name}(右栏,边聊边看)`}
onClick={() => { if (art) store.requestPreview(art) }}>
<span className="dc-icon"><Icon name={icon} size={18} /></span>
<span className="dc-main">
<span className="dc-name">{name}</span>
<span className="dc-meta">{kindLabelOf(a?.kind ?? art?.kind)}{size ? ` · ${size}` : ''}{art ? '' : ' · 生成中'}</span>
</span>
<span className="dc-act">预览</span>
<a className="dc-dl" title={`下载 ${name}`} href={api.artifactDownloadUrl(a?.artifactId ?? '')}
download={name} onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
</div>
)
}
case 'artifact_ref':
return part.artifact ? <DeliverableCard artifact={part.artifact} /> : null
case 'hitl':
return <div className="tool-chip"><Icon name="message" size={12} /> {part.hitl?.question} → {part.hitl?.answer || '…'}</div>
case 'plan':
@@ -119,73 +99,48 @@ function ThinkingBlock({ part, idKey, defaultOpen }: { part: Part; idKey: string
)
}
// 过程段渲染(配对增强):tool_call 吸收紧随其后的同名 tool_result 组成 ToolBlock
//(运行态扫光、完成边沿收起、展开看结果摘要);其余部件原位 PartView。
function ProcessParts({ parts, live, idKey }: { parts: Part[]; live: boolean; idKey: string }) {
const out: React.ReactNode[] = []
for (let i = 0; i < parts.length; i++) {
const p = parts[i]
if (p.type === 'tool_call') {
const res = parts[i + 1]?.type === 'tool_result' ? parts[i + 1] : null
if (res) i++
const st = res?.toolResult?.status
out.push(
<ToolBlock key={i} toolId={`${idKey}:${i}`} name={`🛠 ${p.toolCall?.name ?? '工具'}`}
running={live && !res} status={st === 'ok' ? 'ok' : st ? 'error' : undefined}
statusDetail={res?.toolResult?.summary}
autoCollapseOnComplete>
{res && <div className="part-text">{res.toolResult?.summary}</div>}
</ToolBlock>)
continue
// 交付物卡片:主区域和「预览」打开预览区;下载收进菜单,避免和预览抢同一个按钮。
function DeliverableCard({ artifact }: { artifact: { artifactId: string; name: string; kind?: string } }) {
const [menu, setMenu] = useState(false)
const art = findArtifact(artifact.artifactId)
const name = artifact.name || art?.name || '文件'
const icon = artifactIcon(name, artifact.kind ?? art?.kind)
const size = humanSize(art?.size)
const open = () => { if (art) store.requestPreview(art) }
return (
<div className="deliverable-card mk-rise-in" data-artifact-chip={artifact.artifactId}>
<button type="button" className="dc-open" title={`预览 ${name}`} onClick={open}>
<span className="dc-icon"><Icon name={icon} size={18} /></span>
<span className="dc-main">
<span className="dc-name">{name}</span>
<span className="dc-meta">{kindLabelOf(artifact.kind ?? art?.kind)}{size ? ` · ${size}` : ''}{art ? '' : ' · 生成中'}</span>
</span>
</button>
<button type="button" className="dc-act" onClick={open} disabled={!art}>预览</button>
<button type="button" className="dc-more" aria-label={`更多 ${name}`} aria-expanded={menu}
onClick={() => setMenu(v => !v)}><Icon name="more" size={14} /></button>
{menu && (
<a className="dc-menu" href={api.artifactDownloadUrl(artifact.artifactId)} download={name}
onClick={() => setMenu(false)}>下载</a>
)}
</div>
)
}
function citedArtifactCards(parts: Part[]): Artifact[] {
const seen = new Set(parts.filter(p => p.type === 'artifact_ref').map(p => p.artifact?.artifactId).filter((id): id is string => !!id))
const out: Artifact[] = []
for (const p of parts) {
if (p.type !== 'text' || !p.text) continue
for (const id of artifactIdsIn(p.text)) {
if (seen.has(id)) continue
const art = findArtifact(id)
if (!art) continue
seen.add(id)
out.push(art)
}
out.push(<div className="part" key={i}><PartView part={p} live={live && p.type === 'text'} /></div>)
}
return <>{out}</>
}
// 过程折叠:run 终态后把连续 process 部件折叠为摘要行(可展开回看;Collapsible 契约)。
function ProcessCollapse({ parts, idKey }: { parts: Part[]; idKey: string }) {
const toolCount = parts.filter(p => p.type === 'tool_call').length
const hasThinking = parts.some(p => p.type === 'thinking' && p.text)
return (
<div className="process-collapse">
<Collapsible id={`process:${idKey}`} triggerClassName="process-collapse-summary" unmountDelay={300}
trigger={<>
<span className="process-collapse-chev">▸</span>
<span>过程输出</span>
<span className="process-collapse-meta">
{hasThinking ? '含思考' : ''}{toolCount > 0 ? ` · ${toolCount} 次工具调用` : ''}
</span>
</>}>
<div className="body">
<ProcessParts parts={parts} live={false} idKey={idKey} />
</div>
</Collapsible>
</div>
)
}
// 长回复折叠阈值(CF0):终态后超长正文收成摘要行,点击展开全文;live 期间全文可见。
const LONG_REPLY_CHARS = 800
function LongReplyCollapse({ text, idKey }: { text: string; idKey: string }) {
// 摘句取压缩空白后的前 100 字;字数按 Markdown 源码长度计。
const excerpt = text.replace(/\s+/g, ' ').trim().slice(0, 100)
return (
<div className="process-collapse long-reply">
<Collapsible id={`long:${idKey}`} triggerClassName="process-collapse-summary" unmountDelay={300}
trigger={<>
<span className="process-collapse-chev">▸</span>
<span>📄 长回复</span>
<span className="process-collapse-meta">{text.length} 字(按 Markdown 源码计)</span>
{excerpt && <span className="process-collapse-excerpt">{excerpt}…</span>}
</>}>
<div className="body">
<div className="part-text" dangerouslySetInnerHTML={{ __html: marked.parse(text) }} />
</div>
</Collapsible>
</div>
)
return out
}
// 已工作计时(ZCode「已工作 X 分 X 秒」摘要行;1s 步进,仅 live 期间挂载)。
@@ -215,19 +170,20 @@ function LiveBar({ live }: { live: LiveState }) {
function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null }) {
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 parts = msg.parts ?? []
// 顺序保真(渲染顺序 = 落库顺序):连续过程部件就地聚块,内容部件原位保留——
// 工具调用显示在它真正发生的位置,不被抽到消息头部重排。
const blocks = useMemo(() => groupParts(parts), [parts])
// 流式期间:工具/思考阶段过程实时展开;一旦进入 responding(最终回复开始流式)
// 立即折叠过程,只显式显示回复(Cursor 式);工具恢复(acting)时重新展开。
// run 终态后:按偏好折叠为摘要行(可回看)。
const split = useMemo(() => splitTimeline(parts), [parts])
// 运行中时间线一直展开。终轮确认没有后续工具调用之后,才按偏好收成「已工作 X」。
const showProcess = showProcessInline(live ?? null, autoCollapse)
const lastContentIdx = (() => {
for (let i = blocks.length - 1; i >= 0; i--) if (blocks[i].kind === 'content') return i
const extras = citedArtifactCards(split.final)
const lastText = (() => {
for (let i = split.final.length - 1; i >= 0; i--) if (split.final[i].type === 'text') return i
return -1
})()
const timeline = (
<ProcessTimeline parts={split.timeline} live={!!live} idKey={msg.id}
renderPart={(part, liveText) => <PartView part={part} live={liveText} />} />
)
return (
<div className="msg assistant" data-msg-id={msg.id}>
<div className="msg-role">
@@ -235,18 +191,27 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
<span>助手</span>
{live && <LiveBar live={live} />}
</div>
{blocks.map((b, i) => b.kind === 'process'
? (showProcess
? <div className="part-group" key={i}><ProcessParts parts={b.parts} live={!!live} idKey={`${msg.id}:${i}`} /></div>
: <ProcessCollapse key={i} parts={b.parts} idKey={`${msg.id}:${i}`} />)
: (() => {
// CF0:非 live 的超长文本部件折叠为摘要行;live 与短文本不受影响。
if (!live && b.part.type === 'text' && (b.part.text?.length ?? 0) > LONG_REPLY_CHARS)
return <LongReplyCollapse key={i} text={b.part.text ?? ''} idKey={`${msg.id}:${i}`} />
return <div className="part" key={i}>
<PartView part={b.part} live={!!(live && i === lastContentIdx && b.part.type === 'text')} />
{split.timeline.length > 0 && (showProcess
? <div className="part-group">{timeline}</div>
: (
<div className="process-collapse">
<Collapsible id={`process:${msg.id}`} triggerClassName="process-collapse-summary" unmountDelay={300}
trigger={<>
<span className="process-collapse-chev">▸</span>
<span>{workedLabel(run?.createdAt, run?.endedAt)}</span>
</>}>
<div className="body">{timeline}</div>
</Collapsible>
</div>
})())}
))}
{split.final.map((part, i) => (
<div className="part" key={i}>
<PartView part={part} live={!!(live && i === lastText && part.type === 'text')} />
</div>
))}
{extras.map(art => (
<DeliverableCard key={art.id} artifact={{ artifactId: art.id, name: art.name, kind: art.kind }} />
))}
{live && <LiveAgentNotes live={live} />}
{!live && ctxObs && <ContextObsView obs={ctxObs} />}
</div>
@@ -342,6 +307,40 @@ function CommandMenu({ text, onPick }: { text: string; onPick: (cmd: string) =>
)
}
function OutboxPanel() {
const outbox = useStore(s => s.outbox)
const [editing, setEditing] = useState<string | null>(null)
const [draft, setDraft] = useState('')
if (outbox.length === 0) return null
return (
<Collapsible id="composer-outbox" defaultOpen
trigger={<span>排队 {outbox.length}</span>}
triggerClassName="outbox-trigger">
<ul className="outbox-list">
{outbox.map(item => (
<li key={item.id} className="outbox-item">
{editing === item.id ? (
<input className="search-input outbox-edit" value={draft} onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') { store.updateOutbox(item.id, draft); setEditing(null) }
}} />
) : (
<span className="outbox-text" title={item.text}>{item.text.trim() || '(仅附件)'}{item.attachmentNames.length > 0 ? ` · ${item.attachmentNames.join('、')}` : ''}</span>
)}
{editing === item.id ? (
<button className="btn ghost" onClick={() => { store.updateOutbox(item.id, draft); setEditing(null) }}>保存</button>
) : (
<button className="btn ghost" onClick={() => { setEditing(item.id); setDraft(item.text) }}>修改</button>
)}
<button className="btn ghost" onClick={() => store.sendOutboxNow(item.id)}>发送</button>
<button className="icon-btn" title="移出队列" onClick={() => store.removeOutbox(item.id)}><Icon name="x" size={12} /></button>
</li>
))}
</ul>
</Collapsible>
)
}
export function Conversation() {
const currentConvId = useStore(s => s.currentConvId)
const conversation = useStore(s => s.tasks.find(c => c.id === s.currentConvId))
@@ -373,8 +372,14 @@ export function Conversation() {
const send = async () => {
if (!text.trim() && attachmentIds.length === 0) return
const t = text
const ids = attachmentIds
const names = attachmentNames
setText(''); setAttachmentIds([]); setAttachmentNames([])
await store.sendMessage(t, attachmentIds)
if (active) {
store.enqueueOutbox(t, ids, names)
return
}
await store.sendMessage(t, ids)
}
if (!currentConvId) {
@@ -455,6 +460,7 @@ export function Conversation() {
</div>
<div className="input-bar">
<OutboxPanel />
{attachmentNames.length > 0 && (
<div className="attachments">
{attachmentNames.map((n, i) => <span className="attach-chip" key={i}><Icon name='paperclip' size={12} /> {n}
@@ -474,15 +480,14 @@ export function Conversation() {
}} />
</label>
<textarea rows={1} value={text} placeholder={hasOpenConfirm(cards) ? '有待确认的命令——请先处理上方确认卡'
: active ? '回合进行中——等当前回合结束(或点「停止」)后再发送'
: active ? '继续输入以排队'
: artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
onChange={e => setText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }} />
{active ? (
{active && (
<button className="btn danger" onClick={() => store.cancelActive()}>停止</button>
) : (
<button className="btn" onClick={send} disabled={!text.trim() && attachmentIds.length === 0}>发送</button>
)}
<button className="btn" onClick={send} disabled={!text.trim() && attachmentIds.length === 0}>{active ? '排队' : '发送'}</button>
</div>
</div>
</div>
+50
View File
@@ -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 (
<div className="preview-pane">
<TabStrip
tabs={tabs.map(t => ({ id: t.id, label: t.name }))}
activeId={active.id}
onActivate={id => store.activatePreview(id)}
onClose={id => store.closePreview(id)}
/>
<div className="preview-pane-bar">
<span className="preview-pane-name" title={active.name}>{active.name}</span>
<span className="preview-pane-spacer" />
{art && (
<>
<button type="button" className="icon-btn" title="全屏预览" onClick={() => store.requestPreview(art, 'full')}>
<Icon name="eye" size={14} />
</button>
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(art.id)} download={art.name}>
<Icon name="download" size={14} />
</a>
</>
)}
</div>
<div className="preview-pane-body">
{art ? (
<FilePreview source={{
kind: 'artifact',
id: art.id,
name: art.name,
mime: art.mime,
contentUrl: api.artifactContentUrl(art.id),
downloadUrl: api.artifactDownloadUrl(art.id),
}} />
) : <div className="empty-note" style={{ padding: 14 }}>这个文件已不在当前项目里</div>}
</div>
</div>
)
}
+57
View File
@@ -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 (
<div className="tl">
{rows.map((row, i) => {
if (row.kind === 'thinking') {
const dur = formatDuration(row.durationMs)
const label = row.active ? '思考中' : (dur ? `思考 · 持续了 ${dur}` : '思考')
return (
<div className="part-thinking" key={`${idKey}:th:${i}`}>
<Collapsible id={`think:${idKey}:${i}:${row.active ? 'live' : 'done'}`} defaultOpen={row.active} key={row.active ? 'open' : 'shut'}
triggerClassName="part-thinking-summary"
trigger={<><span className="part-thinking-chev">▸</span>
<span className={row.active ? 'mk-sweep' : ''}>{label}</span></>}>
<div className="part-thinking-body">{row.text}</div>
</Collapsible>
</div>
)
}
if (row.kind === 'text') {
return <div className="part" key={`${idKey}:tx:${i}`}>{renderPart({ type: 'text', text: row.text }, false)}</div>
}
if (row.kind === 'tools') {
const running = row.items.some(it => it.running)
return (
<div className="tl-tools" key={`${idKey}:tl:${i}`}>
<div className={`tl-tools-head${running ? ' mk-sweep' : ''}`}>{toolGroupLabel(row.toolKind, row.items.length, running)}</div>
<ul className="tl-tools-list">
{row.items.map((it, j) => (
<li key={j}>
<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>}
</li>
))}
</ul>
</div>
)
}
return <div className="part" key={`${idKey}:ot:${i}`}>{renderPart(row.part, false)}</div>
})}
</div>
)
}
+11 -32
View File
@@ -1,13 +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 => {
@@ -55,42 +54,22 @@ 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 (
<>
<div className="tabs">
<button className={`tab ${!showPreview ? 'active' : ''}`} onClick={() => store.setUI({ rightTab: 'inbox' })}
<button className={`tab ${!showFiles ? 'active' : ''}`} onClick={() => store.setUI({ rightTab: 'inbox' })}
title="收件箱">收件箱{pending > 0 ? ` ${pending}` : ''}</button>
<button className={`tab ${showPreview ? 'active' : ''}`} onClick={() => store.railPreview(railArtifact ?? null)}
title="预览(右栏轻预览)"><Icon name="eye" size={13} /> 预览</button>
<button className={`tab ${showFiles ? 'active' : ''}`} onClick={() => store.setUI({ rightTab: 'files', rightCollapsed: false })}
title="文件(与 ⌘1 工件面板同一份列表)">文件</button>
<span className="spacer" style={{ flex: 1 }} />
<button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}><Icon name="check" size={13} /> 收纳</button>
<button className="tab" title="收起收件箱" onClick={store.toggleRight}><Icon name="chevron-right" size={14} /></button>
</div>
{showPreview ? (
<div className="tab-body" data-rail-preview="">
{railArtifact ? (
<>
<div className="rail-preview-head">
<span className="rp-name" title={railArtifact.name}>{railArtifact.name}</span>
<span className="spacer" style={{ flex: 1 }} />
<button className="icon-btn" title="全屏预览(唯一大窗落位)" onClick={() => store.requestPreview(railArtifact, 'full')}><Icon name="eye" size={14} /></button>
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(railArtifact.id)} download={railArtifact.name}><Icon name="download" size={14} /></a>
<button className="icon-btn" title="关闭预览" onClick={() => store.railPreview(null)}><Icon name="x" size={14} /></button>
</div>
<FilePreview compact source={{
kind: 'artifact',
id: railArtifact.id,
name: railArtifact.name,
mime: railArtifact.mime,
contentUrl: api.artifactContentUrl(railArtifact.id),
downloadUrl: api.artifactDownloadUrl(railArtifact.id),
}} />
</>
) : <div className="empty-note" style={{ padding: 14 }}>点消息里的工件卡片或右侧工件面板——文件会在这里边聊边看(md/图片/pdf/Office/代码均可)</div>}
{showFiles ? (
<div className="tab-body">
<ArtifactsPanel />
</div>
) : (
<div className="tab-body">
+2 -66
View File
@@ -1,14 +1,12 @@
// 工作区弹层(提案 T4/D25):工件/代理 经 OverlayHost 呈现。
// 重设计方案 阶段②(IA 对话优先):插件面板/服务端声明工作区属系统能力,
// 只进「系统管理」分区(AdminZone),不占普通用户的 ⌘1–9;清单按角色收窄。
import { useState } from 'react'
import { store, useStore } from '../store'
import type { Run } from '../contracts'
import { api } from '../api'
import { OverlayHost, Icon, type DocBridge } from '@ui'
import { OverlayHost, type DocBridge } from '@ui'
import { docBridge } from './Workbench'
import { AgentRow } from './Workbench'
import { artifactIcon, humanSize } from '../messageparts'
import { ArtifactsPanel } from './ArtifactsPanel'
/** 工作区清单(顺序即 ⌘ 序号):内置在前,插件面板/声明工作区仅管理员可见。 */
export function workspaceEntries(): { key: string; title: string; icon: string; shortcut: string }[] {
@@ -59,68 +57,6 @@ export function Workspaces() {
return null
}
/** 工件面板(工作区文件管理):搜索 + kind 过滤 + 上传;行内 深预览/右栏预览/下载。
* 删除是保留能力:DELETE /api/artifacts/{id} 与 api.deleteArtifact 均在(插件可用),
* 系统默认不提供删除入口(提案 2026-09-30-file-preview-and-reserved-capabilities §2.2)。 */
export function ArtifactsPanel() {
const artifacts = useStore(s => s.artifacts)
const [q, setQ] = useState('')
const [kind, setKind] = useState<'all' | 'document' | 'data' | 'image'>('all')
const kinds = ['all', 'document', 'data', 'image'] as const
const visible = artifacts.filter(a =>
(kind === 'all' || a.kind === kind) && a.name.toLowerCase().includes(q.toLowerCase()))
return (
<div className="artifacts-panel">
<div className="ap-toolbar">
<input className="search-input" placeholder="搜索文件名…" value={q} onChange={e => setQ(e.target.value)} />
{kinds.map(k => (
<button key={k} className={`kind-pill ${kind === k ? 'on' : ''}`} onClick={() => setKind(k)}>
{k === 'all' ? '全部' : k === 'document' ? '文档' : k === 'data' ? '数据' : '图片'}
</button>
))}
<label className="btn ghost ap-upload" title="上传文件到当前项目">
<Icon name="paperclip" size={14} /> 上传
<input type="file" multiple style={{ display: 'none' }} onChange={async e => {
for (const f of Array.from(e.target.files ?? [])) await store.uploadAttachment(f)
e.target.value = ''
}} />
</label>
</div>
{artifacts.length === 0
? <div className="empty-note">暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载</div>
: visible.length === 0
? <div className="empty-note">没有匹配「{q}」的文件</div>
: visible.map(a => (
<div key={a.id} className="artifact-row" data-artifact-row={a.id}
onClick={() => store.requestPreview(a)} title={`预览 ${a.name}(右栏,边聊边看)`}>
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
<span className="ar-main">
<span className="name">{a.name}</span>
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
</span>
<span className="ar-acts">
<button className="icon-btn" title="全屏预览" onClick={e => { e.stopPropagation(); store.requestPreview(a, 'full') }}><Icon name="eye" size={14} /></button>
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
</span>
</div>
))}
</div>
)
}
function shortTime(iso: string): string {
try {
const d = new Date(iso)
const now = new Date()
return d.toDateString() === now.toDateString()
? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: d.toLocaleDateString([], { month: '2-digit', day: '2-digit' })
} catch { return '' }
}
/** 代理面板(任务树;点击进 AgentTracker 深读)。 */
export function AgentsPanel({ runs }: { runs: Run[] }) {
if (runs.length === 0) return <div className="empty-note">暂无运行——多代理任务会在此形成任务树(mock:#agents)</div>
+9
View File
@@ -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 {
@@ -248,7 +249,11 @@ export interface PluginManifest {
tools?: string[]; workflows?: string[]; intents?: string[];
prompts?: string[]; panels?: string[]; cards?: string[]; settings?: string[];
commands?: string[]; workspaces?: string[];
skills?: string[]; mcpServers?: string[]; knowledge?: string[];
parsers?: string[]; agents?: string[]; channels?: string[]; pages?: string[];
};
scope?: { tenants?: string[]; projects?: string[] };
backend?: { mode?: 'inproc' | 'sidecar'; command?: string; args?: string[]; schemaVersion?: number };
}
export interface PluginPanel {
@@ -260,6 +265,10 @@ export interface PluginWorkspace {
badgeTool?: string; body: UINode[]; refreshTool?: string;
}
export interface PluginPageDecl {
id: string; title: string; module: string;
}
// ---- 摄取 / 检索 ----
export interface ParsedDocument {
+13 -9
View File
@@ -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
}
+43 -22
View File
@@ -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 : '<span class="missing-art">未找到该文件</span>'
))
}
/** 毫秒转「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 共用)。 */
+38
View File
@@ -0,0 +1,38 @@
// 输入排队(纯数据):回合进行中先入本地队列,终态再逐条发送。
export interface QueuedMessage {
id: string
text: string
attachmentIds: string[]
attachmentNames: string[]
}
export function newQueuedMessage(text: string, attachmentIds: string[], attachmentNames: string[]): QueuedMessage {
return {
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
text,
attachmentIds,
attachmentNames,
}
}
export function enqueueMessage(queue: QueuedMessage[], item: QueuedMessage): QueuedMessage[] {
return [...queue, item]
}
export function enqueueMessageFront(queue: QueuedMessage[], item: QueuedMessage): QueuedMessage[] {
return [item, ...queue.filter(q => q.id !== item.id)]
}
export function removeQueuedMessage(queue: QueuedMessage[], id: string): QueuedMessage[] {
return queue.filter(q => q.id !== id)
}
export function replaceQueuedText(queue: QueuedMessage[], id: string, text: string): QueuedMessage[] {
return queue.map(q => q.id === id ? { ...q, text } : q)
}
export function promoteQueuedMessage(queue: QueuedMessage[], id: string): QueuedMessage[] {
const item = queue.find(q => q.id === id)
if (!item) return queue
return enqueueMessageFront(queue, item)
}
+14
View File
@@ -0,0 +1,14 @@
import type { PluginPageDecl } from './contracts'
export interface PluginPageModule {
mount(el: HTMLElement, bridge: unknown): void
unmount?: () => void
}
// loadPluginPage 按声明加载 ESM。模块 URL 由插件页面声明给出,宿主不内嵌领域页面。
export async function loadPluginPage(page: PluginPageDecl): Promise<PluginPageModule> {
if (!page.module) throw new Error(`plugin page ${page.id} missing module`)
const mod = await import(/* @vite-ignore */ page.module)
if (typeof mod.mount !== 'function') throw new Error(`plugin page ${page.id} has no mount`)
return mod as PluginPageModule
}
+55 -8
View File
@@ -2,6 +2,7 @@
// 全部 dynamic import——对应格式首次预览时才下载 chunk,主 bundle 不含解析器。
// 链接预览 fetcher 走服务端 /api/url-preview(SSRF 防护在服务端执行端)。
import { configureFilePreviewLoaders, configureLinkPreviewFetcher } from '@ui'
import { marked } from 'marked'
import { auth } from './auth'
import type { LinkPreviewData } from '@ui'
@@ -11,15 +12,55 @@ const fetchBuf = async (url: string | undefined): Promise<ArrayBuffer> => {
return r.arrayBuffer()
}
const docxLoader = async (el: HTMLElement, source: { contentUrl?: string }) => {
const mammoth = await import('mammoth')
const { value } = await mammoth.convertToHtml({ arrayBuffer: await fetchBuf(source.contentUrl) })
el.innerHTML = value
// docx/OOXML 是 ZIP 容器,真文件必以 PK 魔数开头(PK\x03\x04 常规,PK\x05\x06 空包)。
// 智能体生成侧可能写出"内容是文本的伪 docx",先嗅探再喂 mammoth:
// 非 ZIP 直接按文本/markdown 回退渲染,不再抛 JSZip 底层错误。
const isZipBytes = (buf: ArrayBuffer): boolean => {
if (buf.byteLength < 4) return false
const h = new Uint8Array(buf, 0, 4)
return h[0] === 0x50 && h[1] === 0x4b &&
((h[2] === 0x03 && h[3] === 0x04) || (h[2] === 0x05 && h[3] === 0x06))
}
const xlsxLoader = async (el: HTMLElement, source: { contentUrl?: string }) => {
const renderTextFallback = (el: HTMLElement, buf: ArrayBuffer, name: string | undefined, kindLabel: string) => {
const note = document.createElement('div')
note.className = 'ui-fp-note'
note.textContent = `「${name ?? '该文件'}」不是有效的 ${kindLabel} 文件(缺少 ZIP 结构),已按文本内容展示;需要正式格式请让智能体重新生成。`
const body = document.createElement('div')
body.className = 'doc-viewer'
body.innerHTML = marked.parse(new TextDecoder().decode(buf)) as string
el.innerHTML = ''
el.append(note, body)
}
const docxLoader = async (el: HTMLElement, source: { contentUrl?: string; name?: string }) => {
const buf = await fetchBuf(source.contentUrl)
if (!isZipBytes(buf)) {
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 = `<div class="doc-viewer">${value}</div>`
}
const xlsxLoader = async (el: HTMLElement, source: { contentUrl?: string; name?: string }) => {
const buf = await fetchBuf(source.contentUrl)
// .xls 是 OLE 复合文档,不是 ZIP;只对 OOXML(xlsx/xlsm,或未给文件名)做魔数嗅探。
if (!/\.xls$/i.test(source.name ?? '') && !isZipBytes(buf)) {
renderTextFallback(el, buf, source.name, 'XLSX')
return
}
const XLSX = await import('xlsx')
const wb = XLSX.read(await fetchBuf(source.contentUrl), { type: 'array' })
const wb = XLSX.read(buf, { type: 'array' })
el.innerHTML = ''
const tabs = document.createElement('div')
tabs.className = 'ui-fp-sheet-tabs'
@@ -63,7 +104,13 @@ function renderSheet(wrap: HTMLElement, rows: unknown[][]) {
wrap.append(table)
}
const pptxLoader = async (el: HTMLElement, source: { contentUrl?: string }) => {
const pptxLoader = async (el: HTMLElement, source: { contentUrl?: string; name?: string }) => {
const buf = await fetchBuf(source.contentUrl)
// .ppt 是老版二进制;OOXML(pptx/ppsx)才要求 ZIP 魔数。
if (!/\.ppt$/i.test(source.name ?? '') && !isZipBytes(buf)) {
renderTextFallback(el, buf, source.name, 'PPTX')
return
}
// pptx2html@0.3.4 的 module 入口引用了大小写错配的 ./txml(Linux 打包必炸),
// 按 D2 备选条款改用 pptx-preview(init(dom).preview(arrayBuffer) 全保真渲染)。
const { init } = await import('pptx-preview')
@@ -72,7 +119,7 @@ const pptxLoader = async (el: HTMLElement, source: { contentUrl?: string }) => {
stage.className = 'ui-fp-slide-stage'
el.append(stage)
const previewer = init(stage, { width: stage.clientWidth || 900 })
await previewer.preview(await fetchBuf(source.contentUrl))
await previewer.preview(buf)
}
export function wirePreviewCapabilities() {
+27
View File
@@ -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 }
}
+119 -22
View File
@@ -3,6 +3,8 @@
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,
AgentProfile, StreamEvent, PluginPanel, ActivityCard,
@@ -35,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
@@ -74,6 +76,10 @@ interface AppState {
connected: boolean
error: string | null
toast: string | null
outbox: QueuedMessage[]
outboxDraining: boolean
previewTabs: Record<string, PreviewTab[]>
previewActive: Record<string, string | null>
}
const DEFAULT_UI: UIState = {
@@ -88,7 +94,7 @@ const DEFAULT_UI: UIState = {
settingsOpen: false,
trackerRunId: null,
viewingArtifact: null,
railPreviewId: null,
previewWidth: 640,
taskOrg: 'project',
showArchived: false,
lastProjectId: null,
@@ -112,6 +118,8 @@ let state: AppState = {
plan: null, todos: null, artifacts: [], cards: [], projectCards: [], inboxScope: 'conv', profiles: [], pluginPanels: [],
serverWorkspaces: [],
ui: loadUI(), connected: false, error: null, toast: null,
outbox: [], outboxDraining: false,
previewTabs: {}, previewActive: {},
}
const listeners = new Set<() => void>()
@@ -121,6 +129,21 @@ function set(patch: Partial<AppState>) {
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<UIState>) {
const ui = { ...state.ui, ...patch }
state = { ...state, ui }
@@ -186,10 +209,16 @@ export const store = {
await store.refreshArtifacts(projectId)
},
/** 新建任务(=会话):默认当前项目上下文;显式 projectId 用于"新建于指定项目"。 */
/** 新建任务(=会话):默认当前项目上下文;显式 projectId 用于"新建于指定项目"。
* 空库时主按钮与空状态都写着「点击新建任务开始」,这里先落一个默认项目再开会话。 */
async createTask(projectId?: string) {
const pid = projectId ?? state.currentProjectId
if (!pid) { store.toast('请先创建项目'); return }
let pid = projectId ?? state.currentProjectId
if (!pid && state.projects.length > 0) pid = state.projects[0].id
if (!pid) {
const p = await store.createProject('默认项目')
pid = p.id
}
if (pid !== state.currentProjectId) await store.openProject(pid)
const c = await api.createConversation(pid)
await store.refreshTasks()
await store.openTask(c.id)
@@ -233,7 +262,8 @@ 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' })
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()
},
@@ -365,27 +395,28 @@ export const store = {
if (p?.context && e.runId) {
set({ contextObs: { ...state.contextObs, [e.runId]: p.context } })
}
setTimeout(() => store.reconcile(), 250)
setTimeout(() => { void store.reconcile().then(() => store.flushOutbox()) }, 250)
break
}
case 'run.failed':
case 'error': {
// 终态:短暂延迟后对账(等待消息树落库),折叠过程输出。
setTimeout(() => store.reconcile(), 250)
// 终态:短暂延迟后对账(等待消息树落库),折叠过程输出,再发送排队消息。
setTimeout(() => { void store.reconcile().then(() => store.flushOutbox()) }, 250)
break
}
}
},
async sendMessage(text: string, attachmentIds: string[]) {
if (!state.currentConvId) return
async sendMessage(text: string, attachmentIds: string[]): Promise<'ok' | 'busy'> {
if (!state.currentConvId) return 'ok'
try {
const out = await api.sendMessage(state.currentConvId, text, attachmentIds)
if (out.command) {
store.toast(out.reply ?? `命令 ${out.command} 已执行`)
if (out.conversation) await store.openConversation(out.conversation.id)
else await store.reconcile()
return
await store.flushOutbox()
return 'ok'
}
if (out.run) {
// 竞态保护:POST 返回前 SSE 事件可能已先行到达并累积(同 run),
@@ -396,9 +427,58 @@ export const store = {
set({ messages: [...state.messages, out.message] })
store.connectIfNeeded()
}
return 'ok'
} catch (e) {
if (isConversationBusy(e)) {
set({ outbox: enqueueMessageFront(state.outbox, newQueuedMessage(text, attachmentIds, [])) })
return 'busy'
}
store.toast(`发送失败:${e}`)
await store.reconcile()
return 'ok'
}
},
enqueueOutbox(text: string, attachmentIds: string[], attachmentNames: string[]) {
set({ outbox: enqueueMessage(state.outbox, newQueuedMessage(text, attachmentIds, attachmentNames)) })
},
updateOutbox(id: string, text: string) {
set({ outbox: replaceQueuedText(state.outbox, id, text) })
},
removeOutbox(id: string) {
set({ outbox: removeQueuedMessage(state.outbox, id) })
},
async sendOutboxNow(id: string) {
const item = state.outbox.find(q => q.id === id)
if (!item) return
const rest = removeQueuedMessage(state.outbox, id)
if (activeRun(state.runs) || state.live) {
set({ outbox: promoteQueuedMessage(state.outbox, id) })
store.toast('回合进行中,已放到队首,结束后自动发送')
return
}
set({ outbox: rest })
await store.sendMessage(item.text, item.attachmentIds)
},
async flushOutbox() {
if (state.outboxDraining) return
if (activeRun(state.runs) || state.live) return
const next = state.outbox[0]
if (!next) return
set({ outbox: state.outbox.slice(1), outboxDraining: true })
let result: 'ok' | 'busy' = 'ok'
try {
result = await store.sendMessage(next.text, next.attachmentIds)
} finally {
set({ outboxDraining: false })
}
if (result === 'busy') return
if (!activeRun(state.runs) && !state.live && state.outbox.length > 0) {
await store.flushOutbox()
}
},
@@ -590,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 }) },
@@ -623,6 +716,10 @@ export function isTerminal(status?: string): boolean {
return status === 'completed' || status === 'failed' || status === 'cancelled' || status === 'interrupted'
}
function isConversationBusy(err: unknown): boolean {
return String(err).includes('409:')
}
export function activeRun(runs: Run[]): Run | undefined {
return runs.find(r => !isTerminal(r.status))
}
+40 -16
View File
@@ -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; }
@@ -162,6 +186,11 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
/* 输入区:悬浮卡片式输入壳(16px 大圆角 = DESIGN.md 特批例外) */
.input-bar { padding: 8px max(16px, 6%) 16px; background: var(--bg); border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
.outbox-trigger { width: 100%; text-align: left; background: var(--bg-inset); color: var(--text-dim); border: 1px solid var(--border-soft); border-radius: var(--radius-xs); padding: 6px 10px; font-size: var(--fs-ui-sm); cursor: pointer; }
.outbox-list { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.outbox-item { display: flex; gap: 6px; align-items: center; background: var(--bg-inset); border-radius: var(--radius-xs); padding: 6px 8px; }
.outbox-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-size: var(--fs-ui-sm); }
.outbox-edit { flex: 1; min-width: 0; }
.attachments { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.attach-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 3px 8px; font-size: var(--fs-ui-sm); }
.input-row { display: flex; gap: 8px; align-items: flex-end; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 8px 8px 8px 14px; box-shadow: var(--shadow-md); transition: border-color .12s, box-shadow .12s; }
@@ -433,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,阶段④合入主线时归一) */
+146
View File
@@ -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<string, unknown>
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
}
+19 -13
View File
@@ -14,12 +14,12 @@ function live(over: Partial<LiveState> = {}): 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)
+39 -37
View File
@@ -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<Part> & { 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('已工作')
})
})
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import {
enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage,
removeQueuedMessage, replaceQueuedText,
} from '../src/outbox'
describe('outbox', () => {
it('appends, edits, promotes, and removes', () => {
const a = newQueuedMessage('a', [], [])
const b = newQueuedMessage('b', ['f1'], ['note.md'])
let q = enqueueMessage(enqueueMessage([], a), b)
expect(q.map(x => x.text)).toEqual(['a', 'b'])
q = replaceQueuedText(q, a.id, 'a2')
expect(q[0].text).toBe('a2')
q = promoteQueuedMessage(q, b.id)
expect(q.map(x => x.text)).toEqual(['b', 'a2'])
q = removeQueuedMessage(q, b.id)
expect(q.map(x => x.text)).toEqual(['a2'])
})
it('puts a busy conflict back at the front without duplicating', () => {
const a = newQueuedMessage('a', [], [])
const b = newQueuedMessage('b', [], [])
const q = enqueueMessageFront(enqueueMessage([], a), b)
const again = enqueueMessageFront(q, b)
expect(again.map(x => x.id)).toEqual([b.id, a.id])
})
})
+24
View File
@@ -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 })
})
})
+62
View File
@@ -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<Part> & { 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 })
})
})
+1 -1
View File
@@ -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"}
{"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"}