@springbrand/message-panel 0.1.3-alpha.34 → 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.
@@ -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
  ) {
@@ -329,16 +329,8 @@ export function getToolCallSummary(call: CloudOsToolCall): {
329
329
  target: url ? hostnameOf(url) : undefined,
330
330
  };
331
331
  }
332
- case "javascript": {
333
- const firstLine = stringField(input, "code")
334
- .split("\n")
335
- .map((line) => line.trim())
336
- .find((line) => line.length > 0);
337
- return {
338
- verb: running ? "Running code" : "Ran code",
339
- target: firstLine ? truncate(firstLine, 60) : undefined,
340
- };
341
- }
332
+ case "javascript":
333
+ return { verb: running ? "Running code" : "Ran code" };
342
334
  case "tasks":
343
335
  return { verb: running ? "Updating plan" : "Updated plan" };
344
336
  case "agent": {
@@ -1,15 +1,29 @@
1
- import { Atom, CaretDown, CaretRight, Copy } from "@phosphor-icons/react";
2
- import { memo, useId, useState } 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,
10
+ safeStringify,
8
11
  type CloudOsToolCall,
9
12
  type PhosphorIcon,
10
13
  type ToolCallGroup,
11
14
  } from "./tool-presentation";
12
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
+
13
27
  /**
14
28
  * 工作行(work rows): WorkIcon / ToolCallDetails / NestedToolCallRow /
15
29
  * ToolGroupRow / ThinkingTraceRow。渲染 UIMessage 归一化后的 CloudOsToolCall。
@@ -34,28 +48,18 @@ function displayOutput(value: unknown, fallback: string): string {
34
48
  return displayValue(record);
35
49
  }
36
50
 
37
- function OutputPre({ value }: { value: string }) {
38
- return (
39
- <div
40
- className="rounded-xl border border-kumo-line/70 bg-kumo-elevated/45 px-4 py-3 text-kumo-subtle"
41
- data-output-card=""
42
- >
43
- <div className="flex items-center justify-between gap-3 text-[14px] font-medium leading-5">
44
- <span>Output</span>
45
- <button
46
- type="button"
47
- onClick={() => navigator.clipboard.writeText(value)}
48
- className="flex h-4 w-4 items-center justify-center text-kumo-subtle transition-colors hover:text-kumo-default focus-visible:outline-none focus-visible:text-kumo-default"
49
- aria-label="Copy output"
50
- >
51
- <Copy size={16} />
52
- </button>
53
- </div>
54
- <pre className="mt-2 max-h-56 overflow-auto border-t border-kumo-line/70 pt-3 font-sans text-[14px] font-normal leading-5 whitespace-pre-wrap">
55
- {value}
56
- </pre>
57
- </div>
58
- );
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
+ };
59
63
  }
60
64
 
61
65
  export const ToolCallDetails = memo(function ToolCallDetails({
@@ -63,21 +67,13 @@ export const ToolCallDetails = memo(function ToolCallDetails({
63
67
  }: {
64
68
  toolCall: CloudOsToolCall;
65
69
  }) {
66
- const output = displayOutput(tc.output, tc.outputText);
67
70
  return (
68
- <div className="space-y-2">
69
- {tc.errorText && (
70
- <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">
71
- {tc.errorText}
72
- </pre>
73
- )}
74
- {output && <OutputPre value={output} />}
75
- {!output && !tc.errorText && (
76
- <p className="m-0 text-[12px] leading-4 text-kumo-inactive">
77
- No additional data
78
- </p>
79
- )}
80
- </div>
71
+ <ToolCall
72
+ {...toolCallView(tc)}
73
+ running={tc.running}
74
+ open
75
+ onOpenChange={() => undefined}
76
+ />
81
77
  );
82
78
  });
83
79
 
@@ -90,42 +86,49 @@ export const NestedToolCallRow = memo(function NestedToolCallRow({
90
86
  open: boolean;
91
87
  onToggle: (key: string) => void;
92
88
  }) {
93
- const key = `call-${tc.toolCallId}`;
94
- const summary = getToolCallSummary(tc);
95
- const label = `${summary.verb}${summary.target ? ` ${summary.target}` : ""}`;
96
- const Icon = getToolIcon(tc.kind);
89
+ if (tc.kind === "web-search" && !tc.failed) {
90
+ return <WebSearchToolCall toolCall={tc} />;
91
+ }
97
92
 
98
- return (
99
- <div className="group/nested">
100
- <button
101
- type="button"
102
- onClick={() => onToggle(key)}
103
- 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]"
104
- 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"
105
109
  >
106
- <span className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
107
- <WorkIcon Icon={Icon} />
108
- </span>
109
- <span className="flex min-w-0 flex-1 items-center gap-2 text-[14px] leading-5 tracking-[-0.25px]">
110
- <span className="min-w-0 truncate">{label}</span>
111
- {tc.failed && (
112
- <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">
113
- Error
114
- </span>
115
- )}
116
- <CaretRight
117
- size={13}
118
- weight="bold"
119
- className={`flex-shrink-0 text-kumo-inactive transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`}
120
- />
121
- </span>
122
- </button>
123
- {open && (
124
- <div className="ml-8 mt-1 space-y-3">
125
- <ToolCallDetails toolCall={tc} />
126
- </div>
127
- )}
128
- </div>
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
+
121
+ const key = `call-${tc.toolCallId}`;
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
+ />
129
132
  );
130
133
  });
131
134
 
@@ -149,21 +152,14 @@ export const ThinkingTraceRow = memo(function ThinkingTraceRow({
149
152
  <button
150
153
  type="button"
151
154
  onClick={() => setOpen((current) => !current)}
152
- className="group -ml-0.5 flex w-fit max-w-[80%] min-w-0 items-center gap-1.5 rounded-xl px-1.5 py-1 text-left text-[14px] leading-5 text-kumo-inactive transition-colors hover:text-kumo-default focus-visible:text-kumo-default focus-visible:outline-none"
155
+ className={`${DISCLOSURE_TITLE_WITH_ICON} group w-fit max-w-[80%]`}
153
156
  aria-expanded={open}
154
157
  aria-controls={contentId}
155
158
  >
156
- <span className="relative grid size-4 flex-shrink-0 place-items-center" aria-hidden="true">
157
- <Atom
158
- size={14}
159
- className="transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0"
160
- />
161
- <CaretDown
162
- size={14}
163
- className={`absolute opacity-0 transition-[opacity,transform] group-hover:opacity-100 group-focus-visible:opacity-100 ${open ? "" : "-rotate-90"}`}
164
- />
159
+ <span className={DISCLOSURE_ICON} aria-hidden="true">
160
+ <img src={thinkingStar} alt="" className="size-[12.2369px]" />
165
161
  </span>
166
- <span className="flex-shrink-0 text-kumo-subtle">Think</span>
162
+ <span className="flex-shrink-0">Think</span>
167
163
  <span aria-hidden="true">·</span>
168
164
  <span
169
165
  className="min-w-0 flex-1 truncate"
@@ -175,7 +171,7 @@ export const ThinkingTraceRow = memo(function ThinkingTraceRow({
175
171
  {open && (
176
172
  <div
177
173
  id={contentId}
178
- className="cos-markdown w-full rounded-xl border border-kumo-line bg-kumo-elevated/45 px-4 py-3 text-[14px] leading-[1.4] text-kumo-default/70"
174
+ className="cos-markdown w-full px-4 text-[14px] leading-[1.4] text-kumo-default/70"
179
175
  >
180
176
  <MarkdownMessage message={reasoning} />
181
177
  </div>
@@ -184,6 +180,20 @@ export const ThinkingTraceRow = memo(function ThinkingTraceRow({
184
180
  );
185
181
  });
186
182
 
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
+ }
196
+
187
197
  export const ToolGroupRow = memo(function ToolGroupRow({
188
198
  group,
189
199
  open,
@@ -195,63 +205,57 @@ export const ToolGroupRow = memo(function ToolGroupRow({
195
205
  expandedKeys: ReadonlySet<string>;
196
206
  onToggle: (key: string) => void;
197
207
  }) {
198
- return (
199
- <div className="group -ml-0.5">
200
- <button
201
- type="button"
202
- onClick={() => onToggle(group.key)}
203
- 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]"
204
- aria-expanded={open}
205
- >
206
- <span className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
207
- <WorkIcon Icon={group.Icon} />
208
- </span>
209
- <span className="min-w-0 flex-1">
210
- <span className="flex min-w-0 items-center gap-2 text-[14px] leading-5 tracking-[-0.25px]">
211
- <span
212
- className={`min-w-0 truncate ${group.hasRunning ? "cos-thinking-shimmer" : ""}`}
213
- >
214
- {group.label}
215
- </span>
216
- {group.hasError && (
217
- <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">
218
- Error
219
- </span>
220
- )}
221
- <CaretRight
222
- size={13}
223
- weight="bold"
224
- className={`flex-shrink-0 text-kumo-inactive transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`}
225
- />
226
- </span>
227
- {group.detailLines.length > 1 && (
228
- <span className="mt-1 block truncate font-mono text-[12px] leading-4 text-kumo-inactive">
229
- {group.detailLines.join(" · ")}
230
- </span>
231
- )}
232
- </span>
233
- </button>
234
- {open &&
235
- (group.calls.length === 1 ? (
236
- <div className="ml-8 mt-1 space-y-3">
237
- <ToolCallDetails toolCall={group.calls[0]} />
238
- </div>
239
- ) : (
240
- <div className="ml-8 mt-1 space-y-1">
241
- {group.calls.map((toolCall) => {
242
- const key = `call-${toolCall.toolCallId}`;
243
- return (
244
- <NestedToolCallRow
245
- key={toolCall.toolCallId}
246
- toolCall={toolCall}
247
- open={expandedKeys.has(key)}
248
- onToggle={onToggle}
249
- />
250
- );
251
- })}
252
- </div>
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
+ />
253
229
  ))}
254
- </div>
230
+ </div>
231
+ );
232
+ }
233
+ return (
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>
255
259
  );
256
260
  });
257
261