@steerable/agent-shell-web 0.6.26 → 0.6.28

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.
Files changed (49) hide show
  1. package/package.json +4 -4
  2. package/src/components/AgentSidebar.tsx +24 -24
  3. package/src/components/BrandLockup.tsx +1 -1
  4. package/src/components/ChatHeader.tsx +5 -15
  5. package/src/components/LocalLlmSettingsModal.tsx +5 -5
  6. package/src/components/TerminalPanel.tsx +1 -1
  7. package/src/components/chat/ApprovalModal.tsx +5 -5
  8. package/src/components/chat/AskUserQuestionMenu.tsx +13 -13
  9. package/src/components/chat/AssistantMessage.tsx +41 -13
  10. package/src/components/chat/ChatInput.tsx +24 -24
  11. package/src/components/chat/ExecPolicyPicker.tsx +4 -4
  12. package/src/components/chat/ExecutedActionsCard.tsx +10 -3
  13. package/src/components/chat/FilePathCode.tsx +2 -1
  14. package/src/components/chat/LocalChatPanel.tsx +39 -4
  15. package/src/components/chat/Markdown.tsx +15 -14
  16. package/src/components/chat/MessageList.tsx +39 -16
  17. package/src/components/chat/ModelPicker.tsx +6 -6
  18. package/src/components/chat/SessionTodoList.tsx +137 -0
  19. package/src/components/chat/SessionTreeModal.tsx +3 -3
  20. package/src/components/chat/SuggestedReplies.tsx +5 -4
  21. package/src/components/chat/TaskPanelModal.tsx +3 -3
  22. package/src/components/chat/TaskProcessPanel.tsx +17 -11
  23. package/src/components/chat/TurnFilesCard.tsx +2 -2
  24. package/src/components/chat/TurnProcessGroup.tsx +281 -117
  25. package/src/components/chat/UserMessage.tsx +4 -4
  26. package/src/components/chat/next-steps-tags.ts +28 -0
  27. package/src/components/chat/process-status.ts +125 -32
  28. package/src/components/chat/todo-list-model.ts +167 -0
  29. package/src/components/chat/turn-timeline.ts +100 -11
  30. package/src/components/settings/AgentsSettingsPanel.tsx +2 -2
  31. package/src/components/settings/AppearanceSettingsPanel.tsx +43 -30
  32. package/src/components/settings/DiagnoseSettingsPanel.tsx +2 -2
  33. package/src/components/settings/InsightsSettingsPanel.tsx +4 -4
  34. package/src/components/settings/LlmSettingsPanel.tsx +10 -10
  35. package/src/components/settings/McpSettingsPanel.tsx +3 -3
  36. package/src/components/settings/ModelIdCombobox.tsx +2 -2
  37. package/src/components/settings/SecuritySettingsPanel.tsx +1 -1
  38. package/src/components/settings/SettingsSaveButton.tsx +1 -1
  39. package/src/components/settings/SkillsSettingsPanel.tsx +2 -2
  40. package/src/components/settings/TelemetrySettingsPanel.tsx +2 -2
  41. package/src/components/settings/UsagePanel.tsx +14 -10
  42. package/src/components/settings/WebSearchSettingsPanel.tsx +2 -2
  43. package/src/layouts/AgentLayout.tsx +3 -3
  44. package/src/lib/chat-transport.ts +68 -12
  45. package/src/lib/show-thinking-content.ts +40 -15
  46. package/src/pages/AgentPage.tsx +56 -6
  47. package/src/pages/ChatPanelPreviewPage.tsx +88 -8
  48. package/src/pages/SettingsPage.tsx +39 -39
  49. package/src/styles/index.css +12 -2
@@ -1,125 +1,275 @@
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
- import { processStatusLabel } from './process-status';
8
- import { processHasReasoning, splitTurnProcess, type TurnBlock } from './turn-timeline';
10
+ import {
11
+ estimateReasoningDurationMs,
12
+ processStatusLabel,
13
+ thinkingFoldLabel,
14
+ } from './process-status';
15
+ import { splitTurnProcess, type TurnBlock } from './turn-timeline';
9
16
 
10
17
  /**
11
- * 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
- * elapsed. The summary itself stays outside the fold.
18
+ * Codex / DeepSeek-style turn process. Each LLM round keeps three surfaces
19
+ * distinct: 思考 (muted CoT), 思考后的 response (reply bubble), 工具
20
+ * (cards). Trailing text becomes 最后一次结论 only after the stream ends —
21
+ * promoting it earlier would restyle it as thinking when the next round
22
+ * starts. Settings 「显示思考内容」 only clip the 思考正文: hidden /
23
+ * 5-line peek / full. A finished 思考 fold auto-collapses in chat;
24
+ * only the live round stays open. Tools and the work row stay independent.
16
25
  */
17
26
 
18
- export const THINKING_PEEK_LINES = 7;
27
+ export const THINKING_PEEK_LINES = 5;
28
+ /** 思考正文行高(`leading-snug`)。 */
29
+ const THINKING_LINE_HEIGHT = 1.375;
30
+ /**
31
+ * 最多 5 行:用 max-height,短思考按内容收,不撑空。
32
+ * 不用 CSS `lh`:Windows Electron 在中文字体尚未就绪时 `lh` 会算成 0,
33
+ * 思考 peek 整块消失,字体加载后又把主列表高度撑跳。
34
+ */
35
+ export const THINKING_PEEK_HEIGHT = `${THINKING_PEEK_LINES * THINKING_LINE_HEIGHT}em`;
36
+
37
+ const THINKING_TEXT =
38
+ 'border-l border-agent-border/70 pl-2 text-xs leading-snug text-agent-muted-foreground';
39
+
40
+ function splitThinkingParagraphs(content: string): string[] {
41
+ return content.split(/\n{2,}/).filter((part) => part.length > 0);
42
+ }
43
+
44
+ function ReasoningBody({
45
+ content,
46
+ showCursor,
47
+ }: {
48
+ content: string;
49
+ showCursor?: boolean;
50
+ }) {
51
+ const parts = splitThinkingParagraphs(content);
52
+ return (
53
+ <>
54
+ <div className="space-y-1">
55
+ {parts.map((part, index) => (
56
+ <div key={index} className="whitespace-pre-wrap break-words">
57
+ {part}
58
+ </div>
59
+ ))}
60
+ </div>
61
+ {showCursor ? (
62
+ <span className="ml-0.5 inline-block h-3 w-[2px] animate-agent-cursor-blink bg-agent-muted-foreground/60 align-text-bottom" />
63
+ ) : null}
64
+ </>
65
+ );
66
+ }
19
67
 
20
- function useLiveElapsedMs(
21
- startedAtMs: number | undefined,
22
- enabled: boolean,
23
- ): number | undefined {
68
+ function resolveThinkingDisplay(
69
+ preference: ThinkingDisplayMode,
70
+ thinkingDisplay?: ThinkingDisplayMode,
71
+ showThinkingContent?: boolean,
72
+ ): ThinkingDisplayMode {
73
+ if (thinkingDisplay) return thinkingDisplay;
74
+ if (showThinkingContent === true) return 'full';
75
+ if (showThinkingContent === false) return 'peek';
76
+ return preference;
77
+ }
78
+
79
+ function useLiveNow(enabled: boolean): number {
24
80
  const [now, setNow] = useState(() => Date.now());
25
81
  useEffect(() => {
26
- if (!enabled || startedAtMs == null) return;
82
+ if (!enabled) return;
27
83
  setNow(Date.now());
28
84
  const id = window.setInterval(() => setNow(Date.now()), 1000);
29
85
  return () => window.clearInterval(id);
30
- }, [enabled, startedAtMs]);
31
- if (startedAtMs == null) return undefined;
32
- return Math.max(0, now - startedAtMs);
86
+ }, [enabled]);
87
+ return now;
33
88
  }
34
89
 
35
- function ProcessBlocks({
90
+ function ReasoningPane({
91
+ content,
92
+ showCursor,
93
+ clipped,
94
+ }: {
95
+ content: string;
96
+ showCursor?: boolean;
97
+ clipped?: boolean;
98
+ }) {
99
+ const scrollerRef = useRef<HTMLDivElement>(null);
100
+ useLayoutEffect(() => {
101
+ if (!clipped) return;
102
+ const el = scrollerRef.current;
103
+ if (!el) return;
104
+ el.scrollTop = el.scrollHeight;
105
+ }, [clipped, content]);
106
+ const body = (
107
+ <div className={THINKING_TEXT}>
108
+ <ReasoningBody content={content} showCursor={showCursor} />
109
+ </div>
110
+ );
111
+ if (!clipped) return body;
112
+ return (
113
+ <div
114
+ ref={scrollerRef}
115
+ className="overflow-y-auto overflow-anchor-none [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
116
+ style={{ maxHeight: THINKING_PEEK_HEIGHT }}
117
+ data-thinking-peek=""
118
+ data-testid="thinking-peek"
119
+ data-peek-lines={THINKING_PEEK_LINES}
120
+ >
121
+ {body}
122
+ </div>
123
+ );
124
+ }
125
+
126
+ function ThinkingFold({
127
+ content,
128
+ label,
129
+ showCursor,
130
+ mode,
131
+ defaultOpen,
132
+ }: {
133
+ content: string;
134
+ label: string;
135
+ showCursor?: boolean;
136
+ mode: ThinkingDisplayMode;
137
+ defaultOpen: boolean;
138
+ }) {
139
+ const [open, setOpen] = useState(defaultOpen);
140
+ useEffect(() => {
141
+ setOpen(defaultOpen);
142
+ }, [defaultOpen]);
143
+
144
+ if (mode === 'hidden') return null;
145
+
146
+ return (
147
+ <div className="space-y-1" data-testid="turn-thinking">
148
+ <button
149
+ type="button"
150
+ onClick={() => setOpen((value) => !value)}
151
+ className="flex min-w-0 items-center gap-1 py-0.5 text-left text-[11px] text-agent-muted-foreground/80 transition-colors hover:text-agent-foreground"
152
+ aria-expanded={open}
153
+ data-thinking-fold=""
154
+ >
155
+ <span className="truncate">{label}</span>
156
+ {open ? (
157
+ <LuChevronDown className="h-3 w-3 shrink-0" />
158
+ ) : (
159
+ <LuChevronRight className="h-3 w-3 shrink-0" />
160
+ )}
161
+ </button>
162
+ {open ? (
163
+ <ReasoningPane
164
+ content={content}
165
+ showCursor={showCursor}
166
+ clipped={mode === 'peek'}
167
+ />
168
+ ) : null}
169
+ </div>
170
+ );
171
+ }
172
+
173
+ function ProcessBlockItems({
36
174
  blocks,
37
175
  isStreaming,
38
176
  agents,
39
177
  chats,
40
178
  chatId,
179
+ thinkingDisplay,
180
+ keepFinishedThinkingOpen = false,
181
+ thinkingElapsedByIndex,
41
182
  }: {
42
183
  blocks: TurnBlock[];
43
184
  isStreaming: boolean;
44
185
  agents: LocalChatAgent[];
45
186
  chats: LocalChat[];
46
187
  chatId?: string | null;
188
+ thinkingDisplay: ThinkingDisplayMode;
189
+ keepFinishedThinkingOpen?: boolean;
190
+ thinkingElapsedByIndex?: Array<number | undefined>;
47
191
  }) {
48
192
  const lastIndex = blocks.length - 1;
49
193
  return (
50
- <div className="space-y-1.5 border-l border-agent-border/70 pl-3">
194
+ <>
51
195
  {blocks.map((block, index) => {
52
196
  const isLast = index === lastIndex;
53
197
  if (block.type === 'reasoning') {
198
+ const isLive = isStreaming && isLast;
199
+ const elapsedMs =
200
+ thinkingElapsedByIndex?.[index]
201
+ ?? block.durationMs
202
+ ?? (isLive ? undefined : estimateReasoningDurationMs(block.content));
54
203
  return (
55
- <div
204
+ <ThinkingFold
56
205
  key={`reasoning-${index}`}
57
- className="text-xs leading-relaxed text-agent-muted-foreground"
58
- >
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
- )}
63
- </div>
206
+ content={block.content}
207
+ label={thinkingFoldLabel({
208
+ content: block.content,
209
+ isLive,
210
+ elapsedMs,
211
+ })}
212
+ showCursor={isLive}
213
+ mode={thinkingDisplay}
214
+ defaultOpen={isLive || keepFinishedThinkingOpen}
215
+ />
64
216
  );
65
217
  }
66
218
  if (block.type === 'tools') {
67
- return <ToolsFlow key={`tools-${index}`} actions={block.actions} compact />;
219
+ return (
220
+ <div key={`tools-${index}`} data-testid="turn-tools">
221
+ <ToolsFlow actions={block.actions} compact />
222
+ </div>
223
+ );
68
224
  }
69
225
  return (
70
- <div
71
- key={`text-${index}`}
72
- className="markdown-content text-xs leading-relaxed text-agent-muted-foreground"
73
- >
74
- <Markdown agents={agents} chats={chats} chatId={chatId}>{block.content}</Markdown>
226
+ <div key={`text-${index}`} data-testid="turn-response">
227
+ <div>
228
+ <div className="markdown-content text-xs leading-relaxed text-agent-foreground">
229
+ <Markdown agents={agents} chats={chats} chatId={chatId}>{block.content}</Markdown>
230
+ </div>
231
+ {isStreaming && isLast ? (
232
+ <span className="ml-0.5 inline-block h-3.5 w-[3px] animate-agent-cursor-blink bg-agent-foreground/60 align-text-bottom" />
233
+ ) : null}
234
+ </div>
75
235
  </div>
76
236
  );
77
237
  })}
78
- </div>
238
+ </>
79
239
  );
80
240
  }
81
241
 
82
- function ThinkingPeek({
242
+ function ProcessBlocks({
83
243
  blocks,
84
244
  isStreaming,
85
245
  agents,
86
246
  chats,
87
247
  chatId,
248
+ thinkingDisplay,
249
+ keepFinishedThinkingOpen,
250
+ thinkingElapsedByIndex,
88
251
  }: {
89
252
  blocks: TurnBlock[];
90
253
  isStreaming: boolean;
91
254
  agents: LocalChatAgent[];
92
255
  chats: LocalChat[];
93
256
  chatId?: string | null;
257
+ thinkingDisplay: ThinkingDisplayMode;
258
+ keepFinishedThinkingOpen: boolean;
259
+ thinkingElapsedByIndex?: Array<number | undefined>;
94
260
  }) {
95
- const scrollerRef = useRef<HTMLDivElement>(null);
96
- const reasoning = blocks.filter(
97
- (block): block is Extract<TurnBlock, { type: 'reasoning' }> =>
98
- block.type === 'reasoning' && block.content.trim().length > 0,
99
- );
100
- const sig = reasoning.map((block) => block.content.length).join(',');
101
- useLayoutEffect(() => {
102
- const el = scrollerRef.current;
103
- if (!el) return;
104
- el.scrollTop = el.scrollHeight;
105
- }, [sig]);
106
- const lastIndex = reasoning.length - 1;
107
261
  return (
108
- <div
109
- ref={scrollerRef}
110
- className="h-[7lh] overflow-hidden border-l border-agent-border/70 pl-3 text-xs leading-relaxed text-agent-muted-foreground"
111
- data-thinking-peek=""
112
- data-testid="thinking-peek"
113
- data-peek-lines={THINKING_PEEK_LINES}
114
- >
115
- {reasoning.map((block, index) => (
116
- <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
- )}
121
- </div>
122
- ))}
262
+ <div className="space-y-1.5">
263
+ <ProcessBlockItems
264
+ blocks={blocks}
265
+ isStreaming={isStreaming}
266
+ agents={agents}
267
+ chats={chats}
268
+ chatId={chatId}
269
+ thinkingDisplay={thinkingDisplay}
270
+ keepFinishedThinkingOpen={keepFinishedThinkingOpen}
271
+ thinkingElapsedByIndex={thinkingElapsedByIndex}
272
+ />
123
273
  </div>
124
274
  );
125
275
  }
@@ -134,7 +284,9 @@ export function TurnProcessGroup({
134
284
  streamingHint,
135
285
  startedAtMs,
136
286
  durationMs,
287
+ thinkingDisplay: thinkingDisplayProp,
137
288
  showThinkingContent: showThinkingContentProp,
289
+ collapseWhenFinished = true,
138
290
  renderAnswer,
139
291
  }: {
140
292
  blocks: TurnBlock[];
@@ -149,25 +301,48 @@ export function TurnProcessGroup({
149
301
  startedAtMs?: number;
150
302
  /** Frozen wall-clock of a finished turn (metadata or just-ended stream). */
151
303
  durationMs?: number;
304
+ /** Override the settings preference (hidden / peek / full). */
305
+ thinkingDisplay?: ThinkingDisplayMode;
152
306
  /**
153
- * Override the settings preference. Omitted = read 「显示思考内容」.
307
+ * Legacy override. `true` = full, `false` = peek.
154
308
  * Task process panel passes true so the inspector stays expanded.
155
309
  */
156
310
  showThinkingContent?: boolean;
311
+ /**
312
+ * After the stream ends, fold the work row to the summary line.
313
+ * Task inspector sets false so the replay stays open.
314
+ */
315
+ collapseWhenFinished?: boolean;
157
316
  renderAnswer: (block: Extract<TurnBlock, { type: 'text' }>, isLast: boolean) => ReactNode;
158
317
  }) {
159
- const preference = useShowThinkingContent();
160
- const showThinkingContent = showThinkingContentProp ?? preference;
161
- const { process, answer } = splitTurnProcess(blocks);
162
- const [open, setOpen] = useState(
163
- () => showThinkingContent && (isStreaming || answer.length === 0),
318
+ const preference = useThinkingDisplay();
319
+ const thinkingDisplay = resolveThinkingDisplay(
320
+ preference,
321
+ thinkingDisplayProp,
322
+ showThinkingContentProp,
164
323
  );
165
- const wasStreamingRef = useRef(isStreaming);
166
- const isStreamingRef = useRef(isStreaming);
324
+ const showFullThinking = thinkingDisplay === 'full';
325
+ const { process, answer } = splitTurnProcess(blocks, { finalize: !isStreaming });
326
+ const defaultOpen = isStreaming
327
+ ? thinkingDisplay !== 'hidden'
328
+ : showFullThinking && !collapseWhenFinished;
329
+ const [userOpen, setUserOpen] = useState<boolean | null>(null);
330
+ const open = userOpen ?? defaultOpen;
331
+ useEffect(() => {
332
+ setUserOpen(null);
333
+ }, [isStreaming]);
167
334
  const answerLenRef = useRef(answer.length);
168
- isStreamingRef.current = isStreaming;
169
335
  answerLenRef.current = answer.length;
170
- const liveElapsedMs = useLiveElapsedMs(startedAtMs, isStreaming);
336
+ const now = useLiveNow(isStreaming);
337
+ const streamClockRef = useRef<number | undefined>(undefined);
338
+ if (isStreaming) {
339
+ streamClockRef.current ??= startedAtMs ?? now;
340
+ } else {
341
+ streamClockRef.current = undefined;
342
+ }
343
+ const liveElapsedMs = isStreaming
344
+ ? Math.max(0, now - (startedAtMs ?? streamClockRef.current ?? now))
345
+ : undefined;
171
346
  const lastLiveElapsedRef = useRef<number | undefined>(undefined);
172
347
  if (isStreaming && liveElapsedMs != null) {
173
348
  lastLiveElapsedRef.current = liveElapsedMs;
@@ -177,72 +352,67 @@ export function TurnProcessGroup({
177
352
  : durationMs ?? lastLiveElapsedRef.current;
178
353
 
179
354
  const lastProcess = process[process.length - 1];
355
+ const lastIsReasoning = lastProcess?.type === 'reasoning';
356
+ const reasoningDurationsRef = useRef<number[]>([]);
180
357
  const reasoningClockRef = useRef<{ index: number; startedAt: number } | null>(null);
181
- if (isStreaming && lastProcess?.type === 'reasoning') {
358
+ if (isStreaming && lastIsReasoning) {
182
359
  const index = process.length - 1;
183
360
  if (reasoningClockRef.current?.index !== index) {
184
- reasoningClockRef.current = { index, startedAt: Date.now() };
361
+ const prev = reasoningClockRef.current;
362
+ if (prev) {
363
+ reasoningDurationsRef.current[prev.index] = Math.max(0, now - prev.startedAt);
364
+ }
365
+ reasoningClockRef.current = { index, startedAt: now };
185
366
  }
186
- } else if (!isStreaming) {
367
+ } else if (reasoningClockRef.current) {
368
+ const prev = reasoningClockRef.current;
369
+ reasoningDurationsRef.current[prev.index] = Math.max(0, now - prev.startedAt);
187
370
  reasoningClockRef.current = null;
188
371
  }
189
- const reasoningElapsedMs =
190
- isStreaming && lastProcess?.type === 'reasoning' && reasoningClockRef.current
191
- ? Math.max(0, Date.now() - reasoningClockRef.current.startedAt)
192
- : undefined;
193
-
194
- useEffect(() => {
195
- if (!showThinkingContent) {
196
- setOpen(false);
197
- return;
198
- }
199
- if (isStreamingRef.current || answerLenRef.current === 0) setOpen(true);
200
- }, [showThinkingContent]);
201
-
202
- useEffect(() => {
203
- const wasStreaming = wasStreamingRef.current;
204
- if (wasStreaming && !isStreaming && answer.length > 0) {
205
- setOpen(false);
372
+ const thinkingElapsedByIndex = process.map((block, index) => {
373
+ if (block.type !== 'reasoning') return undefined;
374
+ if (
375
+ isStreaming &&
376
+ lastIsReasoning &&
377
+ index === process.length - 1 &&
378
+ reasoningClockRef.current
379
+ ) {
380
+ return Math.max(0, now - reasoningClockRef.current.startedAt);
206
381
  }
207
- if (!wasStreaming && isStreaming) {
208
- setOpen(showThinkingContent);
209
- }
210
- wasStreamingRef.current = isStreaming;
211
- }, [isStreaming, answer.length, showThinkingContent]);
382
+ return reasoningDurationsRef.current[index];
383
+ });
212
384
 
213
385
  if (blocks.length === 0) return <>{emptyFallback}</>;
214
386
 
215
387
  const showToggle = process.length > 0;
216
388
  const showProcess = !showToggle || open;
217
- const showThinkingPeek =
218
- isStreaming && !open && showToggle && processHasReasoning(process);
219
389
  const showStreamingHint =
220
390
  Boolean(streamingHint) && isStreaming && answer.length === 0 && !showToggle;
221
391
 
222
392
  return (
223
- <div className="space-y-1.5" data-turn-timeline>
393
+ <div className="space-y-1" data-turn-timeline>
224
394
  {showToggle && (
225
395
  <button
226
396
  type="button"
227
- onClick={() => setOpen((value) => !value)}
397
+ onClick={() => setUserOpen(!open)}
228
398
  className="flex w-full items-center gap-1 py-0.5 text-left text-xs text-agent-muted-foreground transition-colors hover:text-agent-foreground"
229
399
  aria-expanded={open}
230
400
  data-turn-process=""
401
+ data-testid="turn-process-toggle"
231
402
  data-open={open || undefined}
232
403
  >
233
- {open ? (
234
- <LuChevronDown className="h-3.5 w-3.5 shrink-0" />
235
- ) : (
236
- <LuChevronRight className="h-3.5 w-3.5 shrink-0" />
237
- )}
238
- <span className="truncate">
404
+ <span className="min-w-0 truncate">
239
405
  {processStatusLabel({
240
406
  process,
241
407
  isStreaming,
242
408
  elapsedMs,
243
- reasoningElapsedMs,
244
409
  })}
245
410
  </span>
411
+ {open ? (
412
+ <LuChevronDown className="h-3.5 w-3.5 shrink-0" />
413
+ ) : (
414
+ <LuChevronRight className="h-3.5 w-3.5 shrink-0" />
415
+ )}
246
416
  </button>
247
417
  )}
248
418
  {showProcess && (
@@ -252,15 +422,9 @@ export function TurnProcessGroup({
252
422
  agents={agents}
253
423
  chats={chats}
254
424
  chatId={chatId}
255
- />
256
- )}
257
- {showThinkingPeek && (
258
- <ThinkingPeek
259
- blocks={process}
260
- isStreaming={answer.length === 0}
261
- agents={agents}
262
- chats={chats}
263
- chatId={chatId}
425
+ thinkingDisplay={thinkingDisplay}
426
+ keepFinishedThinkingOpen={!collapseWhenFinished}
427
+ thinkingElapsedByIndex={thinkingElapsedByIndex}
264
428
  />
265
429
  )}
266
430
  {showStreamingHint ? streamingHint : null}
@@ -62,7 +62,7 @@ export function UserMessage({ message, agents = [], chats = [] }: UserMessagePro
62
62
 
63
63
  return (
64
64
  <motion.div
65
- className="group/message mb-2"
65
+ className="group/message mb-1"
66
66
  data-message-role="user"
67
67
  data-message-id={message.id}
68
68
  initial={{ opacity: 0, y: 20 }}
@@ -70,7 +70,7 @@ export function UserMessage({ message, agents = [], chats = [] }: UserMessagePro
70
70
  transition={{ duration: 0.3 }}
71
71
  >
72
72
  <div className="flex justify-start">
73
- <div className="mx-auto w-full max-w-[var(--chat-input-box-width)] rounded-lg border border-agent-border bg-agent-muted p-3 text-sm text-agent-foreground">
73
+ <div className="mx-auto w-full max-w-[var(--chat-input-box-width)] rounded-lg border border-agent-border bg-agent-muted px-2.5 py-2 text-xs text-agent-foreground">
74
74
  <div className="relative">
75
75
  <div
76
76
  ref={contentRef}
@@ -121,7 +121,7 @@ export function UserMessage({ message, agents = [], chats = [] }: UserMessagePro
121
121
  </button>
122
122
  )}
123
123
 
124
- <div className="mt-2 flex items-center justify-between">
124
+ <div className="mt-1 flex items-center justify-between">
125
125
  <button
126
126
  type="button"
127
127
  onClick={() => void copy()}
@@ -140,7 +140,7 @@ export function UserMessage({ message, agents = [], chats = [] }: UserMessagePro
140
140
  <LuCopy className="h-3 w-3" />
141
141
  )}
142
142
  </button>
143
- <div className="text-[13px] text-agent-muted-foreground">
143
+ <div className="text-[11px] text-agent-muted-foreground">
144
144
  {message.createdAt
145
145
  ? getFriendlyDate(new Date(message.createdAt))
146
146
  : getFriendlyDate(new Date())}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 助手回复里的自定义 `[next_steps]...[/next_steps]` 标签:
3
+ * 给追问建议当判断来源,气泡里只显示标签内的正文,不显示标签本身。
4
+ */
5
+
6
+ const COMPLETE_BLOCK_RE = /\[next_steps\]([\s\S]*?)\[\/next_steps\]/gi;
7
+ const OPEN_TAG = 'next_steps]';
8
+ const CLOSE_TAG = '/next_steps]';
9
+
10
+ function isIncompleteTagSuffix(suffix: string): boolean {
11
+ const body = suffix.slice(1).toLowerCase();
12
+ if (!body) return false;
13
+ return OPEN_TAG.startsWith(body) || CLOSE_TAG.startsWith(body);
14
+ }
15
+
16
+ function stripIncompleteTrailingTag(text: string): string {
17
+ const idx = text.lastIndexOf('[');
18
+ if (idx < 0) return text;
19
+ const suffix = text.slice(idx);
20
+ return isIncompleteTagSuffix(suffix) ? text.slice(0, idx) : text;
21
+ }
22
+
23
+ /** 去掉自定义标签,保留里面的建议正文。 */
24
+ export function stripNextStepsTags(text: string): string {
25
+ let out = (text ?? '').replace(COMPLETE_BLOCK_RE, '$1');
26
+ out = out.replace(/\[\/?next_steps\]/gi, '');
27
+ return stripIncompleteTrailingTag(out);
28
+ }