@steerable/agent-shell-web 0.6.27 → 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 +16 -3
  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 +5 -4
  23. package/src/components/chat/TurnFilesCard.tsx +2 -2
  24. package/src/components/chat/TurnProcessGroup.tsx +226 -99
  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 -53
  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 +3 -3
  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 +4 -4
  46. package/src/pages/AgentPage.tsx +56 -6
  47. package/src/pages/ChatPanelPreviewPage.tsx +26 -4
  48. package/src/pages/SettingsPage.tsx +39 -39
  49. package/src/styles/index.css +12 -2
@@ -7,23 +7,39 @@ import {
7
7
  } from '@/lib/show-thinking-content';
8
8
  import { Markdown } from './Markdown';
9
9
  import { ToolsFlow } from './ExecutedActionsCard';
10
- import { processStatusLabel } from './process-status';
11
- 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';
12
16
 
13
17
  /**
14
- * Codex / DeepSeek-style turn process: think + tool rows stay in one
15
- * disclosure. Settings 「显示思考内容」 has three modes: hidden / 5-line
16
- * peek / full. The status line still shows 思考中 / 工具名 / tok/s /
17
- * 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.
18
25
  */
19
26
 
20
27
  export const THINKING_PEEK_LINES = 5;
28
+ /** 思考正文行高(`leading-snug`)。 */
29
+ const THINKING_LINE_HEIGHT = 1.375;
21
30
  /**
22
- * 5 行 `text-xs` + `leading-relaxed` 的稳定高度。
31
+ * 最多 5 行:用 max-height,短思考按内容收,不撑空。
23
32
  * 不用 CSS `lh`:Windows Electron 在中文字体尚未就绪时 `lh` 会算成 0,
24
33
  * 思考 peek 整块消失,字体加载后又把主列表高度撑跳。
25
34
  */
26
- export const THINKING_PEEK_HEIGHT = `${THINKING_PEEK_LINES * 1.625}em`;
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
+ }
27
43
 
28
44
  function ReasoningBody({
29
45
  content,
@@ -32,9 +48,16 @@ function ReasoningBody({
32
48
  content: string;
33
49
  showCursor?: boolean;
34
50
  }) {
51
+ const parts = splitThinkingParagraphs(content);
35
52
  return (
36
53
  <>
37
- <div className="whitespace-pre-wrap break-words">{content}</div>
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>
38
61
  {showCursor ? (
39
62
  <span className="ml-0.5 inline-block h-3 w-[2px] animate-agent-cursor-blink bg-agent-muted-foreground/60 align-text-bottom" />
40
63
  ) : null}
@@ -64,83 +87,189 @@ function useLiveNow(enabled: boolean): number {
64
87
  return now;
65
88
  }
66
89
 
67
- 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({
68
174
  blocks,
69
175
  isStreaming,
70
176
  agents,
71
177
  chats,
72
178
  chatId,
179
+ thinkingDisplay,
180
+ keepFinishedThinkingOpen = false,
181
+ thinkingElapsedByIndex,
73
182
  }: {
74
183
  blocks: TurnBlock[];
75
184
  isStreaming: boolean;
76
185
  agents: LocalChatAgent[];
77
186
  chats: LocalChat[];
78
187
  chatId?: string | null;
188
+ thinkingDisplay: ThinkingDisplayMode;
189
+ keepFinishedThinkingOpen?: boolean;
190
+ thinkingElapsedByIndex?: Array<number | undefined>;
79
191
  }) {
80
192
  const lastIndex = blocks.length - 1;
81
193
  return (
82
- <div className="space-y-1.5 border-l border-agent-border/70 pl-3">
194
+ <>
83
195
  {blocks.map((block, index) => {
84
196
  const isLast = index === lastIndex;
85
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));
86
203
  return (
87
- <div
204
+ <ThinkingFold
88
205
  key={`reasoning-${index}`}
89
- className="text-xs leading-relaxed text-agent-muted-foreground"
90
- >
91
- <ReasoningBody content={block.content} showCursor={isStreaming && isLast} />
92
- </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
+ />
93
216
  );
94
217
  }
95
218
  if (block.type === 'tools') {
96
- 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
+ );
97
224
  }
98
225
  return (
99
- <div
100
- key={`text-${index}`}
101
- className="markdown-content text-xs leading-relaxed text-agent-muted-foreground"
102
- >
103
- <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>
104
235
  </div>
105
236
  );
106
237
  })}
107
- </div>
238
+ </>
108
239
  );
109
240
  }
110
241
 
111
- function ThinkingPeek({
242
+ function ProcessBlocks({
112
243
  blocks,
113
244
  isStreaming,
245
+ agents,
246
+ chats,
247
+ chatId,
248
+ thinkingDisplay,
249
+ keepFinishedThinkingOpen,
250
+ thinkingElapsedByIndex,
114
251
  }: {
115
252
  blocks: TurnBlock[];
116
253
  isStreaming: boolean;
254
+ agents: LocalChatAgent[];
255
+ chats: LocalChat[];
256
+ chatId?: string | null;
257
+ thinkingDisplay: ThinkingDisplayMode;
258
+ keepFinishedThinkingOpen: boolean;
259
+ thinkingElapsedByIndex?: Array<number | undefined>;
117
260
  }) {
118
- const scrollerRef = useRef<HTMLDivElement>(null);
119
- const reasoning = blocks.filter(
120
- (block): block is Extract<TurnBlock, { type: 'reasoning' }> =>
121
- block.type === 'reasoning' && block.content.trim().length > 0,
122
- );
123
- const sig = reasoning.map((block) => block.content.length).join(',');
124
- useLayoutEffect(() => {
125
- const el = scrollerRef.current;
126
- if (!el) return;
127
- el.scrollTop = el.scrollHeight;
128
- }, [sig]);
129
- const lastIndex = reasoning.length - 1;
130
261
  return (
131
- <div
132
- ref={scrollerRef}
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 }}
135
- data-thinking-peek=""
136
- data-testid="thinking-peek"
137
- data-peek-lines={THINKING_PEEK_LINES}
138
- >
139
- {reasoning.map((block, index) => (
140
- <div key={`peek-reasoning-${index}`}>
141
- <ReasoningBody content={block.content} showCursor={isStreaming && index === lastIndex} />
142
- </div>
143
- ))}
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
+ />
144
273
  </div>
145
274
  );
146
275
  }
@@ -157,6 +286,7 @@ export function TurnProcessGroup({
157
286
  durationMs,
158
287
  thinkingDisplay: thinkingDisplayProp,
159
288
  showThinkingContent: showThinkingContentProp,
289
+ collapseWhenFinished = true,
160
290
  renderAnswer,
161
291
  }: {
162
292
  blocks: TurnBlock[];
@@ -178,6 +308,11 @@ export function TurnProcessGroup({
178
308
  * Task process panel passes true so the inspector stays expanded.
179
309
  */
180
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;
181
316
  renderAnswer: (block: Extract<TurnBlock, { type: 'text' }>, isLast: boolean) => ReactNode;
182
317
  }) {
183
318
  const preference = useThinkingDisplay();
@@ -187,15 +322,16 @@ export function TurnProcessGroup({
187
322
  showThinkingContentProp,
188
323
  );
189
324
  const showFullThinking = thinkingDisplay === 'full';
190
- const showThinkingPeekAllowed = thinkingDisplay === 'peek';
191
- const { process, answer } = splitTurnProcess(blocks);
192
- const [open, setOpen] = useState(
193
- () => showFullThinking && (isStreaming || answer.length === 0),
194
- );
195
- const wasStreamingRef = useRef(isStreaming);
196
- const isStreamingRef = useRef(isStreaming);
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]);
197
334
  const answerLenRef = useRef(answer.length);
198
- isStreamingRef.current = isStreaming;
199
335
  answerLenRef.current = answer.length;
200
336
  const now = useLiveNow(isStreaming);
201
337
  const streamClockRef = useRef<number | undefined>(undefined);
@@ -216,76 +352,67 @@ export function TurnProcessGroup({
216
352
  : durationMs ?? lastLiveElapsedRef.current;
217
353
 
218
354
  const lastProcess = process[process.length - 1];
355
+ const lastIsReasoning = lastProcess?.type === 'reasoning';
356
+ const reasoningDurationsRef = useRef<number[]>([]);
219
357
  const reasoningClockRef = useRef<{ index: number; startedAt: number } | null>(null);
220
- if (isStreaming && lastProcess?.type === 'reasoning') {
358
+ if (isStreaming && lastIsReasoning) {
221
359
  const index = process.length - 1;
222
360
  if (reasoningClockRef.current?.index !== index) {
361
+ const prev = reasoningClockRef.current;
362
+ if (prev) {
363
+ reasoningDurationsRef.current[prev.index] = Math.max(0, now - prev.startedAt);
364
+ }
223
365
  reasoningClockRef.current = { index, startedAt: now };
224
366
  }
225
- } else if (!isStreaming) {
367
+ } else if (reasoningClockRef.current) {
368
+ const prev = reasoningClockRef.current;
369
+ reasoningDurationsRef.current[prev.index] = Math.max(0, now - prev.startedAt);
226
370
  reasoningClockRef.current = null;
227
371
  }
228
- const reasoningElapsedMs =
229
- isStreaming && lastProcess?.type === 'reasoning' && reasoningClockRef.current
230
- ? Math.max(0, now - reasoningClockRef.current.startedAt)
231
- : undefined;
232
-
233
- useEffect(() => {
234
- if (!showFullThinking) {
235
- setOpen(false);
236
- return;
237
- }
238
- if (isStreamingRef.current || answerLenRef.current === 0) setOpen(true);
239
- }, [showFullThinking]);
240
-
241
- useEffect(() => {
242
- const wasStreaming = wasStreamingRef.current;
243
- if (wasStreaming && !isStreaming && answer.length > 0) {
244
- setOpen(false);
245
- }
246
- if (!wasStreaming && isStreaming) {
247
- setOpen(showFullThinking);
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);
248
381
  }
249
- wasStreamingRef.current = isStreaming;
250
- }, [isStreaming, answer.length, showFullThinking]);
382
+ return reasoningDurationsRef.current[index];
383
+ });
251
384
 
252
385
  if (blocks.length === 0) return <>{emptyFallback}</>;
253
386
 
254
387
  const showToggle = process.length > 0;
255
388
  const showProcess = !showToggle || open;
256
- const showThinkingPeek =
257
- showThinkingPeekAllowed &&
258
- isStreaming &&
259
- !open &&
260
- showToggle &&
261
- processHasReasoning(process);
262
389
  const showStreamingHint =
263
390
  Boolean(streamingHint) && isStreaming && answer.length === 0 && !showToggle;
264
391
 
265
392
  return (
266
- <div className="space-y-1.5" data-turn-timeline>
393
+ <div className="space-y-1" data-turn-timeline>
267
394
  {showToggle && (
268
395
  <button
269
396
  type="button"
270
- onClick={() => setOpen((value) => !value)}
397
+ onClick={() => setUserOpen(!open)}
271
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"
272
399
  aria-expanded={open}
273
400
  data-turn-process=""
401
+ data-testid="turn-process-toggle"
274
402
  data-open={open || undefined}
275
403
  >
276
- {open ? (
277
- <LuChevronDown className="h-3.5 w-3.5 shrink-0" />
278
- ) : (
279
- <LuChevronRight className="h-3.5 w-3.5 shrink-0" />
280
- )}
281
- <span className="truncate">
404
+ <span className="min-w-0 truncate">
282
405
  {processStatusLabel({
283
406
  process,
284
407
  isStreaming,
285
408
  elapsedMs,
286
- reasoningElapsedMs,
287
409
  })}
288
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
+ )}
289
416
  </button>
290
417
  )}
291
418
  {showProcess && (
@@ -295,11 +422,11 @@ export function TurnProcessGroup({
295
422
  agents={agents}
296
423
  chats={chats}
297
424
  chatId={chatId}
425
+ thinkingDisplay={thinkingDisplay}
426
+ keepFinishedThinkingOpen={!collapseWhenFinished}
427
+ thinkingElapsedByIndex={thinkingElapsedByIndex}
298
428
  />
299
429
  )}
300
- {showThinkingPeek && (
301
- <ThinkingPeek blocks={process} isStreaming={answer.length === 0} />
302
- )}
303
430
  {showStreamingHint ? streamingHint : null}
304
431
  {answer.map((block, index) => (
305
432
  <Fragment key={`answer-${index}`}>
@@ -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
+ }