Migrate chat folds to Collapsible and adopt ToolBlock
Why: Approved redesign phase 4: process folds, long-reply folds, thinking blocks and context-observation fold move to the @ui Collapsible primitive (two-layer height+fade animation, delayed unmount, open-state memory); tool calls render as ToolBlock rows with running sweep and paired results; the live bar gains the ZCode worked-time line with sweep; input placeholder states the backend's honest busy semantics (serialized per conversation). Evidence: npx tsc -b clean; npm test -> 39 passed (details-guard included); npm run build succeeds; combined make test recorded in the Dev task card. Impact: Conversation.tsx replaces all <details> usage (guard-enforced); ProcessParts pairs tool_call with its trailing tool_result into ToolBlock; styles.css rewrites fold selectors for the Collapsible structure and adds message mount animation (mk-rise-in); no contract or API changes. Refs: Agent-System-Dev task feature/ui-tokens-v3 (phase 4).
This commit is contained in:
+121
-50
@@ -1,5 +1,7 @@
|
||||
// 中栏:消息流(Part 渲染器注册表)+ 流式渲染 + 过程折叠 + HITL 卡片 + 输入区(4.4)。
|
||||
import { Icon, Avatar, BrandMark } from '@ui'
|
||||
// 重设计方案 阶段④:折叠区全部迁 @ui Collapsible(<details> 已禁);工具调用块走
|
||||
// ToolBlock 契约(运行态扫光、完成边沿收起、展开态记忆);live 行扫光 + 已工作计时。
|
||||
import { Icon, Avatar, BrandMark, Collapsible, ToolBlock } from '@ui'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { marked } from 'marked'
|
||||
import { api } from '../api'
|
||||
@@ -21,12 +23,8 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(part.text ?? '') }} />
|
||||
case 'thinking':
|
||||
// 流式期间展开可实时看到思考;run 终态后收进过程折叠区,默认收起。
|
||||
return (
|
||||
<details className="part-thinking" open={live ? true : undefined}>
|
||||
<summary>思考过程</summary>
|
||||
<div className="part-thinking-body">{part.text}</div>
|
||||
</details>
|
||||
)
|
||||
//(此单件形态用于过程组之外散落的思考;组内思考由 ProcessParts 渲染。)
|
||||
return <ThinkingBlock part={part} idKey={part.text?.slice(0, 24) ?? 't'} defaultOpen={!!live} />
|
||||
case 'tool_call':
|
||||
return <span className="tool-chip">🛠 {part.toolCall?.name}</span>
|
||||
case 'tool_result': {
|
||||
@@ -94,40 +92,108 @@ function findCard(id: string) {
|
||||
return cardCache[id]
|
||||
}
|
||||
|
||||
// 过程折叠:run 终态后把连续 process 部件折叠为摘要行(可展开回看)。
|
||||
function ProcessCollapse({ parts }: { parts: Part[] }) {
|
||||
// 思考块(Collapsible 契约):左线弱色,流式默认展开、终态默认收起;展开态记忆。
|
||||
function ThinkingBlock({ part, idKey, defaultOpen }: { part: Part; idKey: string; defaultOpen?: boolean }) {
|
||||
return (
|
||||
<div className="part-thinking">
|
||||
<Collapsible id={`think:${idKey}`} defaultOpen={defaultOpen} triggerClassName="part-thinking-summary"
|
||||
trigger={<><span className="part-thinking-chev">▸</span>思考过程</>}>
|
||||
<div className="part-thinking-body">{part.text}</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 过程段渲染(配对增强):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
|
||||
}
|
||||
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 (
|
||||
<details className="process-collapse">
|
||||
<summary>
|
||||
<span>过程输出</span>
|
||||
<span>{hasThinking ? '含思考' : ''}{toolCount > 0 ? ` · ${toolCount} 次工具调用` : ''}(点击展开)</span>
|
||||
</summary>
|
||||
<div className="body">
|
||||
{parts.map((p, i) => <div className="part" key={i}><PartView part={p} /></div>)}
|
||||
</div>
|
||||
</details>
|
||||
<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 }: { text: string }) {
|
||||
function LongReplyCollapse({ text, idKey }: { text: string; idKey: string }) {
|
||||
// 摘句取压缩空白后的前 100 字;字数按 Markdown 源码长度计。
|
||||
const excerpt = text.replace(/\s+/g, ' ').trim().slice(0, 100)
|
||||
return (
|
||||
<details className="process-collapse long-reply">
|
||||
<summary>
|
||||
<span>📄 长回复</span>
|
||||
<span>{text.length} 字(按 Markdown 源码计)</span>
|
||||
{excerpt && <span style={{ color: 'var(--text-faint)' }}>{excerpt}…</span>}
|
||||
</summary>
|
||||
<div className="body">
|
||||
<div className="part-text" dangerouslySetInnerHTML={{ __html: marked.parse(text) }} />
|
||||
</div>
|
||||
</details>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
// 已工作计时(ZCode「已工作 X 分 X 秒」摘要行;1s 步进,仅 live 期间挂载)。
|
||||
function useElapsed(startedAt: number): string {
|
||||
const [, tick] = useState(0)
|
||||
useEffect(() => {
|
||||
const t = window.setInterval(() => tick(n => n + 1), 1000)
|
||||
return () => window.clearInterval(t)
|
||||
}, [])
|
||||
const s = Math.max(0, Math.floor((Date.now() - startedAt) / 1000))
|
||||
const m = Math.floor(s / 60)
|
||||
return m > 0 ? `${m} 分 ${s % 60} 秒` : `${s} 秒`
|
||||
}
|
||||
|
||||
// 流式状态行(独立组件:计时 hook 只在 live 行挂载期间运转)。
|
||||
function LiveBar({ live }: { live: LiveState }) {
|
||||
const elapsed = useElapsed(live.startedAt)
|
||||
return (
|
||||
<span className="live-bar">
|
||||
<span className="live-dot" />
|
||||
<span className="mk-sweep">{live.phase}</span>
|
||||
<span className="live-elapsed">已工作 {elapsed}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -152,16 +218,16 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
|
||||
<div className="msg-role">
|
||||
<span className="role-mark role-assistant"><BrandMark size={16} /></span>
|
||||
<span>助手</span>
|
||||
{live && <span className="live-bar"><span className="live-dot" />{live.phase}</span>}
|
||||
{live && <LiveBar live={live} />}
|
||||
</div>
|
||||
{blocks.map((b, i) => b.kind === 'process'
|
||||
? (showProcess
|
||||
? <div className="part-group" key={i}>{b.parts.map((p, j) => <div className="part" key={j}><PartView part={p} live={!!live} /></div>)}</div>
|
||||
: <ProcessCollapse key={i} parts={b.parts} />)
|
||||
? <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 ?? ''} />
|
||||
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')} />
|
||||
</div>
|
||||
@@ -175,22 +241,25 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
|
||||
// 上下文组装观测(4.10/4.17):模型实际看到什么、裁了什么——可解释性入口。
|
||||
function ContextObsView({ obs }: { obs: ContextObs }) {
|
||||
return (
|
||||
<details className="process-collapse">
|
||||
<summary>
|
||||
<span>🧩 上下文构成</span>
|
||||
<span>{obs.items.join(' · ')}(约 {obs.estChars} 字符)</span>
|
||||
</summary>
|
||||
<div className="body">
|
||||
<ul className="ui-list">
|
||||
{obs.items.map((it, i) => <li key={i}>{it}</li>)}
|
||||
</ul>
|
||||
{obs.truncations && obs.truncations.length > 0 && (
|
||||
<div style={{ color: 'var(--warn)', fontSize: 'var(--fs-ui-sm)', marginTop: 6 }}>
|
||||
{obs.truncations.map((t, i) => <div key={i}>⚠ {t}</div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
<div className="process-collapse">
|
||||
<Collapsible id={`ctxobs:${obs.estChars}`} triggerClassName="process-collapse-summary" unmountDelay={300}
|
||||
trigger={<>
|
||||
<span className="process-collapse-chev">▸</span>
|
||||
<span>🧩 上下文构成</span>
|
||||
<span className="process-collapse-meta">{obs.items.join(' · ')}(约 {obs.estChars} 字符)</span>
|
||||
</>}>
|
||||
<div className="body">
|
||||
<ul className="ui-list">
|
||||
{obs.items.map((it, i) => <li key={i}>{it}</li>)}
|
||||
</ul>
|
||||
{obs.truncations && obs.truncations.length > 0 && (
|
||||
<div style={{ color: 'var(--warn)', fontSize: 'var(--fs-ui-sm)', marginTop: 6 }}>
|
||||
{obs.truncations.map((t, i) => <div key={i}>⚠ {t}</div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -389,7 +458,9 @@ export function Conversation() {
|
||||
e.target.value = ''
|
||||
}} />
|
||||
</label>
|
||||
<textarea rows={1} value={text} placeholder={hasOpenConfirm(cards) ? '有待确认的命令——请先处理上方确认卡' : artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
|
||||
<textarea rows={1} value={text} placeholder={hasOpenConfirm(cards) ? '有待确认的命令——请先处理上方确认卡'
|
||||
: active ? '回合进行中——等当前回合结束(或点「停止」)后再发送'
|
||||
: artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }} />
|
||||
{active ? (
|
||||
|
||||
+14
-7
@@ -64,7 +64,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.empty-state { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-faint); gap: 10px; font-size: var(--fs-ui-caption); }
|
||||
.empty-state .es-mark { width: 44px; height: 44px; border-radius: var(--radius-lg); background: var(--bg-raised); border: 1px solid var(--border-soft); display: flex; align-items: center; justify-content: center; color: var(--text-faint); margin-bottom: 4px; }
|
||||
.empty-state svg { opacity: .5; }
|
||||
.msg { margin-bottom: 20px; }
|
||||
.msg { margin-bottom: 20px; animation: mk-rise-in .18s var(--ease-out); }
|
||||
.msg-role { font-size: var(--fs-ui-sm); color: var(--text-faint); margin-bottom: 6px; display: flex; gap: 8px; align-items: center; }
|
||||
.msg-role .role-mark { width: 18px; height: 18px; border-radius: 5px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); background: var(--bg-raised); border: 1px solid var(--border); }
|
||||
.msg.user .bubble {
|
||||
@@ -72,8 +72,11 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
border-radius: var(--radius-lg); padding: 11px 16px; white-space: pre-wrap;
|
||||
}
|
||||
.part { margin: 4px 0; }
|
||||
/* 思考块(Collapsible 契约:触发行 + mk-col 动画体) */
|
||||
.part-thinking { color: var(--text-dim); font-size: var(--fs-ui-sm); border-left: 2px solid var(--border); padding-left: 10px; margin: 4px 0; }
|
||||
.part-thinking summary { cursor: pointer; color: var(--text-faint); user-select: none; }
|
||||
.part-thinking .part-thinking-summary { color: var(--text-faint); user-select: none; }
|
||||
.part-thinking .part-thinking-chev { display: inline-flex; color: var(--text-faint); font-size: var(--fs-ui-xs); transition: transform .12s; }
|
||||
.part-thinking .mk-col-trigger[aria-expanded="true"] .part-thinking-chev { transform: rotate(90deg); }
|
||||
.part-thinking .part-thinking-body { white-space: pre-wrap; margin-top: 4px; }
|
||||
.part-tool { font-size: var(--fs-ui-sm); }
|
||||
.tool-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); padding: 4px 9px; margin: 2px 4px 2px 0; font-family: var(--mono); }
|
||||
@@ -111,15 +114,19 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
/* 过程段内联组(流式/关闭自动折叠时,按原位渲染的过程容器) */
|
||||
.part-group { margin: 2px 0; }
|
||||
|
||||
/* 过程折叠(run 终态后) */
|
||||
/* 过程折叠(run 终态后;Collapsible 契约:触发行 + mk-col 动画体) */
|
||||
.process-collapse { border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-panel); margin: 6px 0; }
|
||||
.process-collapse summary { list-style: none; cursor: pointer; padding: 7px 12px; color: var(--text-faint); font-size: var(--fs-ui-sm); display: flex; align-items: center; gap: 8px; user-select: none; }
|
||||
.process-collapse summary::before { content: '▸'; transition: transform .12s; font-size: var(--fs-ui-xs); }
|
||||
.process-collapse[open] summary::before { transform: rotate(90deg); }
|
||||
.process-collapse .process-collapse-summary { padding: 7px 12px; color: var(--text-faint); font-size: var(--fs-ui-sm); user-select: none; }
|
||||
.process-collapse .process-collapse-summary:hover { color: var(--text-dim); }
|
||||
.process-collapse .process-collapse-chev { display: inline-flex; color: var(--text-faint); font-size: var(--fs-ui-xs); transition: transform .12s; }
|
||||
.process-collapse .mk-col-trigger[aria-expanded="true"] .process-collapse-chev { transform: rotate(90deg); }
|
||||
.process-collapse .process-collapse-meta { color: var(--text-faint); }
|
||||
.process-collapse .process-collapse-excerpt { color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 40%; }
|
||||
.process-collapse .body { padding: 4px 12px 10px; }
|
||||
|
||||
/* 流式状态行(呼吸/扫光走 mk-* 类) */
|
||||
/* 流式状态行(呼吸/扫光走 mk-* 类;已工作计时弱色等宽) */
|
||||
.live-bar { display: flex; align-items: center; gap: 8px; color: var(--text-faint); font-size: var(--fs-ui-sm); margin-bottom: 6px; }
|
||||
.live-elapsed { color: var(--text-faint); font-variant-numeric: tabular-nums; }
|
||||
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: mk-breathe 1.2s infinite; }
|
||||
.cursor-blink::after { content: '▍'; animation: mk-breathe 1s infinite; color: var(--accent); }
|
||||
|
||||
|
||||
@@ -77,4 +77,12 @@ describe('行内字号棘轮', () => {
|
||||
}
|
||||
expect(extra, '行内 fontSize 数值——改用 var(--fs-ui-*);确需豁免更新基线并写明理由').toEqual([])
|
||||
})
|
||||
it('组件 TSX 无 <details> 折叠(走 @ui Collapsible,DESIGN.md §11.7)', () => {
|
||||
const extra: string[] = []
|
||||
for (const p of walk(SRC).filter(p => p.endsWith('.tsx'))) {
|
||||
const code = read(p).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
|
||||
if (/<details[\s>]/.test(code)) extra.push(relative(SRC, p))
|
||||
}
|
||||
expect(extra, '<details> 折叠——改用 @ui Collapsible').toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user