@springbrand/message-panel 0.1.3-alpha.33 → 0.1.3-alpha.36

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.
@@ -1,37 +1,29 @@
1
- import { Atom, CaretDown, CaretRight, Copy } from "@phosphor-icons/react";
2
- import { motion, useReducedMotion } from "motion/react";
3
- import { memo, useId, type ReactNode } from "react";
4
- import {
5
- MarkdownMessage,
6
- type CloudOsUrlResolver,
7
- } from "./markdown-message";
1
+ import { memo, useEffect, useId, useState } from "react";
2
+ import { CodeRunner } from "./code-runner";
3
+ import { MarkdownMessage } from "./markdown-message";
4
+ import { ToolCall } from "./tool-call";
5
+ import { ToolTimeline } from "./tool-timeline";
6
+ import { WebSearchToolCall } from "./web-search";
8
7
  import {
9
8
  getToolCallSummary,
10
- getToolIcon,
11
9
  normalizeOutputText,
10
+ safeStringify,
12
11
  type CloudOsToolCall,
13
12
  type PhosphorIcon,
14
13
  type ToolCallGroup,
15
14
  } from "./tool-presentation";
16
- import { useLifecycleDisclosure } from "./use-lifecycle-disclosure";
17
-
18
- const thinkingSpark = new URL(
19
- "../assets/thinking-spark.svg",
20
- import.meta.url,
21
- ).href;
22
15
 
23
- const SECONDARY_DISCLOSURE_TITLE_BASE =
24
- "flex min-w-0 cursor-pointer gap-2 rounded-xl text-left text-[14px] leading-[1.4] text-cos-work-description transition-colors duration-150 ease-out hover:text-cos-work-description-subtle focus-visible:text-cos-work-description-subtle focus-visible:outline-none active:scale-[0.995]";
16
+ const DISCLOSURE_TITLE_BASE =
17
+ "flex min-w-0 cursor-pointer gap-2 rounded-xl text-left text-[14px] leading-[1.4] text-kumo-inactive transition-colors duration-150 ease-out hover:text-kumo-subtle focus-visible:text-kumo-subtle focus-visible:outline-none active:scale-[0.995]";
25
18
 
26
- export const SECONDARY_DISCLOSURE_TITLE_WITH_ICON =
27
- `${SECONDARY_DISCLOSURE_TITLE_BASE} items-start px-1.5 py-1`;
19
+ export const DISCLOSURE_TITLE_WITH_ICON =
20
+ `${DISCLOSURE_TITLE_BASE} items-start py-1`;
28
21
 
29
- export const SECONDARY_DISCLOSURE_TITLE_WITHOUT_ICON =
30
- `${SECONDARY_DISCLOSURE_TITLE_BASE} items-center py-1 pr-1.5`;
31
-
32
- export const SECONDARY_DISCLOSURE_ICON =
22
+ export const DISCLOSURE_ICON =
33
23
  "mt-0.5 flex size-4 shrink-0 items-center justify-center";
34
24
 
25
+ const thinkingStar = new URL("../assets/thinking-star.svg", import.meta.url).href;
26
+
35
27
  /**
36
28
  * 工作行(work rows): WorkIcon / ToolCallDetails / NestedToolCallRow /
37
29
  * ToolGroupRow / ThinkingTraceRow。渲染 UIMessage 归一化后的 CloudOsToolCall。
@@ -56,76 +48,18 @@ function displayOutput(value: unknown, fallback: string): string {
56
48
  return displayValue(record);
57
49
  }
58
50
 
59
- interface ToolDetailItem {
60
- key: string;
61
- label: string;
62
- output: string;
63
- error: string;
64
- }
65
-
66
- function detailItems(calls: readonly CloudOsToolCall[]): ToolDetailItem[] {
67
- return calls.map((call) => {
68
- const summary = getToolCallSummary(call);
69
- return {
70
- key: call.toolCallId,
71
- label: `${summary.verb}${summary.target ? ` ${summary.target}` : ""}`,
72
- output: displayOutput(call.output, call.outputText),
73
- error: call.errorText,
74
- };
75
- });
76
- }
77
-
78
- function OutputCard({ items }: { items: readonly ToolDetailItem[] }) {
79
- const copyValue = items
80
- .map((item) => {
81
- const detail = [item.error, item.output]
82
- .filter(Boolean)
83
- .join("\n\n") || "No additional data";
84
- return items.length === 1 ? detail : `${item.label}\n${detail}`;
85
- })
86
- .join("\n\n");
87
- return (
88
- <div
89
- className="rounded-xl border border-[rgba(0,0,0,0.07)] bg-[rgba(0,0,0,0.03)] px-4 py-3 text-cos-output"
90
- data-output-card=""
91
- >
92
- <div className="flex items-center justify-between gap-3 text-[14px] font-medium leading-5">
93
- <span>Output</span>
94
- <button
95
- type="button"
96
- onClick={() => navigator.clipboard.writeText(copyValue)}
97
- className="flex h-4 w-4 items-center justify-center text-cos-output transition-colors hover:text-kumo-default focus-visible:outline-none focus-visible:text-kumo-default"
98
- aria-label="Copy output"
99
- >
100
- <Copy size={16} />
101
- </button>
102
- </div>
103
- <div className="mt-3 max-h-56 space-y-3 overflow-auto border-t border-kumo-line/70 pt-3 text-[14px] font-normal leading-[1.4]">
104
- {items.map((item) => (
105
- <div key={item.key} className="space-y-1.5">
106
- {items.length > 1 && (
107
- <p className="m-0 text-[12px] leading-4 text-cos-output">
108
- {item.label}
109
- </p>
110
- )}
111
- {item.error && (
112
- <p className="m-0 whitespace-pre-wrap text-kumo-danger">
113
- {item.error}
114
- </p>
115
- )}
116
- {item.output && (
117
- <p className="m-0 whitespace-pre-wrap text-cos-output">
118
- {item.output}
119
- </p>
120
- )}
121
- {!item.error && !item.output && (
122
- <p className="m-0 text-cos-output">No additional data</p>
123
- )}
124
- </div>
125
- ))}
126
- </div>
127
- </div>
128
- );
51
+ function toolCallView(tc: CloudOsToolCall) {
52
+ const settled = getToolCallSummary({ ...tc, running: false });
53
+ const active = getToolCallSummary({ ...tc, running: true });
54
+ return {
55
+ label: settled.verb,
56
+ activeLabel: active.verb,
57
+ query: settled.target ?? "",
58
+ result:
59
+ tc.errorText ||
60
+ displayOutput(tc.output, tc.outputText) ||
61
+ (tc.running ? "Running…" : "No additional data"),
62
+ };
129
63
  }
130
64
 
131
65
  export const ToolCallDetails = memo(function ToolCallDetails({
@@ -133,15 +67,14 @@ export const ToolCallDetails = memo(function ToolCallDetails({
133
67
  }: {
134
68
  toolCall: CloudOsToolCall;
135
69
  }) {
136
- return <OutputCard items={detailItems([tc])} />;
137
- });
138
-
139
- export const ToolGroupDetails = memo(function ToolGroupDetails({
140
- calls,
141
- }: {
142
- calls: readonly CloudOsToolCall[];
143
- }) {
144
- return <OutputCard items={detailItems(calls)} />;
70
+ return (
71
+ <ToolCall
72
+ {...toolCallView(tc)}
73
+ running={tc.running}
74
+ open
75
+ onOpenChange={() => undefined}
76
+ />
77
+ );
145
78
  });
146
79
 
147
80
  export const NestedToolCallRow = memo(function NestedToolCallRow({
@@ -153,64 +86,51 @@ export const NestedToolCallRow = memo(function NestedToolCallRow({
153
86
  open: boolean;
154
87
  onToggle: (key: string) => void;
155
88
  }) {
156
- const key = `call-${tc.toolCallId}`;
157
- const summary = getToolCallSummary(tc);
158
- const label = `${summary.verb}${summary.target ? ` ${summary.target}` : ""}`;
159
- const Icon = getToolIcon(tc.kind);
89
+ if (tc.kind === "web-search" && !tc.failed) {
90
+ return <WebSearchToolCall toolCall={tc} />;
91
+ }
160
92
 
161
- return (
162
- <div className="group/nested">
163
- <button
164
- type="button"
165
- onClick={() => onToggle(key)}
166
- className={`${SECONDARY_DISCLOSURE_TITLE_WITH_ICON} w-full`}
167
- aria-expanded={open}
93
+ if (tc.kind === "javascript") {
94
+ const code = typeof tc.input.code === "string"
95
+ ? tc.input.code
96
+ : safeStringify(tc.input);
97
+ const output =
98
+ tc.errorText || displayOutput(tc.output, tc.outputText);
99
+ const key = `call-${tc.toolCallId}`;
100
+ return (
101
+ <ToolCall
102
+ {...toolCallView(tc)}
103
+ running={tc.running}
104
+ open={open}
105
+ onOpenChange={(next) => {
106
+ if (next !== open) onToggle(key);
107
+ }}
108
+ className="max-w-none"
168
109
  >
169
- <span className={SECONDARY_DISCLOSURE_ICON}>
170
- <WorkIcon Icon={Icon} />
171
- </span>
172
- <span className="flex min-w-0 flex-1 items-center gap-2">
173
- <span className="min-w-0 truncate">{label}</span>
174
- {tc.failed && (
175
- <span className="flex-shrink-0 rounded-full bg-kumo-danger-tint px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.04em] text-kumo-danger">
176
- Error
177
- </span>
178
- )}
179
- <CaretRight
180
- size={16}
181
- className={`flex-shrink-0 text-kumo-inactive transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`}
182
- />
183
- </span>
184
- </button>
185
- {open && (
186
- <div className="mt-1 space-y-3">
187
- <ToolCallDetails toolCall={tc} />
188
- </div>
189
- )}
190
- </div>
191
- );
192
- });
110
+ <CodeRunner
111
+ language="javascript"
112
+ code={code}
113
+ state={tc.running ? "running" : tc.failed ? "error" : "ok"}
114
+ output={output ? output.split(/\r?\n/) : []}
115
+ className="max-w-none"
116
+ />
117
+ </ToolCall>
118
+ );
119
+ }
193
120
 
194
- function ThinkingContent({
195
- id,
196
- text,
197
- resolveUrl,
198
- className = "",
199
- }: {
200
- id?: string;
201
- text: string;
202
- resolveUrl?: CloudOsUrlResolver;
203
- className?: string;
204
- }) {
121
+ const key = `call-${tc.toolCallId}`;
205
122
  return (
206
- <div
207
- id={id}
208
- className={`cos-markdown max-h-[240px] overflow-y-auto rounded-xl border border-[rgba(0,0,0,0.07)] bg-[#F2F2F2] px-4 py-3 text-[14px] leading-[1.4] text-[rgba(0,0,0,0.7)] ${className}`}
209
- >
210
- <MarkdownMessage message={text} resolveUrl={resolveUrl} />
211
- </div>
123
+ <ToolCall
124
+ {...toolCallView(tc)}
125
+ running={tc.running}
126
+ open={open}
127
+ onOpenChange={(next) => {
128
+ if (next !== open) onToggle(key);
129
+ }}
130
+ className="max-w-none"
131
+ />
212
132
  );
213
- }
133
+ });
214
134
 
215
135
  export const ThinkingTraceRow = memo(function ThinkingTraceRow({
216
136
  reasoning,
@@ -219,7 +139,7 @@ export const ThinkingTraceRow = memo(function ThinkingTraceRow({
219
139
  reasoning: string;
220
140
  running: boolean;
221
141
  }) {
222
- const [open, toggle] = useLifecycleDisclosure(running);
142
+ const [open, setOpen] = useState(false);
223
143
  const contentId = useId();
224
144
  const summary = reasoning.replace(/\s+/g, " ").trim();
225
145
 
@@ -231,20 +151,13 @@ export const ThinkingTraceRow = memo(function ThinkingTraceRow({
231
151
  >
232
152
  <button
233
153
  type="button"
234
- onClick={toggle}
235
- className={`${SECONDARY_DISCLOSURE_TITLE_WITH_ICON} group w-fit max-w-[80%]`}
154
+ onClick={() => setOpen((current) => !current)}
155
+ className={`${DISCLOSURE_TITLE_WITH_ICON} group w-fit max-w-[80%]`}
236
156
  aria-expanded={open}
237
157
  aria-controls={contentId}
238
158
  >
239
- <span className={`${SECONDARY_DISCLOSURE_ICON} relative`} aria-hidden="true">
240
- <Atom
241
- size={15}
242
- className="transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0"
243
- />
244
- <CaretDown
245
- size={15}
246
- className={`absolute opacity-0 transition-[opacity,transform] group-hover:opacity-100 group-focus-visible:opacity-100 ${open ? "" : "-rotate-90"}`}
247
- />
159
+ <span className={DISCLOSURE_ICON} aria-hidden="true">
160
+ <img src={thinkingStar} alt="" className="size-[12.2369px]" />
248
161
  </span>
249
162
  <span className="flex-shrink-0">Think</span>
250
163
  <span aria-hidden="true">·</span>
@@ -256,185 +169,30 @@ export const ThinkingTraceRow = memo(function ThinkingTraceRow({
256
169
  </span>
257
170
  </button>
258
171
  {open && (
259
- <ThinkingContent
172
+ <div
260
173
  id={contentId}
261
- text={reasoning}
262
- className="w-full"
263
- />
264
- )}
265
- </div>
266
- );
267
- });
268
-
269
- export function formatWorkDuration(milliseconds: number): string {
270
- const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000));
271
- const hours = Math.floor(totalSeconds / 3600);
272
- const minutes = Math.floor((totalSeconds % 3600) / 60);
273
- const seconds = totalSeconds % 60;
274
- if (hours > 0) {
275
- return `${hours}h ${minutes}m${seconds > 0 ? ` ${seconds}s` : ""}`;
276
- }
277
- if (minutes > 0) return `${minutes}m${seconds > 0 ? ` ${seconds}s` : ""}`;
278
- return `${seconds}s`;
279
- }
280
-
281
- interface ActivitySummary {
282
- leading: string;
283
- emphasis?: string;
284
- trailing?: string;
285
- }
286
-
287
- function plural(count: number, singular: string, plural = `${singular}s`): string {
288
- return `${count} ${count === 1 ? singular : plural}`;
289
- }
290
-
291
- function activitySummary(groups: readonly ToolCallGroup[]): ActivitySummary | null {
292
- const calls = groups.flatMap((group) => group.calls);
293
- const count = (kind: CloudOsToolCall["kind"]) =>
294
- calls.filter((call) => call.kind === kind).length;
295
- const webSearches = count("web-search");
296
- const webPages = count("web-fetch");
297
- if (webSearches > 0 && webPages > 0) {
298
- return {
299
- leading: "searched the web and retrieved ",
300
- emphasis: `${webPages} web`,
301
- trailing: webPages === 1 ? " page" : " pages",
302
- };
303
- }
304
- if (webSearches > 0) return { leading: "searched the web" };
305
- if (webPages > 0) {
306
- return {
307
- leading: "retrieved ",
308
- emphasis: `${webPages} web`,
309
- trailing: webPages === 1 ? " page" : " pages",
310
- };
311
- }
312
-
313
- const firstKnown = calls.find((call) =>
314
- ["read", "write", "edit", "bash", "skill", "javascript"].includes(
315
- call.kind,
316
- ),
317
- );
318
- if (!firstKnown) return null;
319
- const total = count(firstKnown.kind);
320
- switch (firstKnown.kind) {
321
- case "read":
322
- return { leading: `read ${plural(total, "file")}` };
323
- case "write":
324
- return { leading: `wrote ${plural(total, "file")}` };
325
- case "edit":
326
- return { leading: `made ${plural(total, "edit")}` };
327
- case "bash":
328
- return { leading: `ran ${plural(total, "command")}` };
329
- case "skill":
330
- return { leading: `read ${plural(total, "instruction set")}` };
331
- case "javascript":
332
- return { leading: total === 1 ? "ran code" : `ran code ${total} times` };
333
- default:
334
- return null;
335
- }
336
- }
337
-
338
- export const WorkTraceDisclosure = memo(function WorkTraceDisclosure({
339
- durationMs,
340
- groups,
341
- completed = false,
342
- open,
343
- onToggle,
344
- children,
345
- }: {
346
- durationMs?: number;
347
- groups: readonly ToolCallGroup[];
348
- completed?: boolean;
349
- open: boolean;
350
- onToggle: () => void;
351
- children: ReactNode;
352
- }) {
353
- const contentId = useId();
354
- const reduceMotion = useReducedMotion();
355
- const activity = activitySummary(groups);
356
- const duration = durationMs === undefined ? "" : ` for ${formatWorkDuration(durationMs)}`;
357
- const verb = completed ? "Worked" : "Thought";
358
- const label = `${verb}${duration}${activity ? `, ${activity.leading}${activity.emphasis ?? ""}${activity.trailing ?? ""}` : ""}`;
359
-
360
- return (
361
- <div data-agent-work="" data-state={open ? "open" : "closed"}>
362
- <div className="w-full pb-2">
363
- <button
364
- type="button"
365
- onClick={onToggle}
366
- aria-expanded={open}
367
- aria-controls={contentId}
368
- aria-label={label}
369
- className="flex min-w-0 items-center gap-2 text-left text-[14px] leading-[1.4] text-cos-work-description transition-colors duration-150 ease-out hover:text-cos-work-description-subtle focus-visible:text-cos-work-description-subtle focus-visible:outline-none"
174
+ className="cos-markdown w-full px-4 text-[14px] leading-[1.4] text-kumo-default/70"
370
175
  >
371
- {!completed && (
372
- <span className="grid size-4 shrink-0 place-items-center" aria-hidden="true">
373
- <motion.img
374
- src={thinkingSpark}
375
- alt=""
376
- className="size-[12.24px]"
377
- initial={{ rotate: 0 }}
378
- animate={
379
- reduceMotion ? { rotate: 0 } : { rotate: [0, 360, -360, -360] }
380
- }
381
- transition={
382
- reduceMotion
383
- ? { duration: 0 }
384
- : {
385
- rotate: {
386
- duration: 1.68,
387
- times: [0, 0.8368, 0.8373, 1],
388
- ease: [
389
- [0.5, 0, 0.5, 1],
390
- [0.5, 0, 0.5, 1],
391
- "linear",
392
- ],
393
- repeat: Infinity,
394
- },
395
- }
396
- }
397
- />
398
- </span>
399
- )}
400
- <span className="min-w-0 truncate">
401
- <span>{`${verb}${duration}${activity ? `, ${activity.leading}` : ""}`}</span>
402
- {activity?.emphasis && (
403
- <span className="text-kumo-default">{activity.emphasis}</span>
404
- )}
405
- {activity?.trailing}
406
- </span>
407
- <CaretRight
408
- size={16}
409
- className={`shrink-0 text-kumo-inactive transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`}
410
- aria-hidden="true"
411
- />
412
- </button>
413
- </div>
414
- {open && (
415
- <div id={contentId} className="mt-4 space-y-3" data-agent-work-content="">
416
- {children}
176
+ <MarkdownMessage message={reasoning} />
417
177
  </div>
418
178
  )}
419
179
  </div>
420
180
  );
421
181
  });
422
182
 
423
- export const WorkDescriptionRow = memo(function WorkDescriptionRow({
424
- text,
425
- resolveUrl,
426
- }: {
427
- text: string;
428
- running?: boolean;
429
- resolveUrl?: CloudOsUrlResolver;
430
- }) {
431
- return (
432
- <ThinkingContent
433
- text={text}
434
- resolveUrl={resolveUrl}
435
- />
436
- );
437
- });
183
+ function useElapsedSeconds(running: boolean) {
184
+ const [elapsed, setElapsed] = useState(0);
185
+ useEffect(() => {
186
+ if (!running) return undefined;
187
+ const startedAt = Date.now() - elapsed * 1000;
188
+ const timer = window.setInterval(
189
+ () => setElapsed(Math.floor((Date.now() - startedAt) / 1000)),
190
+ 1000,
191
+ );
192
+ return () => window.clearInterval(timer);
193
+ }, [running]);
194
+ return elapsed;
195
+ }
438
196
 
439
197
  export const ToolGroupRow = memo(function ToolGroupRow({
440
198
  group,
@@ -447,48 +205,57 @@ export const ToolGroupRow = memo(function ToolGroupRow({
447
205
  expandedKeys: ReadonlySet<string>;
448
206
  onToggle: (key: string) => void;
449
207
  }) {
450
- void expandedKeys;
208
+ const elapsed = useElapsedSeconds(group.hasRunning && group.calls.length > 1);
209
+ const onlyCall = group.calls.length === 1 ? group.calls[0] : undefined;
210
+ if (onlyCall) {
211
+ return (
212
+ <NestedToolCallRow
213
+ toolCall={onlyCall}
214
+ open={expandedKeys.has(`call-${onlyCall.toolCallId}`)}
215
+ onToggle={onToggle}
216
+ />
217
+ );
218
+ }
219
+ if (group.calls.every((call) => call.kind === "web-search")) {
220
+ return (
221
+ <div className="flex w-full max-w-sm flex-col gap-4">
222
+ {group.calls.map((toolCall) => (
223
+ <NestedToolCallRow
224
+ key={toolCall.toolCallId}
225
+ toolCall={toolCall}
226
+ open={expandedKeys.has(`call-${toolCall.toolCallId}`)}
227
+ onToggle={onToggle}
228
+ />
229
+ ))}
230
+ </div>
231
+ );
232
+ }
451
233
  return (
452
- <div className="group/tool-call">
453
- <button
454
- type="button"
455
- onClick={() => onToggle(group.key)}
456
- className={SECONDARY_DISCLOSURE_TITLE_WITH_ICON}
457
- aria-expanded={open}
458
- >
459
- <span className={SECONDARY_DISCLOSURE_ICON}>
460
- <WorkIcon Icon={group.Icon} />
461
- </span>
462
- <span className="min-w-0 flex-1">
463
- <span className="flex min-w-0 items-center gap-2">
464
- <span
465
- className={`min-w-0 truncate ${group.hasRunning ? "cos-thinking-shimmer" : ""}`}
466
- >
467
- {group.label}
468
- </span>
469
- {group.hasError && (
470
- <span className="flex-shrink-0 rounded-full bg-kumo-danger-tint px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.04em] text-kumo-danger">
471
- Error
472
- </span>
473
- )}
474
- <CaretRight
475
- size={16}
476
- className={`shrink-0 text-kumo-inactive transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`}
477
- />
478
- </span>
479
- {group.detailLines.length > 1 && (
480
- <span className="mt-2 block truncate text-[12px] leading-4 text-[rgba(0,0,0,0.3)]">
481
- {group.detailLines.join(" · ")}
482
- </span>
483
- )}
484
- </span>
485
- </button>
486
- {open && (
487
- <div className="mt-3">
488
- <ToolGroupDetails calls={group.calls} />
489
- </div>
490
- )}
491
- </div>
234
+ <ToolTimeline
235
+ steps={[]}
236
+ visibleSteps={0}
237
+ streaming={group.hasRunning}
238
+ open={open}
239
+ onOpenChange={(next) => {
240
+ if (next !== open) onToggle(group.key);
241
+ }}
242
+ restingLabel={group.label}
243
+ activeLabel={`Working for ${elapsed}s`}
244
+ stats={[]}
245
+ className="max-w-none"
246
+ >
247
+ {group.calls.map((toolCall) => {
248
+ const key = `call-${toolCall.toolCallId}`;
249
+ return (
250
+ <NestedToolCallRow
251
+ key={toolCall.toolCallId}
252
+ toolCall={toolCall}
253
+ open={expandedKeys.has(key)}
254
+ onToggle={onToggle}
255
+ />
256
+ );
257
+ })}
258
+ </ToolTimeline>
492
259
  );
493
260
  });
494
261