@springbrand/message-panel 0.1.3-alpha.9 → 0.2.0-alpha.38

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 (67) hide show
  1. package/cloud-os/README.md +4 -3
  2. package/cloud-os/assets/followup-arrow.svg +3 -0
  3. package/cloud-os/assets/loading-corner.svg +3 -0
  4. package/cloud-os/assets/loading-mark.svg +16 -0
  5. package/cloud-os/assets/loading-spark.svg +3 -0
  6. package/cloud-os/assets/model-selected.svg +5 -0
  7. package/cloud-os/assets/thinking-star.svg +9 -0
  8. package/cloud-os/capability-chip.tsx +1 -1
  9. package/cloud-os/chat/cloud-os-chat-messages.tsx +441 -98
  10. package/cloud-os/chat/code-runner.tsx +106 -0
  11. package/cloud-os/chat/image-generation.tsx +76 -0
  12. package/cloud-os/chat/loading-state.tsx +25 -0
  13. package/cloud-os/chat/markdown-message.tsx +144 -41
  14. package/cloud-os/chat/range.ts +8 -0
  15. package/cloud-os/chat/rich-blocks.tsx +285 -227
  16. package/cloud-os/chat/surfaces.tsx +90 -0
  17. package/cloud-os/chat/tool-call.tsx +94 -0
  18. package/cloud-os/chat/tool-presentation.ts +31 -22
  19. package/cloud-os/chat/tool-rows.tsx +195 -143
  20. package/cloud-os/chat/tool-timeline.tsx +123 -0
  21. package/cloud-os/chat/transcript-model.ts +258 -20
  22. package/cloud-os/chat/web-search.tsx +135 -0
  23. package/cloud-os/composer/cloud-os-chat-input.tsx +29 -88
  24. package/cloud-os/composer/cloud-os-model-select.tsx +105 -0
  25. package/cloud-os/file-view.tsx +195 -12
  26. package/cloud-os/index.ts +24 -3
  27. package/cloud-os/layout/cloud-os-workspace-split.tsx +60 -3
  28. package/cloud-os/primitives/collapsible.tsx +21 -0
  29. package/cloud-os/primitives/workshop-controls.tsx +2 -2
  30. package/cloud-os/styles/cloud-os.css +114 -1
  31. package/demo/camel-chat-showcase.tsx +21 -13
  32. package/demo/chat-scenarios.ts +214 -40
  33. package/demo/cloud-os-chat-showcase.tsx +37 -14
  34. package/demo/fixtures.ts +4 -5
  35. package/demo/message-panel-gallery.tsx +16 -2
  36. package/package.json +8 -4
  37. package/springbrand-pet/LICENSE.bloub +21 -0
  38. package/springbrand-pet/bot/cycles.test.ts +221 -0
  39. package/springbrand-pet/bot/cycles.ts +225 -0
  40. package/springbrand-pet/bot/decor.ts +285 -0
  41. package/springbrand-pet/bot/engine.test.ts +543 -0
  42. package/springbrand-pet/bot/engine.ts +558 -0
  43. package/springbrand-pet/bot/expressions.test.ts +135 -0
  44. package/springbrand-pet/bot/expressions.ts +192 -0
  45. package/springbrand-pet/bot/eyefit.ts +455 -0
  46. package/springbrand-pet/bot/face.test.ts +101 -0
  47. package/springbrand-pet/bot/face.ts +179 -0
  48. package/springbrand-pet/bot/math.ts +42 -0
  49. package/springbrand-pet/bot/profiles.ts +22 -0
  50. package/springbrand-pet/bot/repere.ts +31 -0
  51. package/springbrand-pet/bot/shape.test.ts +97 -0
  52. package/springbrand-pet/bot/shape.ts +310 -0
  53. package/springbrand-pet/bot/skins.test.ts +325 -0
  54. package/springbrand-pet/bot/skins.ts +161 -0
  55. package/springbrand-pet/bot/states.ts +616 -0
  56. package/springbrand-pet/drag.test.ts +19 -0
  57. package/springbrand-pet/index.tsx +569 -0
  58. package/springbrand-pet/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  59. package/springbrand-pet/vitest.config.ts +9 -0
  60. package/src/camel/camel-chat-messages.tsx +78 -78
  61. package/src/camel/camel-tool-presentation.tsx +19 -15
  62. package/src/camel/camel-turn.ts +1 -17
  63. package/src/composer/composer-attachments.tsx +1 -1
  64. package/src/composer/composer.tsx +2 -2
  65. package/src/contracts.ts +0 -2
  66. package/src/message-panel.tsx +1 -24
  67. package/src/parts/index.tsx +103 -64
@@ -0,0 +1,90 @@
1
+ "use client";
2
+
3
+ import type { ComponentProps } from "react";
4
+ import { useLayoutEffect, useRef, useState } from "react";
5
+ import { cn } from "../internal/cn";
6
+
7
+ export const paper = "bg-background border border-border/60 dark:bg-popover";
8
+
9
+ export const field = "bg-foreground/[0.04] dark:bg-foreground/[0.06]";
10
+
11
+ export const ghostButton =
12
+ "flex items-center justify-center rounded-full text-foreground/45 outline-none transition-[background-color,color,scale] duration-150 hover:bg-foreground/[0.06] hover:text-foreground/90 active:scale-[0.96] focus-visible:ring-1 focus-visible:ring-foreground/20 motion-reduce:transition-none dark:hover:bg-foreground/[0.09]";
13
+
14
+ export const labelSwap =
15
+ "col-start-1 row-start-1 flex w-max items-center gap-1.5 leading-none transition-[opacity,filter] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none";
16
+
17
+ export const labelSwapIn = "opacity-100 blur-none";
18
+
19
+ export const labelSwapOut =
20
+ "pointer-events-none select-none opacity-0 blur-[2px]";
21
+
22
+ export const collapsePanel =
23
+ "h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-200 ease-[cubic-bezier(0.32,0.72,0,1)] data-[ending-style]:h-0 data-[starting-style]:h-0 motion-reduce:transition-none";
24
+
25
+ export const mono = "font-mono text-[11px] tracking-tight";
26
+
27
+ export const codeScroll = "max-h-48 overflow-auto";
28
+
29
+ export const codeSurface = "w-max min-w-full";
30
+
31
+ export function ShimmerLabel({
32
+ active = true,
33
+ className,
34
+ ...props
35
+ }: ComponentProps<"span"> & { active?: boolean }) {
36
+ return (
37
+ <span
38
+ className={cn(active && "shimmer motion-reduce:animate-none", className)}
39
+ {...props}
40
+ />
41
+ );
42
+ }
43
+
44
+ export function SwapLabel({
45
+ active,
46
+ children,
47
+ className,
48
+ }: {
49
+ active: 0 | 1;
50
+ children: [React.ReactNode, React.ReactNode];
51
+ className?: string;
52
+ }) {
53
+ const layers = [useRef<HTMLSpanElement>(null), useRef<HTMLSpanElement>(null)];
54
+ const [width, setWidth] = useState<number | null>(null);
55
+
56
+ useLayoutEffect(() => {
57
+ const target = layers[active]?.current;
58
+ if (!target) return undefined;
59
+ const measure = () =>
60
+ setWidth(Math.ceil(target.getBoundingClientRect().width));
61
+ measure();
62
+ const observer = new ResizeObserver(measure);
63
+ observer.observe(target);
64
+ return () => observer.disconnect();
65
+ }, [active]);
66
+
67
+ return (
68
+ <span
69
+ style={width === null ? undefined : { width }}
70
+ className={cn(
71
+ "grid overflow-x-clip transition-[width] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none",
72
+ className,
73
+ )}
74
+ >
75
+ {children.map((layer, index) => (
76
+ <span
77
+ key={index}
78
+ ref={layers[index]}
79
+ aria-hidden={active !== index}
80
+ className={cn(
81
+ labelSwap,
82
+ active === index ? labelSwapIn : labelSwapOut,
83
+ )}
84
+ >
85
+ {layer}
86
+ </span>
87
+ ))}
88
+ </span>
89
+ );
90
+ }
@@ -0,0 +1,94 @@
1
+ "use client";
2
+
3
+ import { CheckIcon, ChevronRightIcon } from "lucide-react";
4
+ import {
5
+ Collapsible,
6
+ CollapsibleContent,
7
+ CollapsibleTrigger,
8
+ } from "../primitives/collapsible";
9
+ import { cn } from "../internal/cn";
10
+ import {
11
+ collapsePanel,
12
+ field,
13
+ mono,
14
+ ShimmerLabel,
15
+ SwapLabel,
16
+ } from "./surfaces";
17
+
18
+ export interface ToolCallProps {
19
+ label: string;
20
+ activeLabel: string;
21
+ query: string;
22
+ result: string;
23
+ running: boolean;
24
+ open: boolean;
25
+ onOpenChange: (open: boolean) => void;
26
+ className?: string;
27
+ children?: React.ReactNode;
28
+ }
29
+
30
+ export function ToolCall({
31
+ label,
32
+ activeLabel,
33
+ query,
34
+ result,
35
+ running,
36
+ open,
37
+ onOpenChange,
38
+ className,
39
+ children,
40
+ }: ToolCallProps) {
41
+ return (
42
+ <Collapsible
43
+ data-slot="tool-call"
44
+ open={open}
45
+ onOpenChange={onOpenChange}
46
+ className={cn("w-full max-w-sm", className)}
47
+ >
48
+ <CollapsibleTrigger className="group/trigger text-foreground/55 hover:text-foreground/90 flex items-center gap-2 rounded-md py-1 text-[13.5px] transition-colors outline-none">
49
+ <ChevronRightIcon className="size-3.5 shrink-0 opacity-60 transition-transform duration-200 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-open/trigger:rotate-90 group-data-panel-open/trigger:rotate-90 motion-reduce:transition-none" />
50
+ <SwapLabel active={running ? 0 : 1} className="text-start">
51
+ <ShimmerLabel
52
+ active={running}
53
+ className="relative inline-block leading-none"
54
+ >
55
+ {activeLabel}
56
+ </ShimmerLabel>
57
+ <>{label}</>
58
+ </SwapLabel>
59
+ {query && (
60
+ <span
61
+ className={cn(
62
+ mono,
63
+ "bg-foreground/[0.06] text-foreground/70 rounded-md px-1.5 py-0.5",
64
+ )}
65
+ >
66
+ {query}
67
+ </span>
68
+ )}
69
+ <span className="ms-auto flex w-4 items-center justify-end">
70
+ {!running && (
71
+ <CheckIcon className="fade-in zoom-in-90 animate-in size-3.5 text-emerald-500 duration-200" />
72
+ )}
73
+ </span>
74
+ </CollapsibleTrigger>
75
+ <CollapsibleContent className={cn(collapsePanel, "outline-none")}>
76
+ {children ? (
77
+ <div className="mt-2">{children}</div>
78
+ ) : (
79
+ <div className={cn(field, "mt-2 overflow-hidden rounded-2xl text-xs")}>
80
+ <div className="px-3.5 py-2.5">
81
+ <p className={cn(mono, "text-foreground/35 mb-1")}>Result</p>
82
+ <p
83
+ className="text-foreground/90 overflow-auto leading-4"
84
+ style={{ maxHeight: "5rem" }}
85
+ >
86
+ {result}
87
+ </p>
88
+ </div>
89
+ </div>
90
+ )}
91
+ </CollapsibleContent>
92
+ </Collapsible>
93
+ );
94
+ }
@@ -213,7 +213,7 @@ export function canonicalToolKind(name: string): CloudOsToolKind {
213
213
  return "web-fetch";
214
214
  }
215
215
  if (
216
- ["javascript", "js_exec", "node_repl", "execute_javascript", "execute_code"].includes(
216
+ ["javascript", "js_exec", "node_repl", "execute", "execute_javascript", "execute_code"].includes(
217
217
  normalized,
218
218
  )
219
219
  ) {
@@ -298,7 +298,8 @@ export function getToolCallSummary(call: CloudOsToolCall): {
298
298
  return { verb: running ? "Editing" : "Edited", target: filename || undefined };
299
299
  case "bash": {
300
300
  const command =
301
- stringField(input, "description") || stringField(input, "command", "cmd");
301
+ stringField(input, "description") ||
302
+ stringField(input, "script", "command", "cmd");
302
303
  return {
303
304
  verb: running ? "Running" : "Ran",
304
305
  target: command ? truncate(command, 60) : undefined,
@@ -328,16 +329,8 @@ export function getToolCallSummary(call: CloudOsToolCall): {
328
329
  target: url ? hostnameOf(url) : undefined,
329
330
  };
330
331
  }
331
- case "javascript": {
332
- const firstLine = stringField(input, "code")
333
- .split("\n")
334
- .map((line) => line.trim())
335
- .find((line) => line.length > 0);
336
- return {
337
- verb: running ? "Running code" : "Ran code",
338
- target: firstLine ? truncate(firstLine, 60) : undefined,
339
- };
340
- }
332
+ case "javascript":
333
+ return { verb: running ? "Running code" : "Ran code" };
341
334
  case "tasks":
342
335
  return { verb: running ? "Updating plan" : "Updated plan" };
343
336
  case "agent": {
@@ -498,7 +491,7 @@ export function toCloudOsToolCall(
498
491
  *
499
492
  * - `pending`:还等着用户操作,选项可点。**只有 input 态才算 pending** ——
500
493
  * 服务端一旦结算,这次 interaction 就不再接受响应。
501
- * - `answered`:`ask_user` 的 settle 写了 `{ selections, text }`。
494
+ * - `answered`:`ask_user` 的 settle 写了按问题对应的 `{ answers }`。
502
495
  * - `closed`:被结算掉了但不是一个答案 —— 用户改成直接发言(`{ cancelled: true }`)、
503
496
  * Turn 提前结束、或工具出错。必须和 pending 分开,否则卡片会留一个点了拿
504
497
  * `{ ok: false }` 的假 Submit,读起来就是「点了没反应」。
@@ -508,7 +501,14 @@ export function toCloudOsToolCall(
508
501
  */
509
502
  export type AskUserOutcome =
510
503
  | { kind: "pending" }
511
- | { kind: "answered"; selections: string[]; text: string }
504
+ | {
505
+ kind: "answered";
506
+ answers: Array<{
507
+ question: string;
508
+ selections: string[];
509
+ text: string;
510
+ }>;
511
+ }
512
512
  | { kind: "closed"; reason: "user_replied_freeform" | "other" };
513
513
 
514
514
  export function askUserOutcome(call: CloudOsToolCall): AskUserOutcome {
@@ -516,14 +516,23 @@ export function askUserOutcome(call: CloudOsToolCall): AskUserOutcome {
516
516
  return { kind: "pending" };
517
517
  }
518
518
  const record = recordOf(call.output);
519
- if (Array.isArray(record.selections)) {
520
- return {
521
- kind: "answered",
522
- selections: record.selections.filter(
523
- (value): value is string => typeof value === "string",
524
- ),
525
- text: typeof record.text === "string" ? record.text : "",
526
- };
519
+ if (Array.isArray(record.answers) && record.answers.length > 0) {
520
+ const answers = record.answers.map(recordOf);
521
+ if (answers.every((answer) =>
522
+ typeof answer.question === "string" &&
523
+ Array.isArray(answer.selections) &&
524
+ answer.selections.every((value) => typeof value === "string") &&
525
+ (answer.text === undefined || typeof answer.text === "string")
526
+ )) {
527
+ return {
528
+ kind: "answered",
529
+ answers: answers.map((answer) => ({
530
+ question: answer.question as string,
531
+ selections: answer.selections as string[],
532
+ text: typeof answer.text === "string" ? answer.text : "",
533
+ })),
534
+ };
535
+ }
527
536
  }
528
537
  return {
529
538
  kind: "closed",
@@ -1,9 +1,11 @@
1
- import { CaretRight } from "@phosphor-icons/react";
2
- import { memo } from "react";
1
+ import { memo, useEffect, useId, useState } from "react";
2
+ import { CodeRunner } from "./code-runner";
3
3
  import { MarkdownMessage } from "./markdown-message";
4
+ import { ToolCall } from "./tool-call";
5
+ import { ToolTimeline } from "./tool-timeline";
6
+ import { WebSearchToolCall } from "./web-search";
4
7
  import {
5
8
  getToolCallSummary,
6
- getToolIcon,
7
9
  normalizeOutputText,
8
10
  safeStringify,
9
11
  type CloudOsToolCall,
@@ -11,31 +13,53 @@ import {
11
13
  type ToolCallGroup,
12
14
  } from "./tool-presentation";
13
15
 
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]";
18
+
19
+ export const DISCLOSURE_TITLE_WITH_ICON =
20
+ `${DISCLOSURE_TITLE_BASE} items-start py-1`;
21
+
22
+ export const DISCLOSURE_ICON =
23
+ "mt-0.5 flex size-4 shrink-0 items-center justify-center";
24
+
25
+ const thinkingStar = new URL("../assets/thinking-star.svg", import.meta.url).href;
26
+
14
27
  /**
15
- * 工作行(work rows)。整块从 cloudflare-os-main ChatInterface.tsx 拷来:
16
- * WorkIcon / ToolCallDetails / NestedToolCallRow / ToolGroupRow / ThinkingTraceRow
17
- * className 一个字没改;改的只有取数——从 gadgets 的 AiToolCall 换成
18
- * UIMessage 归一化后的 CloudOsToolCall。
28
+ * 工作行(work rows): WorkIcon / ToolCallDetails / NestedToolCallRow /
29
+ * ToolGroupRow / ThinkingTraceRow。渲染 UIMessage 归一化后的 CloudOsToolCall
19
30
  */
20
31
 
21
32
  export function WorkIcon({ Icon }: { Icon: PhosphorIcon }) {
22
33
  return <Icon size={15} className="text-kumo-inactive" />;
23
34
  }
24
35
 
25
- function OutputPre({ label, value }: { label?: string; value: string }) {
26
- if (!value) return null;
27
- return (
28
- <>
29
- {label && (
30
- <span className="font-mono text-[11px] leading-4 text-kumo-inactive uppercase tracking-[0.08em]">
31
- {label}
32
- </span>
33
- )}
34
- <pre className="max-h-56 overflow-auto rounded-xl border border-kumo-line/70 bg-kumo-base p-3 font-mono text-[12px] leading-[18px] text-kumo-subtle whitespace-pre-wrap">
35
- {value}
36
- </pre>
37
- </>
38
- );
36
+ function displayOutput(value: unknown, fallback: string): string {
37
+ if (value == null || typeof value !== "object" || Array.isArray(value)) {
38
+ return fallback;
39
+ }
40
+ const record = value as Record<string, unknown>;
41
+ if (typeof record.summary === "string" && record.summary.trim()) {
42
+ return record.summary.trim();
43
+ }
44
+ const displayValue = (item: unknown): string =>
45
+ item != null && typeof item === "object"
46
+ ? Object.entries(item).map(([key, child]) => `${key}: ${displayValue(child)}`).join("\n")
47
+ : String(item ?? "");
48
+ return displayValue(record);
49
+ }
50
+
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
+ };
39
63
  }
40
64
 
41
65
  export const ToolCallDetails = memo(function ToolCallDetails({
@@ -43,38 +67,13 @@ export const ToolCallDetails = memo(function ToolCallDetails({
43
67
  }: {
44
68
  toolCall: CloudOsToolCall;
45
69
  }) {
46
- const code =
47
- typeof tc.input.code === "string"
48
- ? tc.input.code
49
- : typeof tc.input.command === "string"
50
- ? tc.input.command
51
- : "";
52
- const inputText =
53
- Object.keys(tc.input).length > 0 ? safeStringify(tc.input) : "";
54
70
  return (
55
- <div className="space-y-2">
56
- {tc.errorText && (
57
- <pre className="rounded-xl border border-kumo-danger/20 bg-kumo-danger-tint/40 p-3 font-mono text-[12px] leading-[18px] text-kumo-danger whitespace-pre-wrap">
58
- {tc.errorText}
59
- </pre>
60
- )}
61
- {code ? (
62
- <>
63
- <OutputPre label="Code" value={code} />
64
- <OutputPre label="Output" value={tc.outputText} />
65
- </>
66
- ) : (
67
- <>
68
- <OutputPre value={inputText} />
69
- {tc.outputText && <OutputPre label="Output" value={tc.outputText} />}
70
- </>
71
- )}
72
- {!code && !inputText && !tc.outputText && !tc.errorText && (
73
- <p className="m-0 text-[12px] leading-4 text-kumo-inactive">
74
- No additional data
75
- </p>
76
- )}
77
- </div>
71
+ <ToolCall
72
+ {...toolCallView(tc)}
73
+ running={tc.running}
74
+ open
75
+ onOpenChange={() => undefined}
76
+ />
78
77
  );
79
78
  });
80
79
 
@@ -87,58 +86,117 @@ export const NestedToolCallRow = memo(function NestedToolCallRow({
87
86
  open: boolean;
88
87
  onToggle: (key: string) => void;
89
88
  }) {
89
+ if (tc.kind === "web-search" && !tc.failed) {
90
+ return <WebSearchToolCall toolCall={tc} />;
91
+ }
92
+
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"
109
+ >
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
+ }
120
+
90
121
  const key = `call-${tc.toolCallId}`;
91
- const summary = getToolCallSummary(tc);
92
- const label = `${summary.verb}${summary.target ? ` ${summary.target}` : ""}`;
93
- const Icon = getToolIcon(tc.kind);
122
+ return (
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
+ />
132
+ );
133
+ });
134
+
135
+ export const ThinkingTraceRow = memo(function ThinkingTraceRow({
136
+ reasoning,
137
+ running,
138
+ }: {
139
+ reasoning: string;
140
+ running: boolean;
141
+ }) {
142
+ const [open, setOpen] = useState(false);
143
+ const contentId = useId();
144
+ const summary = reasoning.replace(/\s+/g, " ").trim();
94
145
 
95
146
  return (
96
- <div className="group/nested">
147
+ <div
148
+ className="flex min-w-0 flex-col items-start gap-2"
149
+ data-thinking-trace=""
150
+ data-state={open ? "open" : "closed"}
151
+ >
97
152
  <button
98
153
  type="button"
99
- onClick={() => onToggle(key)}
100
- className="flex w-full cursor-pointer items-center gap-3 rounded-xl px-1.5 py-1 text-left text-kumo-subtle transition-colors duration-150 ease-out hover:text-kumo-default focus-visible:text-kumo-default focus-visible:outline-none active:scale-[0.995]"
154
+ onClick={() => setOpen((current) => !current)}
155
+ className={`${DISCLOSURE_TITLE_WITH_ICON} group w-fit max-w-[80%]`}
101
156
  aria-expanded={open}
157
+ aria-controls={contentId}
102
158
  >
103
- <span className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
104
- <WorkIcon Icon={Icon} />
105
- </span>
106
- <span className="flex min-w-0 flex-1 items-center gap-2 text-[14px] leading-5 tracking-[-0.25px]">
107
- <span className="min-w-0 truncate">{label}</span>
108
- {tc.failed && (
109
- <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">
110
- Error
111
- </span>
112
- )}
113
- <CaretRight
114
- size={13}
115
- weight="bold"
116
- className={`flex-shrink-0 text-kumo-inactive transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`}
159
+ <span className={DISCLOSURE_ICON} aria-hidden="true">
160
+ <img
161
+ src={thinkingStar}
162
+ alt=""
163
+ className={`size-[12.2369px] ${running ? "cos-thinking-star" : ""}`}
117
164
  />
118
165
  </span>
166
+ <span className="flex-shrink-0">Think</span>
167
+ <span aria-hidden="true">·</span>
168
+ <span
169
+ className="min-w-0 flex-1 truncate"
170
+ style={running ? { direction: "rtl" } : undefined}
171
+ >
172
+ <bdi dir="auto">{summary}</bdi>
173
+ </span>
119
174
  </button>
120
175
  {open && (
121
- <div className="themed-surface-inset ml-8 mt-1 space-y-3 rounded-2xl border border-kumo-line/70 bg-kumo-elevated/45 p-3">
122
- <ToolCallDetails toolCall={tc} />
176
+ <div
177
+ id={contentId}
178
+ className="cos-markdown w-full px-4 text-[14px] leading-[1.4] text-kumo-default/70"
179
+ >
180
+ <MarkdownMessage message={reasoning} />
123
181
  </div>
124
182
  )}
125
183
  </div>
126
184
  );
127
185
  });
128
186
 
129
- export const ThinkingTraceRow = memo(function ThinkingTraceRow({
130
- reasoning,
131
- }: {
132
- reasoning: string;
133
- }) {
134
- return (
135
- <div className="min-w-0 py-1 text-kumo-subtle">
136
- <div className="cos-markdown min-w-0 text-[13px] leading-[19px]">
137
- <MarkdownMessage message={reasoning} />
138
- </div>
139
- </div>
140
- );
141
- });
187
+ function useElapsedSeconds(running: boolean) {
188
+ const [elapsed, setElapsed] = useState(0);
189
+ useEffect(() => {
190
+ if (!running) return undefined;
191
+ const startedAt = Date.now() - elapsed * 1000;
192
+ const timer = window.setInterval(
193
+ () => setElapsed(Math.floor((Date.now() - startedAt) / 1000)),
194
+ 1000,
195
+ );
196
+ return () => window.clearInterval(timer);
197
+ }, [running]);
198
+ return elapsed;
199
+ }
142
200
 
143
201
  export const ToolGroupRow = memo(function ToolGroupRow({
144
202
  group,
@@ -151,63 +209,57 @@ export const ToolGroupRow = memo(function ToolGroupRow({
151
209
  expandedKeys: ReadonlySet<string>;
152
210
  onToggle: (key: string) => void;
153
211
  }) {
154
- return (
155
- <div className="group -ml-0.5">
156
- <button
157
- type="button"
158
- onClick={() => onToggle(group.key)}
159
- className="flex w-full cursor-pointer items-center gap-3 rounded-xl px-1.5 py-1 text-left text-kumo-subtle transition-colors duration-150 ease-out hover:text-kumo-default focus-visible:text-kumo-default focus-visible:outline-none active:scale-[0.995]"
160
- aria-expanded={open}
161
- >
162
- <span className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
163
- <WorkIcon Icon={group.Icon} />
164
- </span>
165
- <span className="min-w-0 flex-1">
166
- <span className="flex min-w-0 items-center gap-2 text-[14px] leading-5 tracking-[-0.25px]">
167
- <span
168
- className={`min-w-0 truncate ${group.hasRunning ? "cos-thinking-shimmer" : ""}`}
169
- >
170
- {group.label}
171
- </span>
172
- {group.hasError && (
173
- <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">
174
- Error
175
- </span>
176
- )}
177
- <CaretRight
178
- size={13}
179
- weight="bold"
180
- className={`flex-shrink-0 text-kumo-inactive transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`}
181
- />
182
- </span>
183
- {group.detailLines.length > 1 && (
184
- <span className="mt-1 block truncate font-mono text-[12px] leading-4 text-kumo-inactive">
185
- {group.detailLines.join(" · ")}
186
- </span>
187
- )}
188
- </span>
189
- </button>
190
- {open &&
191
- (group.calls.length === 1 ? (
192
- <div className="themed-surface-inset ml-8 mt-1 space-y-3 rounded-2xl border border-kumo-line/70 bg-kumo-elevated/45 p-3">
193
- <ToolCallDetails toolCall={group.calls[0]} />
194
- </div>
195
- ) : (
196
- <div className="ml-8 mt-1 space-y-1">
197
- {group.calls.map((toolCall) => {
198
- const key = `call-${toolCall.toolCallId}`;
199
- return (
200
- <NestedToolCallRow
201
- key={toolCall.toolCallId}
202
- toolCall={toolCall}
203
- open={expandedKeys.has(key)}
204
- onToggle={onToggle}
205
- />
206
- );
207
- })}
208
- </div>
212
+ const elapsed = useElapsedSeconds(group.hasRunning && group.calls.length > 1);
213
+ const onlyCall = group.calls.length === 1 ? group.calls[0] : undefined;
214
+ if (onlyCall) {
215
+ return (
216
+ <NestedToolCallRow
217
+ toolCall={onlyCall}
218
+ open={expandedKeys.has(`call-${onlyCall.toolCallId}`)}
219
+ onToggle={onToggle}
220
+ />
221
+ );
222
+ }
223
+ if (group.calls.every((call) => call.kind === "web-search")) {
224
+ return (
225
+ <div className="flex w-full max-w-sm flex-col gap-4">
226
+ {group.calls.map((toolCall) => (
227
+ <NestedToolCallRow
228
+ key={toolCall.toolCallId}
229
+ toolCall={toolCall}
230
+ open={expandedKeys.has(`call-${toolCall.toolCallId}`)}
231
+ onToggle={onToggle}
232
+ />
209
233
  ))}
210
- </div>
234
+ </div>
235
+ );
236
+ }
237
+ return (
238
+ <ToolTimeline
239
+ steps={[]}
240
+ visibleSteps={0}
241
+ streaming={group.hasRunning}
242
+ open={open}
243
+ onOpenChange={(next) => {
244
+ if (next !== open) onToggle(group.key);
245
+ }}
246
+ restingLabel={group.label}
247
+ activeLabel={`Working for ${elapsed}s`}
248
+ stats={[]}
249
+ className="max-w-none"
250
+ >
251
+ {group.calls.map((toolCall) => {
252
+ const key = `call-${toolCall.toolCallId}`;
253
+ return (
254
+ <NestedToolCallRow
255
+ key={toolCall.toolCallId}
256
+ toolCall={toolCall}
257
+ open={expandedKeys.has(key)}
258
+ onToggle={onToggle}
259
+ />
260
+ );
261
+ })}
262
+ </ToolTimeline>
211
263
  );
212
264
  });
213
265