diff --git a/apps/desktop/src/renderer/src/components/MessageItem.tsx b/apps/desktop/src/renderer/src/components/MessageItem.tsx index dcf85e6..2989441 100644 --- a/apps/desktop/src/renderer/src/components/MessageItem.tsx +++ b/apps/desktop/src/renderer/src/components/MessageItem.tsx @@ -29,6 +29,16 @@ function readBlocks(raw: unknown): ContentBlock[] { return [] } +/** 折叠态紧凑行用的最新思考行(取最后一个非空行,超长截断) */ +function lastThinkingLine(text: string): string { + const lines = text.split('\n') + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim() + if (line) return line.length > 120 ? `${line.slice(0, 120)}…` : line + } + return '' +} + /** 工作计时:working=true 起表,结束后冻结读数 */ function useWorkingSeconds(working: boolean): string { const startRef = useRef(null) @@ -197,22 +207,20 @@ export function AssistantTurn({ const workedFor = useWorkingSeconds(turnActive) const headerLabel = workedFor || `${calls} 次工具调用${thinking ? `,${thinking} 条消息` : ''}` - // 流式分段:思考段属过程(折叠时隐藏),正文段属答案;均按真实时序渲染 - const showStreamThinking = !collapsed || !hasProcess + // 流式分段:思考段属过程(过程区折叠时隐藏,由头部下方的紧凑行代为展示 + // 最新一行),正文段属答案;均按真实时序渲染。思考块保持挂载(仅 display:none), + // 折叠/展开往返不重置内部计时的滚动状态。 const streamNodes: ReactNode[] = [] - let visibleThinkingRows = 0 if (stream) { const segments = stream.segments segments.forEach((seg, i) => { if (seg.kind === 'thinking') { - if (!showStreamThinking) return const rows = thinkingRows(seg.text) if (rows.length === 0) return - visibleThinkingRows += rows.length - // 该思考段之后已有正文段在流:思考已完成,表头从「思考中…」切回「思考过程」 + // 该思考段之后已有正文段在流:思考已完成,表头从「正在思考」切回 const followedByText = segments.some((s, k) => k > i && s.kind === 'text' && s.text.length > 0) streamNodes.push( -
+
) @@ -227,10 +235,10 @@ export function AssistantTurn({ } }) } - // 折叠时思考不可见 → 头部提示「思考中…」(仅当前正在流思考段时) + // 过程区折叠时的「正在思考」紧凑行:展示最新一行思考(ZCode 式),随流式刷新 const lastSeg = stream?.segments[stream.segments.length - 1] - const hiddenThinking = - collapsed && hasProcess && Boolean(stream && lastSeg?.kind === 'thinking' && lastSeg.text.trim()) + const compactThinking = + collapsed && hasProcess && stream && lastSeg?.kind === 'thinking' ? lastThinkingLine(lastSeg.text) : null return (
@@ -256,14 +264,33 @@ export function AssistantTurn({ > - - {headerLabel} - {hiddenThinking ? ',思考中…' : ''} - + {headerLabel}
)} + {/* 折叠时的实时思考预览:仅展示最新一行,随流式滚动前进 */} + {compactThinking !== null && ( +
+ + + + + 正在思考 + + {compactThinking && ( + · {compactThinking} + )} +
+ )} + {/* 过程区:GSAP 高度 + 透明度补间(见上方 useLayoutEffect) */}
{processNodes} @@ -272,7 +299,7 @@ export function AssistantTurn({ {/* 答案区:最终正文 / 流式中的答案雏形,始终可见 */} {answerNodes} {streamNodes.length > 0 &&
{streamNodes}
} - {stream && streamNodes.length === 0 && visibleThinkingRows === 0 && showStreamThinking && ( + {stream && streamNodes.length === 0 && !(collapsed && hasProcess) && ( )} {/* 回合进行中但没有流式占位:工具执行或自动压缩的静默间隙,给一个存活指示 */} diff --git a/apps/desktop/src/renderer/src/components/Thinking.tsx b/apps/desktop/src/renderer/src/components/Thinking.tsx index aee9850..40edbee 100644 --- a/apps/desktop/src/renderer/src/components/Thinking.tsx +++ b/apps/desktop/src/renderer/src/components/Thinking.tsx @@ -1,13 +1,15 @@ -import { useLayoutEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' -/** 可展开的思考轨迹(beautifului.dev Thinking · Reasoning 形态)。 - * 流式期间自动展开并 shimmer「思考中…」;结束后收起为「思考过程」, +/** 可展开的思考轨迹(ZCode 式实时预览)。 + * 流式中:展开区为限高滚动预览——新内容在尾部并自动吸底,用户上滚即取消 + * 吸底(滚回底部恢复),表头显示「正在思考 · 持续了 N 秒」;收起时表头 + * 改为「正在思考 · 最新一行」持续刷新。结束后收起为「思考过程 · 持续了 N 秒」, * 点击表头可手动展开/收起。rows 由真实思考文本逐行喂入。 */ export function Thinking({ rows, streaming, - activeLabel = '思考中…', + activeLabel = '正在思考', doneLabel = '思考过程' }: { rows: { primary: string }[] @@ -18,25 +20,49 @@ export function Thinking({ const [manualExpanded, setManualExpanded] = useState(null) const expanded = manualExpanded ?? streaming + // 已思考时长:流式期间每秒递增;分段间歇(streaming 抖动)暂停但不清零 + const [secs, setSecs] = useState(0) + useEffect(() => { + if (!streaming) return + const t = setInterval(() => setSecs((s) => s + 1), 1000) + return () => clearInterval(t) + }, [streaming]) + + // 滚动吸底:新内容到达且当前贴着底部时跟随;用户上滚即取消,滚回底部恢复。 + // 收起时重置为吸底,重新展开直接落在最新内容。 + const scrollRef = useRef(null) + const pinnedRef = useRef(true) + const handleScroll = (): void => { + const el = scrollRef.current + if (!el) return + pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24 + } + useEffect(() => { + if (!expanded) { + pinnedRef.current = true + return + } + const el = scrollRef.current + if (el && pinnedRef.current) el.scrollTop = el.scrollHeight + }, [rows, expanded]) + // 流式轨迹的行必须「随到随显」:fade-up 的 both+delay 会让新行在延迟期间 // 透明但占高,流式期间底部因此持续留出一大片空白。交错入场只保留给 // 非流式一次性渲染的静态轨迹(挂载时决定,之后不变更,避免整段重播动画)。 const streamedMountRef = useRef(streaming) - const traceRef = useRef(null) - const [lineHeight, setLineHeight] = useState(0) - useLayoutEffect(() => { - if (traceRef.current) setLineHeight(traceRef.current.offsetHeight) - }, [rows, expanded]) + const statusWord = streaming ? activeLabel : expanded ? '思考' : doneLabel + const latest = rows.length > 0 ? rows[rows.length - 1].primary : '' + const suffix = expanded ? (secs > 0 ? `持续了 ${secs} 秒` : '') : streaming ? latest : '' return ( -
- {/* 表头:状态词 + 旋转箭头 */} +
+ {/* 表头:状态词 + 后缀(时长 / 最新一行)+ 旋转箭头 */} - {/* 展开轨迹:左侧 hairline + 逐行 fade-up */} + {/* 展开轨迹:限高滚动预览(最新在尾部),左侧 hairline 随内容延伸 */}
-
-
- -
+
+
+
+ {rows.map((row, i) => (
- {row.primary} + + {row.primary} +
))}