@steerable/agent-shell-web 0.6.26 → 0.6.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell-web",
3
- "version": "0.6.26",
3
+ "version": "0.6.27",
4
4
  "type": "module",
5
5
  "description": "Steerable framework — product-neutral renderer SPA source for @steerable/agent-shell (React + Vite). Consumed by product web entry points via the `@/` alias (anchored at this package's src/) and the createProductViteConfig factory. Published as a source package (src/), plus the compiled vite.base config factory (vite.base.js + .d.ts) for Node-side config loading.",
6
6
  "license": "Apache-2.0",
@@ -46,8 +46,8 @@
46
46
  "react-router-dom": "^6.30.0",
47
47
  "rehype-raw": "^7.0.0",
48
48
  "remark-gfm": "^4.0.1",
49
- "@steerable/agent-ui": "0.6.26",
50
- "@steerable/agent-protocol": "0.6.26"
49
+ "@steerable/agent-protocol": "0.6.27",
50
+ "@steerable/agent-ui": "0.6.27"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@tailwindcss/vite": "^4.3.0",
@@ -60,7 +60,7 @@
60
60
  "typescript": "^5.8.3",
61
61
  "vite": "^5.4.21",
62
62
  "vitest": "^3.2.4",
63
- "@steerable/pack-sdk": "0.6.26"
63
+ "@steerable/pack-sdk": "0.6.27"
64
64
  },
65
65
  "scripts": {
66
66
  "gen": "echo agent-shell-web",
@@ -173,8 +173,8 @@ export function MessageList({
173
173
  onSelectSuggestion,
174
174
  }: MessageListProps) {
175
175
  const containerRef = useRef<HTMLDivElement>(null);
176
- const endRef = useRef<HTMLDivElement>(null);
177
176
  const [isAtBottom, setIsAtBottom] = useState(true);
177
+ const isAtBottomRef = useRef(true);
178
178
 
179
179
  const visibleMessages = messages.filter(
180
180
  (m) => m.role === 'user' || m.role === 'assistant',
@@ -192,13 +192,18 @@ export function MessageList({
192
192
  if (!el) return;
193
193
  const atBottom =
194
194
  el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_THRESHOLD_PX;
195
+ isAtBottomRef.current = atBottom;
195
196
  setIsAtBottom(atBottom);
196
197
  }, []);
197
198
 
198
- const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
199
- const el = endRef.current;
199
+ const scrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
200
+ const el = containerRef.current;
200
201
  if (!el) return;
201
- el.scrollIntoView({ behavior, block: 'end' });
202
+ if (behavior === 'smooth') {
203
+ el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
204
+ return;
205
+ }
206
+ el.scrollTop = el.scrollHeight;
202
207
  }, []);
203
208
 
204
209
  useEffect(() => {
@@ -213,28 +218,30 @@ export function MessageList({
213
218
  // Snap to bottom on first paint, regardless of whether the user "was" at
214
219
  // the bottom — there's no "before" on mount.
215
220
  useLayoutEffect(() => {
216
- scrollToBottom('instant' as ScrollBehavior);
221
+ isAtBottomRef.current = true;
222
+ scrollToBottom('auto');
217
223
  setIsAtBottom(true);
218
224
  }, [scrollToBottom]);
219
225
 
220
- // Subsequent auto-scroll only when the user is already near the bottom,
221
- // so reading old messages doesn't get yanked.
226
+ // Stick to the bottom *before paint* while the user is still anchored.
227
+ // Smooth `scrollIntoView` on every reasoning token paints a frame mid-list
228
+ // then animates down — on Windows the classic scrollbar visibly jumps.
222
229
  const lastMessageId = visibleMessages[visibleMessages.length - 1]?.id;
223
230
  const lastContentLen = visibleMessages[visibleMessages.length - 1]?.content?.length ?? 0;
224
231
  const lastTimelineSig = currentTurnTimeline
225
232
  ?.map((block) => (block.type === 'tools' ? `t${block.actions.length}` : `c${block.content.length}`))
226
233
  .join('|') ?? '';
227
234
  const suggestedSig = suggestedReplies?.join('\0') ?? '';
228
- useEffect(() => {
229
- if (!isAtBottom) return;
230
- scrollToBottom('smooth');
231
- }, [lastMessageId, lastContentLen, lastTimelineSig, suggestedSig, isAtBottom, scrollToBottom]);
235
+ useLayoutEffect(() => {
236
+ if (!isAtBottomRef.current) return;
237
+ scrollToBottom('auto');
238
+ }, [lastMessageId, lastContentLen, lastTimelineSig, suggestedSig, scrollToBottom]);
232
239
 
233
240
  return (
234
241
  <div className="relative flex-1 overflow-hidden">
235
242
  <div
236
243
  ref={containerRef}
237
- className="h-full overflow-y-auto px-3 py-3"
244
+ className="h-full overflow-y-auto overflow-anchor-none px-3 py-3"
238
245
  >
239
246
  {visibleMessages.length === 0 ? (
240
247
  emptyState ?? null
@@ -359,13 +366,16 @@ export function MessageList({
359
366
  ) : null}
360
367
  </div>
361
368
  )}
362
- <div ref={endRef} />
363
369
  </div>
364
370
 
365
371
  {!isAtBottom && visibleMessages.length > 0 && (
366
372
  <button
367
373
  type="button"
368
- onClick={() => scrollToBottom('smooth')}
374
+ onClick={() => {
375
+ isAtBottomRef.current = true;
376
+ setIsAtBottom(true);
377
+ scrollToBottom('smooth');
378
+ }}
369
379
  className="absolute bottom-3 right-3 flex h-8 w-8 items-center justify-center rounded-full border border-agent-border bg-agent-canvas text-agent-foreground shadow-md transition-colors hover:bg-agent-foreground/5"
370
380
  title="回到底部"
371
381
  aria-label="回到底部"
@@ -35,16 +35,18 @@ export function TaskProcessPanel({
35
35
  const [error, setError] = useState<string | null>(null);
36
36
  const containerRef = useRef<HTMLDivElement>(null);
37
37
  const [isAtBottom, setIsAtBottom] = useState(true);
38
+ const isAtBottomRef = useRef(true);
38
39
 
39
40
  const checkAtBottom = useCallback(() => {
40
41
  const el = containerRef.current;
41
42
  if (!el) return;
42
43
  const atBottom =
43
44
  el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_THRESHOLD_PX;
45
+ isAtBottomRef.current = atBottom;
44
46
  setIsAtBottom(atBottom);
45
47
  }, []);
46
48
 
47
- const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
49
+ const scrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
48
50
  const el = containerRef.current;
49
51
  if (!el) return;
50
52
  if (behavior === 'smooth') {
@@ -65,20 +67,22 @@ export function TaskProcessPanel({
65
67
 
66
68
  // 换任务 / 首屏:没有「之前在不在底部」可言,直接钉到底。
67
69
  useLayoutEffect(() => {
68
- scrollToBottom('instant' as ScrollBehavior);
70
+ isAtBottomRef.current = true;
71
+ scrollToBottom('auto');
69
72
  setIsAtBottom(true);
70
73
  }, [inspected.id, scrollToBottom]);
71
74
 
72
- // live 增量只在用户还贴着底部时跟滚——往上翻看前面的工具调用时不要拽走。
75
+ // live 增量在 paint 前钉住底部。smooth scroll 会先露出一帧半截内容再往下
76
+ // 滑,Windows 经典滚动条就会上下跳。
73
77
  const timelineSig = blocks
74
78
  .map((block) =>
75
79
  block.type === 'tools' ? `t${block.actions.length}` : `c${block.content.length}`,
76
80
  )
77
81
  .join('|');
78
- useEffect(() => {
79
- if (!isAtBottom) return;
80
- scrollToBottom('smooth');
81
- }, [timelineSig, live, isAtBottom, scrollToBottom]);
82
+ useLayoutEffect(() => {
83
+ if (!isAtBottomRef.current) return;
84
+ scrollToBottom('auto');
85
+ }, [timelineSig, live, scrollToBottom]);
82
86
 
83
87
  useEffect(() => {
84
88
  let cancelled = false;
@@ -153,7 +157,7 @@ export function TaskProcessPanel({
153
157
  <div className="relative min-h-0 flex-1 overflow-hidden">
154
158
  <div
155
159
  ref={containerRef}
156
- className="h-full overflow-y-auto p-3"
160
+ className="h-full overflow-y-auto overflow-anchor-none p-3"
157
161
  data-testid="task-process-scroll"
158
162
  >
159
163
  {loading ? (
@@ -191,6 +195,7 @@ export function TaskProcessPanel({
191
195
  <button
192
196
  type="button"
193
197
  onClick={() => {
198
+ isAtBottomRef.current = true;
194
199
  setIsAtBottom(true);
195
200
  scrollToBottom('smooth');
196
201
  }}
@@ -1,7 +1,10 @@
1
1
  import { Fragment, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
2
2
  import { LuChevronDown, LuChevronRight } from 'react-icons/lu';
3
3
  import type { LocalChat, LocalChatAgent } from '@/lib/local-api';
4
- import { useShowThinkingContent } from '@/lib/show-thinking-content';
4
+ import {
5
+ useThinkingDisplay,
6
+ type ThinkingDisplayMode,
7
+ } from '@/lib/show-thinking-content';
5
8
  import { Markdown } from './Markdown';
6
9
  import { ToolsFlow } from './ExecutedActionsCard';
7
10
  import { processStatusLabel } from './process-status';
@@ -9,27 +12,56 @@ import { processHasReasoning, splitTurnProcess, type TurnBlock } from './turn-ti
9
12
 
10
13
  /**
11
14
  * Codex / DeepSeek-style turn process: think + tool rows stay in one
12
- * disclosure. Default is collapsed (settings 「显示思考内容」 off). While
13
- * streaming, a fixed 7-line peek shows the latest reasoning; it folds when
14
- * the turn ends. The status line still shows 思考中 / 工具名 / tok/s /
15
+ * disclosure. Settings 「显示思考内容」 has three modes: hidden / 5-line
16
+ * peek / full. The status line still shows 思考中 / 工具名 / tok/s /
15
17
  * elapsed. The summary itself stays outside the fold.
16
18
  */
17
19
 
18
- export const THINKING_PEEK_LINES = 7;
20
+ export const THINKING_PEEK_LINES = 5;
21
+ /**
22
+ * 5 行 `text-xs` + `leading-relaxed` 的稳定高度。
23
+ * 不用 CSS `lh`:Windows Electron 在中文字体尚未就绪时 `lh` 会算成 0,
24
+ * 思考 peek 整块消失,字体加载后又把主列表高度撑跳。
25
+ */
26
+ export const THINKING_PEEK_HEIGHT = `${THINKING_PEEK_LINES * 1.625}em`;
27
+
28
+ function ReasoningBody({
29
+ content,
30
+ showCursor,
31
+ }: {
32
+ content: string;
33
+ showCursor?: boolean;
34
+ }) {
35
+ return (
36
+ <>
37
+ <div className="whitespace-pre-wrap break-words">{content}</div>
38
+ {showCursor ? (
39
+ <span className="ml-0.5 inline-block h-3 w-[2px] animate-agent-cursor-blink bg-agent-muted-foreground/60 align-text-bottom" />
40
+ ) : null}
41
+ </>
42
+ );
43
+ }
44
+
45
+ function resolveThinkingDisplay(
46
+ preference: ThinkingDisplayMode,
47
+ thinkingDisplay?: ThinkingDisplayMode,
48
+ showThinkingContent?: boolean,
49
+ ): ThinkingDisplayMode {
50
+ if (thinkingDisplay) return thinkingDisplay;
51
+ if (showThinkingContent === true) return 'full';
52
+ if (showThinkingContent === false) return 'peek';
53
+ return preference;
54
+ }
19
55
 
20
- function useLiveElapsedMs(
21
- startedAtMs: number | undefined,
22
- enabled: boolean,
23
- ): number | undefined {
56
+ function useLiveNow(enabled: boolean): number {
24
57
  const [now, setNow] = useState(() => Date.now());
25
58
  useEffect(() => {
26
- if (!enabled || startedAtMs == null) return;
59
+ if (!enabled) return;
27
60
  setNow(Date.now());
28
61
  const id = window.setInterval(() => setNow(Date.now()), 1000);
29
62
  return () => window.clearInterval(id);
30
- }, [enabled, startedAtMs]);
31
- if (startedAtMs == null) return undefined;
32
- return Math.max(0, now - startedAtMs);
63
+ }, [enabled]);
64
+ return now;
33
65
  }
34
66
 
35
67
  function ProcessBlocks({
@@ -56,10 +88,7 @@ function ProcessBlocks({
56
88
  key={`reasoning-${index}`}
57
89
  className="text-xs leading-relaxed text-agent-muted-foreground"
58
90
  >
59
- <Markdown agents={agents} chats={chats} chatId={chatId}>{block.content}</Markdown>
60
- {isStreaming && isLast && (
61
- <span className="ml-0.5 inline-block h-3 w-[2px] animate-agent-cursor-blink bg-agent-muted-foreground/60 align-text-bottom" />
62
- )}
91
+ <ReasoningBody content={block.content} showCursor={isStreaming && isLast} />
63
92
  </div>
64
93
  );
65
94
  }
@@ -82,15 +111,9 @@ function ProcessBlocks({
82
111
  function ThinkingPeek({
83
112
  blocks,
84
113
  isStreaming,
85
- agents,
86
- chats,
87
- chatId,
88
114
  }: {
89
115
  blocks: TurnBlock[];
90
116
  isStreaming: boolean;
91
- agents: LocalChatAgent[];
92
- chats: LocalChat[];
93
- chatId?: string | null;
94
117
  }) {
95
118
  const scrollerRef = useRef<HTMLDivElement>(null);
96
119
  const reasoning = blocks.filter(
@@ -107,17 +130,15 @@ function ThinkingPeek({
107
130
  return (
108
131
  <div
109
132
  ref={scrollerRef}
110
- className="h-[7lh] overflow-hidden border-l border-agent-border/70 pl-3 text-xs leading-relaxed text-agent-muted-foreground"
133
+ className="overflow-y-auto overflow-anchor-none [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden border-l border-agent-border/70 pl-3 text-xs leading-relaxed text-agent-muted-foreground"
134
+ style={{ height: THINKING_PEEK_HEIGHT }}
111
135
  data-thinking-peek=""
112
136
  data-testid="thinking-peek"
113
137
  data-peek-lines={THINKING_PEEK_LINES}
114
138
  >
115
139
  {reasoning.map((block, index) => (
116
140
  <div key={`peek-reasoning-${index}`}>
117
- <Markdown agents={agents} chats={chats} chatId={chatId}>{block.content}</Markdown>
118
- {isStreaming && index === lastIndex && (
119
- <span className="ml-0.5 inline-block h-3 w-[2px] animate-agent-cursor-blink bg-agent-muted-foreground/60 align-text-bottom" />
120
- )}
141
+ <ReasoningBody content={block.content} showCursor={isStreaming && index === lastIndex} />
121
142
  </div>
122
143
  ))}
123
144
  </div>
@@ -134,6 +155,7 @@ export function TurnProcessGroup({
134
155
  streamingHint,
135
156
  startedAtMs,
136
157
  durationMs,
158
+ thinkingDisplay: thinkingDisplayProp,
137
159
  showThinkingContent: showThinkingContentProp,
138
160
  renderAnswer,
139
161
  }: {
@@ -149,25 +171,42 @@ export function TurnProcessGroup({
149
171
  startedAtMs?: number;
150
172
  /** Frozen wall-clock of a finished turn (metadata or just-ended stream). */
151
173
  durationMs?: number;
174
+ /** Override the settings preference (hidden / peek / full). */
175
+ thinkingDisplay?: ThinkingDisplayMode;
152
176
  /**
153
- * Override the settings preference. Omitted = read 「显示思考内容」.
177
+ * Legacy override. `true` = full, `false` = peek.
154
178
  * Task process panel passes true so the inspector stays expanded.
155
179
  */
156
180
  showThinkingContent?: boolean;
157
181
  renderAnswer: (block: Extract<TurnBlock, { type: 'text' }>, isLast: boolean) => ReactNode;
158
182
  }) {
159
- const preference = useShowThinkingContent();
160
- const showThinkingContent = showThinkingContentProp ?? preference;
183
+ const preference = useThinkingDisplay();
184
+ const thinkingDisplay = resolveThinkingDisplay(
185
+ preference,
186
+ thinkingDisplayProp,
187
+ showThinkingContentProp,
188
+ );
189
+ const showFullThinking = thinkingDisplay === 'full';
190
+ const showThinkingPeekAllowed = thinkingDisplay === 'peek';
161
191
  const { process, answer } = splitTurnProcess(blocks);
162
192
  const [open, setOpen] = useState(
163
- () => showThinkingContent && (isStreaming || answer.length === 0),
193
+ () => showFullThinking && (isStreaming || answer.length === 0),
164
194
  );
165
195
  const wasStreamingRef = useRef(isStreaming);
166
196
  const isStreamingRef = useRef(isStreaming);
167
197
  const answerLenRef = useRef(answer.length);
168
198
  isStreamingRef.current = isStreaming;
169
199
  answerLenRef.current = answer.length;
170
- const liveElapsedMs = useLiveElapsedMs(startedAtMs, isStreaming);
200
+ const now = useLiveNow(isStreaming);
201
+ const streamClockRef = useRef<number | undefined>(undefined);
202
+ if (isStreaming) {
203
+ streamClockRef.current ??= startedAtMs ?? now;
204
+ } else {
205
+ streamClockRef.current = undefined;
206
+ }
207
+ const liveElapsedMs = isStreaming
208
+ ? Math.max(0, now - (startedAtMs ?? streamClockRef.current ?? now))
209
+ : undefined;
171
210
  const lastLiveElapsedRef = useRef<number | undefined>(undefined);
172
211
  if (isStreaming && liveElapsedMs != null) {
173
212
  lastLiveElapsedRef.current = liveElapsedMs;
@@ -181,23 +220,23 @@ export function TurnProcessGroup({
181
220
  if (isStreaming && lastProcess?.type === 'reasoning') {
182
221
  const index = process.length - 1;
183
222
  if (reasoningClockRef.current?.index !== index) {
184
- reasoningClockRef.current = { index, startedAt: Date.now() };
223
+ reasoningClockRef.current = { index, startedAt: now };
185
224
  }
186
225
  } else if (!isStreaming) {
187
226
  reasoningClockRef.current = null;
188
227
  }
189
228
  const reasoningElapsedMs =
190
229
  isStreaming && lastProcess?.type === 'reasoning' && reasoningClockRef.current
191
- ? Math.max(0, Date.now() - reasoningClockRef.current.startedAt)
230
+ ? Math.max(0, now - reasoningClockRef.current.startedAt)
192
231
  : undefined;
193
232
 
194
233
  useEffect(() => {
195
- if (!showThinkingContent) {
234
+ if (!showFullThinking) {
196
235
  setOpen(false);
197
236
  return;
198
237
  }
199
238
  if (isStreamingRef.current || answerLenRef.current === 0) setOpen(true);
200
- }, [showThinkingContent]);
239
+ }, [showFullThinking]);
201
240
 
202
241
  useEffect(() => {
203
242
  const wasStreaming = wasStreamingRef.current;
@@ -205,17 +244,21 @@ export function TurnProcessGroup({
205
244
  setOpen(false);
206
245
  }
207
246
  if (!wasStreaming && isStreaming) {
208
- setOpen(showThinkingContent);
247
+ setOpen(showFullThinking);
209
248
  }
210
249
  wasStreamingRef.current = isStreaming;
211
- }, [isStreaming, answer.length, showThinkingContent]);
250
+ }, [isStreaming, answer.length, showFullThinking]);
212
251
 
213
252
  if (blocks.length === 0) return <>{emptyFallback}</>;
214
253
 
215
254
  const showToggle = process.length > 0;
216
255
  const showProcess = !showToggle || open;
217
256
  const showThinkingPeek =
218
- isStreaming && !open && showToggle && processHasReasoning(process);
257
+ showThinkingPeekAllowed &&
258
+ isStreaming &&
259
+ !open &&
260
+ showToggle &&
261
+ processHasReasoning(process);
219
262
  const showStreamingHint =
220
263
  Boolean(streamingHint) && isStreaming && answer.length === 0 && !showToggle;
221
264
 
@@ -255,13 +298,7 @@ export function TurnProcessGroup({
255
298
  />
256
299
  )}
257
300
  {showThinkingPeek && (
258
- <ThinkingPeek
259
- blocks={process}
260
- isStreaming={answer.length === 0}
261
- agents={agents}
262
- chats={chats}
263
- chatId={chatId}
264
- />
301
+ <ThinkingPeek blocks={process} isStreaming={answer.length === 0} />
265
302
  )}
266
303
  {showStreamingHint ? streamingHint : null}
267
304
  {answer.map((block, index) => (
@@ -35,6 +35,22 @@ export function formatTokenSpeed(tokens: number, elapsedMs: number): string | nu
35
35
  return `${shown} tok/s`;
36
36
  }
37
37
 
38
+ export function estimateProcessReasoningTokens(process: TurnBlock[]): number {
39
+ let tokens = 0;
40
+ for (const block of process) {
41
+ if (block.type === 'reasoning') tokens += estimateTextTokens(block.content);
42
+ }
43
+ return tokens;
44
+ }
45
+
46
+ function speedElapsedMs(
47
+ reasoningElapsedMs: number | undefined,
48
+ elapsedMs: number | undefined,
49
+ ): number {
50
+ if (reasoningElapsedMs != null && reasoningElapsedMs >= 400) return reasoningElapsedMs;
51
+ return elapsedMs ?? reasoningElapsedMs ?? 0;
52
+ }
53
+
38
54
  export function lastToolsAreRunning(process: TurnBlock[]): boolean {
39
55
  const last = process[process.length - 1];
40
56
  if (last?.type !== 'tools' || last.actions.length === 0) return false;
@@ -79,13 +95,18 @@ export function processStatusLabel(input: {
79
95
  if (names.length > 0) parts.push(names.join('、'));
80
96
  } else {
81
97
  parts.push('思考中');
82
- if (last?.type === 'reasoning') {
83
- const speed = formatTokenSpeed(
84
- estimateTextTokens(last.content),
85
- reasoningElapsedMs ?? elapsedMs ?? 0,
86
- );
87
- if (speed) parts.push(speed);
88
- }
98
+ const tokens =
99
+ last?.type === 'reasoning'
100
+ ? estimateTextTokens(last.content)
101
+ : estimateProcessReasoningTokens(process);
102
+ const speed = formatTokenSpeed(
103
+ tokens,
104
+ speedElapsedMs(
105
+ last?.type === 'reasoning' ? reasoningElapsedMs : undefined,
106
+ elapsedMs,
107
+ ),
108
+ );
109
+ if (speed) parts.push(speed);
89
110
  }
90
111
  if (elapsed) parts.push(elapsed);
91
112
  return parts.join(' · ');
@@ -1,43 +1,56 @@
1
- import { persistShowThinkingContent, useShowThinkingContent } from '@/lib/show-thinking-content';
1
+ import {
2
+ THINKING_DISPLAY_OPTIONS,
3
+ persistThinkingDisplay,
4
+ useThinkingDisplay,
5
+ } from '@/lib/show-thinking-content';
2
6
 
3
7
  /**
4
- * 界面偏好:思考过程正文默认折叠,状态行仍显示思考中 / 工具名 / 速度 / 时间。
8
+ * 界面偏好:思考过程正文按「隐藏 / 显示5行 / 完整显示」三档。
5
9
  * 立即写入 localStorage,不走设置页顶部的模型保存按钮。
6
10
  */
7
11
  export function AppearanceSettingsPanel() {
8
- const showThinkingContent = useShowThinkingContent();
12
+ const mode = useThinkingDisplay();
13
+ const current = THINKING_DISPLAY_OPTIONS.find((item) => item.mode === mode);
9
14
 
10
15
  return (
11
16
  <div
12
17
  className="space-y-3 rounded-agent-md border border-agent-border bg-agent-card p-4"
13
18
  data-testid="appearance-settings-panel"
14
19
  >
15
- <div className="flex items-start justify-between gap-4">
16
- <div className="min-w-0">
17
- <p className="text-sm font-medium text-agent-foreground">显示思考内容</p>
18
- <p className="mt-1 text-xs leading-relaxed text-agent-muted-foreground">
19
- 关闭后流式中只露出 7 行思考,结束后自动折叠。状态行仍显示思考中、工具名、速度和用时。
20
- </p>
21
- </div>
22
- <button
23
- type="button"
24
- role="switch"
25
- aria-checked={showThinkingContent}
26
- aria-label="显示思考内容"
27
- data-testid="show-thinking-content-toggle"
28
- onClick={() => persistShowThinkingContent(!showThinkingContent)}
29
- className={[
30
- 'relative mt-0.5 inline-flex h-5 w-9 shrink-0 items-center rounded-full px-0.5 transition-colors',
31
- showThinkingContent ? 'bg-agent-foreground' : 'bg-agent-foreground/25',
32
- ].join(' ')}
33
- >
34
- <span
35
- className={[
36
- 'h-4 w-4 rounded-full bg-agent-canvas shadow-sm transition-transform',
37
- showThinkingContent ? 'translate-x-4' : 'translate-x-0',
38
- ].join(' ')}
39
- />
40
- </button>
20
+ <div>
21
+ <p className="text-sm font-medium text-agent-foreground">显示思考内容</p>
22
+ <p className="mt-1 text-xs leading-relaxed text-agent-muted-foreground">
23
+ {current?.hint} 状态行仍显示思考中、工具名、速度和用时。
24
+ </p>
25
+ </div>
26
+ <div
27
+ className="flex w-full rounded-full border border-agent-border bg-agent-canvas p-0.5"
28
+ role="radiogroup"
29
+ aria-label="显示思考内容"
30
+ data-testid="thinking-display-toggle"
31
+ >
32
+ {THINKING_DISPLAY_OPTIONS.map((item) => {
33
+ const selected = mode === item.mode;
34
+ return (
35
+ <button
36
+ key={item.mode}
37
+ type="button"
38
+ role="radio"
39
+ aria-checked={selected}
40
+ aria-label={item.label}
41
+ data-testid={`thinking-display-${item.mode}`}
42
+ onClick={() => persistThinkingDisplay(item.mode)}
43
+ className={[
44
+ 'h-7 flex-1 rounded-full px-2 text-xs transition-colors',
45
+ selected
46
+ ? 'bg-agent-foreground/10 text-agent-foreground'
47
+ : 'text-agent-muted-foreground hover:text-agent-foreground',
48
+ ].join(' ')}
49
+ >
50
+ {item.label}
51
+ </button>
52
+ );
53
+ })}
41
54
  </div>
42
55
  </div>
43
56
  );
@@ -1,8 +1,8 @@
1
1
  /**
2
- * 聊天里「显示思考内容」的本机偏好。
2
+ * 聊天里「显示思考内容」的本机偏好:隐藏 / 显示 5 行 / 完整显示。
3
3
  *
4
- * 默认关闭:思考 / 工具过程折叠,只留状态行。打开后流式中自动展开。
5
- * 存在 localStorage,设置页开关立即生效,不走后端。
4
+ * 默认「显示 5 行」:流式中露出固定行数,结束后折叠,只留状态行。
5
+ * 存在 localStorage,设置页切换立即生效,不走后端。
6
6
  */
7
7
 
8
8
  import { useEffect, useState } from 'react';
@@ -10,32 +10,57 @@ import { useEffect, useState } from 'react';
10
10
  export const SHOW_THINKING_CONTENT_STORAGE_KEY = 'agent-show-thinking-content';
11
11
  export const SHOW_THINKING_CONTENT_EVENT = 'agent-show-thinking-content-changed';
12
12
 
13
- export function readShowThinkingContent(): boolean {
14
- if (typeof localStorage === 'undefined') return false;
15
- return localStorage.getItem(SHOW_THINKING_CONTENT_STORAGE_KEY) === '1';
13
+ export const THINKING_DISPLAY_MODES = ['hidden', 'peek', 'full'] as const;
14
+ export type ThinkingDisplayMode = (typeof THINKING_DISPLAY_MODES)[number];
15
+ export const DEFAULT_THINKING_DISPLAY: ThinkingDisplayMode = 'peek';
16
+
17
+ export const THINKING_DISPLAY_OPTIONS: ReadonlyArray<{
18
+ mode: ThinkingDisplayMode;
19
+ label: string;
20
+ hint: string;
21
+ }> = [
22
+ { mode: 'hidden', label: '隐藏', hint: '只保留状态行,不展示思考正文。' },
23
+ { mode: 'peek', label: '显示5行', hint: '流式中露出 5 行思考,结束后自动折叠。' },
24
+ { mode: 'full', label: '完整显示', hint: '流式中展开全部思考与工具过程,结束后可手动折叠。' },
25
+ ];
26
+
27
+ export function isThinkingDisplayMode(value: unknown): value is ThinkingDisplayMode {
28
+ return value === 'hidden' || value === 'peek' || value === 'full';
29
+ }
30
+
31
+ /** 兼容旧开关:'1' → 完整显示,'0' / 缺失 → 显示 5 行。 */
32
+ export function parseThinkingDisplay(raw: string | null | undefined): ThinkingDisplayMode {
33
+ if (isThinkingDisplayMode(raw)) return raw;
34
+ if (raw === '1') return 'full';
35
+ return DEFAULT_THINKING_DISPLAY;
36
+ }
37
+
38
+ export function readThinkingDisplay(): ThinkingDisplayMode {
39
+ if (typeof localStorage === 'undefined') return DEFAULT_THINKING_DISPLAY;
40
+ return parseThinkingDisplay(localStorage.getItem(SHOW_THINKING_CONTENT_STORAGE_KEY));
16
41
  }
17
42
 
18
- export function persistShowThinkingContent(show: boolean): void {
43
+ export function persistThinkingDisplay(mode: ThinkingDisplayMode): void {
19
44
  if (typeof localStorage !== 'undefined') {
20
- localStorage.setItem(SHOW_THINKING_CONTENT_STORAGE_KEY, show ? '1' : '0');
45
+ localStorage.setItem(SHOW_THINKING_CONTENT_STORAGE_KEY, mode);
21
46
  }
22
47
  if (typeof window === 'undefined') return;
23
48
  window.dispatchEvent(
24
- new CustomEvent<boolean>(SHOW_THINKING_CONTENT_EVENT, { detail: show }),
49
+ new CustomEvent<ThinkingDisplayMode>(SHOW_THINKING_CONTENT_EVENT, { detail: mode }),
25
50
  );
26
51
  }
27
52
 
28
53
  /** 当前页与跨标签的偏好变更都会刷新。 */
29
- export function useShowThinkingContent(): boolean {
30
- const [show, setShow] = useState(readShowThinkingContent);
54
+ export function useThinkingDisplay(): ThinkingDisplayMode {
55
+ const [mode, setMode] = useState(readThinkingDisplay);
31
56
  useEffect(() => {
32
57
  const onCustom = (event: Event) => {
33
- const detail = (event as CustomEvent<boolean>).detail;
34
- setShow(typeof detail === 'boolean' ? detail : readShowThinkingContent());
58
+ const detail = (event as CustomEvent<ThinkingDisplayMode>).detail;
59
+ setMode(isThinkingDisplayMode(detail) ? detail : readThinkingDisplay());
35
60
  };
36
61
  const onStorage = (event: StorageEvent) => {
37
62
  if (event.key === SHOW_THINKING_CONTENT_STORAGE_KEY) {
38
- setShow(event.newValue === '1');
63
+ setMode(parseThinkingDisplay(event.newValue));
39
64
  }
40
65
  };
41
66
  window.addEventListener(SHOW_THINKING_CONTENT_EVENT, onCustom);
@@ -45,5 +70,5 @@ export function useShowThinkingContent(): boolean {
45
70
  window.removeEventListener('storage', onStorage);
46
71
  };
47
72
  }, []);
48
- return show;
73
+ return mode;
49
74
  }
@@ -1,4 +1,4 @@
1
- import { useMemo, useState } from 'react';
1
+ import { useEffect, useMemo, useState } from 'react';
2
2
  import type { ChatMessage } from '@steerable/agent-protocol';
3
3
  import { LocalChatPanel } from '@/components/chat/LocalChatPanel';
4
4
  import { ChatHeader } from '@/components/ChatHeader';
@@ -123,17 +123,53 @@ const MOCK_TIMELINE: TurnBlock[] = [
123
123
  * StreamingStatus's "正在思考..." baseline.
124
124
  * • `tools-run` — in-flight bubble, round 2, 2 tools just ran; verifies
125
125
  * StreamingStatus's "已调用 N 个工具" + "Round N" badge interaction.
126
+ * • `reasoning-stream` — reasoning tokens arrive over time; verifies the
127
+ * 5-line peek stays visible and the list scrollbar sticks to the bottom.
126
128
  */
127
- type PreviewScene = 'static' | 'thinking' | 'tools-run';
129
+ type PreviewScene = 'static' | 'thinking' | 'tools-run' | 'reasoning-stream';
130
+
131
+ const STREAMING_REASONING = [
132
+ '先检查服务是否还能连上。',
133
+ '如果端口通了,再跑本目录的测试套件。',
134
+ '失败的话只读失败文件,不要整仓扫一遍。',
135
+ '断言颜色对不上时优先看 AssistantMessage 的默认色。',
136
+ '改完再复跑一次,确认没有带出新的失败。',
137
+ '工具调用保持最少:连通性检查、跑测试、必要时读文件。',
138
+ '最后用一两句说清楚结果和下一步。',
139
+ '如果还在思考,后面的句子会被 5 行窗口裁掉。',
140
+ '比较 a < b 时不要被 Markdown 当成 HTML 标签吃掉。',
141
+ '滚动条应一直钉在最下面,不要随着 token 上下跳。',
142
+ ].join('\n');
128
143
 
129
144
  const SCENES: { id: PreviewScene; label: string }[] = [
130
145
  { id: 'static', label: '静态历史' },
131
146
  { id: 'thinking', label: '流式 · 空内容' },
132
147
  { id: 'tools-run', label: '流式 · 工具已跑 · round 2' },
148
+ { id: 'reasoning-stream', label: '流式 · 推理打字' },
133
149
  ];
134
150
 
135
151
  export function ChatPanelPreviewPage() {
136
152
  const [scene, setScene] = useState<PreviewScene>('static');
153
+ const [streamedReasoning, setStreamedReasoning] = useState('');
154
+
155
+ useEffect(() => {
156
+ if (scene !== 'reasoning-stream') {
157
+ setStreamedReasoning('');
158
+ return;
159
+ }
160
+ setStreamedReasoning('');
161
+ let index = 0;
162
+ const id = window.setInterval(() => {
163
+ index += 2;
164
+ if (index >= STREAMING_REASONING.length) {
165
+ setStreamedReasoning(STREAMING_REASONING);
166
+ window.clearInterval(id);
167
+ return;
168
+ }
169
+ setStreamedReasoning(STREAMING_REASONING.slice(0, index));
170
+ }, 40);
171
+ return () => window.clearInterval(id);
172
+ }, [scene]);
137
173
 
138
174
  // Explicit struct type so TS doesn't try to narrow `actionsByMsgId` to the
139
175
  // union of {} | { m2: ... } — both shapes are valid `Record<string, ...>`
@@ -198,7 +234,7 @@ export function ChatPanelPreviewPage() {
198
234
  '改完再复跑一次,确认没有带出新的失败。',
199
235
  '工具调用保持最少:连通性检查、跑测试、必要时读文件。',
200
236
  '最后用一两句说清楚结果和下一步。',
201
- '如果还在思考,后面的句子会被 7 行窗口裁掉。',
237
+ '如果还在思考,后面的句子会被 5 行窗口裁掉。',
202
238
  ].join('\n'),
203
239
  },
204
240
  {
@@ -209,6 +245,28 @@ export function ChatPanelPreviewPage() {
209
245
  currentTurnStartedAtMs: Date.now() - 12_000,
210
246
  currentRound: 2,
211
247
  };
248
+ case 'reasoning-stream':
249
+ return {
250
+ messages: [
251
+ MOCK_MESSAGES[0],
252
+ {
253
+ id: 'm-streaming',
254
+ role: 'assistant',
255
+ agentId: MOCK_AGENT.id,
256
+ content: '',
257
+ createdAt: new Date().toISOString(),
258
+ },
259
+ ],
260
+ isStreaming: true,
261
+ actionsByMsgId: {} as Record<string, ExecutedAction[]>,
262
+ currentTurnActions: [],
263
+ timelineByMsgId: {} as Record<string, TurnBlock[]>,
264
+ currentTurnTimeline: streamedReasoning
265
+ ? [{ type: 'reasoning', content: streamedReasoning }]
266
+ : [],
267
+ currentTurnStartedAtMs: Date.now() - 1_000,
268
+ currentRound: 1,
269
+ };
212
270
  case 'static':
213
271
  default:
214
272
  return {
@@ -222,7 +280,7 @@ export function ChatPanelPreviewPage() {
222
280
  suggestedReplies: ['修一下失败的断言颜色', '把测试再跑一遍', '解释这次失败的原因'],
223
281
  };
224
282
  }
225
- }, [scene]);
283
+ }, [scene, streamedReasoning]);
226
284
 
227
285
  return (
228
286
  <div className="flex h-full w-full flex-col bg-agent-muted/30">