@springbrand/message-panel 0.1.3-alpha.3 → 0.1.3-alpha.30

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 (39) hide show
  1. package/cloud-os/README.md +6 -6
  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-spark.svg +9 -0
  8. package/cloud-os/capability-chip.tsx +35 -0
  9. package/cloud-os/chat/activity-indicator.tsx +2 -2
  10. package/cloud-os/chat/cloud-os-chat-messages.tsx +374 -63
  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/rich-blocks.tsx +548 -246
  15. package/cloud-os/chat/tool-presentation.ts +35 -16
  16. package/cloud-os/chat/tool-rows.tsx +330 -85
  17. package/cloud-os/chat/transcript-model.ts +428 -56
  18. package/cloud-os/composer/cloud-os-chat-input.tsx +228 -142
  19. package/cloud-os/composer/cloud-os-model-select.tsx +105 -0
  20. package/cloud-os/file-view.tsx +441 -0
  21. package/cloud-os/index.ts +35 -3
  22. package/cloud-os/layout/cloud-os-workspace-split.tsx +58 -15
  23. package/cloud-os/primitives/workshop-controls.tsx +2 -2
  24. package/cloud-os/styles/cloud-os.css +100 -20
  25. package/demo/camel-chat-showcase.tsx +21 -13
  26. package/demo/chat-scenarios.ts +223 -43
  27. package/demo/cloud-os-chat-showcase.tsx +47 -14
  28. package/demo/fixtures.ts +4 -5
  29. package/demo/message-panel-gallery.tsx +16 -2
  30. package/package.json +3 -4
  31. package/src/camel/camel-chat-messages.tsx +78 -78
  32. package/src/camel/camel-tool-presentation.tsx +19 -15
  33. package/src/camel/camel-turn.ts +1 -17
  34. package/src/composer/composer-attachments.tsx +1 -1
  35. package/src/composer/composer-trigger-popover.tsx +1 -1
  36. package/src/composer/composer.tsx +2 -2
  37. package/src/contracts.ts +0 -2
  38. package/src/message-panel.tsx +1 -24
  39. package/src/parts/index.tsx +103 -64
@@ -145,7 +145,7 @@ function truncate(value: string, maxLength: number): string {
145
145
  return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}…`;
146
146
  }
147
147
 
148
- function humanize(name: string): string {
148
+ export function humanizeToolName(name: string): string {
149
149
  const spaced = name
150
150
  .replace(/^mcp__/, "")
151
151
  .replace(/__/g, " ")
@@ -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,
@@ -343,7 +344,7 @@ export function getToolCallSummary(call: CloudOsToolCall): {
343
344
  case "agent": {
344
345
  const name =
345
346
  stringField(input, "subagentName", "agent", "name", "agentType") ||
346
- humanize(toolName);
347
+ humanizeToolName(toolName);
347
348
  return { verb: running ? "Running" : "Ran", target: name };
348
349
  }
349
350
  case "skill": {
@@ -378,13 +379,15 @@ export function getToolCallSummary(call: CloudOsToolCall): {
378
379
  const [, server = "", ...rest] = toolName.split("__");
379
380
  return {
380
381
  verb: running ? "Calling" : "Called",
381
- target: rest.length ? `${humanize(rest.join(" "))} · ${humanize(server)}` : undefined,
382
+ target: rest.length
383
+ ? `${humanizeToolName(rest.join(" "))} · ${humanizeToolName(server)}`
384
+ : undefined,
382
385
  };
383
386
  }
384
387
  case "generic":
385
388
  break;
386
389
  }
387
- const displayName = humanize(toolName);
390
+ const displayName = humanizeToolName(toolName);
388
391
  return { verb: running ? `Using ${displayName}` : `Used ${displayName}` };
389
392
  }
390
393
 
@@ -435,7 +438,7 @@ export function describeToolCallCount(
435
438
  case "generic":
436
439
  break;
437
440
  }
438
- const displayName = humanize(toolName);
441
+ const displayName = humanizeToolName(toolName);
439
442
  return count === 1 ? `Used ${displayName}` : `Used ${displayName} ${formatTimes(count)}`;
440
443
  }
441
444
 
@@ -496,7 +499,7 @@ export function toCloudOsToolCall(
496
499
  *
497
500
  * - `pending`:还等着用户操作,选项可点。**只有 input 态才算 pending** ——
498
501
  * 服务端一旦结算,这次 interaction 就不再接受响应。
499
- * - `answered`:`ask_user` 的 settle 写了 `{ selections, text }`。
502
+ * - `answered`:`ask_user` 的 settle 写了按问题对应的 `{ answers }`。
500
503
  * - `closed`:被结算掉了但不是一个答案 —— 用户改成直接发言(`{ cancelled: true }`)、
501
504
  * Turn 提前结束、或工具出错。必须和 pending 分开,否则卡片会留一个点了拿
502
505
  * `{ ok: false }` 的假 Submit,读起来就是「点了没反应」。
@@ -506,7 +509,14 @@ export function toCloudOsToolCall(
506
509
  */
507
510
  export type AskUserOutcome =
508
511
  | { kind: "pending" }
509
- | { kind: "answered"; selections: string[]; text: string }
512
+ | {
513
+ kind: "answered";
514
+ answers: Array<{
515
+ question: string;
516
+ selections: string[];
517
+ text: string;
518
+ }>;
519
+ }
510
520
  | { kind: "closed"; reason: "user_replied_freeform" | "other" };
511
521
 
512
522
  export function askUserOutcome(call: CloudOsToolCall): AskUserOutcome {
@@ -514,14 +524,23 @@ export function askUserOutcome(call: CloudOsToolCall): AskUserOutcome {
514
524
  return { kind: "pending" };
515
525
  }
516
526
  const record = recordOf(call.output);
517
- if (Array.isArray(record.selections)) {
518
- return {
519
- kind: "answered",
520
- selections: record.selections.filter(
521
- (value): value is string => typeof value === "string",
522
- ),
523
- text: typeof record.text === "string" ? record.text : "",
524
- };
527
+ if (Array.isArray(record.answers) && record.answers.length > 0) {
528
+ const answers = record.answers.map(recordOf);
529
+ if (answers.every((answer) =>
530
+ typeof answer.question === "string" &&
531
+ Array.isArray(answer.selections) &&
532
+ answer.selections.every((value) => typeof value === "string") &&
533
+ (answer.text === undefined || typeof answer.text === "string")
534
+ )) {
535
+ return {
536
+ kind: "answered",
537
+ answers: answers.map((answer) => ({
538
+ question: answer.question as string,
539
+ selections: answer.selections as string[],
540
+ text: typeof answer.text === "string" ? answer.text : "",
541
+ })),
542
+ };
543
+ }
525
544
  }
526
545
  return {
527
546
  kind: "closed",
@@ -1,40 +1,117 @@
1
- import { CaretRight } from "@phosphor-icons/react";
2
- import { memo } from "react";
3
- import { MarkdownMessage } from "./markdown-message";
1
+ import { Atom, CaretDown, CaretRight, Copy } from "@phosphor-icons/react";
2
+ import { motion, useReducedMotion } from "motion/react";
3
+ import { memo, useId, useState, type ReactNode } from "react";
4
+ import {
5
+ MarkdownMessage,
6
+ type CloudOsUrlResolver,
7
+ } from "./markdown-message";
4
8
  import {
5
9
  getToolCallSummary,
6
10
  getToolIcon,
7
11
  normalizeOutputText,
8
- safeStringify,
9
12
  type CloudOsToolCall,
10
13
  type PhosphorIcon,
11
14
  type ToolCallGroup,
12
15
  } from "./tool-presentation";
13
16
 
17
+ const thinkingSpark = new URL(
18
+ "../assets/thinking-spark.svg",
19
+ import.meta.url,
20
+ ).href;
21
+
14
22
  /**
15
- * 工作行(work rows)。整块从 cloudflare-os-main ChatInterface.tsx 拷来:
16
- * WorkIcon / ToolCallDetails / NestedToolCallRow / ToolGroupRow / ThinkingTraceRow
17
- * className 一个字没改;改的只有取数——从 gadgets 的 AiToolCall 换成
18
- * UIMessage 归一化后的 CloudOsToolCall。
23
+ * 工作行(work rows): WorkIcon / ToolCallDetails / NestedToolCallRow /
24
+ * ToolGroupRow / ThinkingTraceRow。渲染 UIMessage 归一化后的 CloudOsToolCall
19
25
  */
20
26
 
21
27
  export function WorkIcon({ Icon }: { Icon: PhosphorIcon }) {
22
28
  return <Icon size={15} className="text-kumo-inactive" />;
23
29
  }
24
30
 
25
- function OutputPre({ label, value }: { label?: string; value: string }) {
26
- if (!value) return null;
31
+ function displayOutput(value: unknown, fallback: string): string {
32
+ if (value == null || typeof value !== "object" || Array.isArray(value)) {
33
+ return fallback;
34
+ }
35
+ const record = value as Record<string, unknown>;
36
+ if (typeof record.summary === "string" && record.summary.trim()) {
37
+ return record.summary.trim();
38
+ }
39
+ const displayValue = (item: unknown): string =>
40
+ item != null && typeof item === "object"
41
+ ? Object.entries(item).map(([key, child]) => `${key}: ${displayValue(child)}`).join("\n")
42
+ : String(item ?? "");
43
+ return displayValue(record);
44
+ }
45
+
46
+ interface ToolDetailItem {
47
+ key: string;
48
+ label: string;
49
+ output: string;
50
+ error: string;
51
+ }
52
+
53
+ function detailItems(calls: readonly CloudOsToolCall[]): ToolDetailItem[] {
54
+ return calls.map((call) => {
55
+ const summary = getToolCallSummary(call);
56
+ return {
57
+ key: call.toolCallId,
58
+ label: `${summary.verb}${summary.target ? ` ${summary.target}` : ""}`,
59
+ output: displayOutput(call.output, call.outputText),
60
+ error: call.errorText,
61
+ };
62
+ });
63
+ }
64
+
65
+ function OutputCard({ items }: { items: readonly ToolDetailItem[] }) {
66
+ const copyValue = items
67
+ .map((item) => {
68
+ const detail = [item.error, item.output]
69
+ .filter(Boolean)
70
+ .join("\n\n") || "No additional data";
71
+ return items.length === 1 ? detail : `${item.label}\n${detail}`;
72
+ })
73
+ .join("\n\n");
27
74
  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
- </>
75
+ <div
76
+ 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"
77
+ data-output-card=""
78
+ >
79
+ <div className="flex items-center justify-between gap-3 text-[14px] font-medium leading-5">
80
+ <span>Output</span>
81
+ <button
82
+ type="button"
83
+ onClick={() => navigator.clipboard.writeText(copyValue)}
84
+ 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"
85
+ aria-label="Copy output"
86
+ >
87
+ <Copy size={16} />
88
+ </button>
89
+ </div>
90
+ <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]">
91
+ {items.map((item) => (
92
+ <div key={item.key} className="space-y-1.5">
93
+ {items.length > 1 && (
94
+ <p className="m-0 text-[12px] leading-4 text-cos-output">
95
+ {item.label}
96
+ </p>
97
+ )}
98
+ {item.error && (
99
+ <p className="m-0 whitespace-pre-wrap text-kumo-danger">
100
+ {item.error}
101
+ </p>
102
+ )}
103
+ {item.output && (
104
+ <p className="m-0 whitespace-pre-wrap text-cos-output">
105
+ {item.output}
106
+ </p>
107
+ )}
108
+ {!item.error && !item.output && (
109
+ <p className="m-0 text-cos-output">No additional data</p>
110
+ )}
111
+ </div>
112
+ ))}
113
+ </div>
114
+ </div>
38
115
  );
39
116
  }
40
117
 
@@ -43,39 +120,15 @@ export const ToolCallDetails = memo(function ToolCallDetails({
43
120
  }: {
44
121
  toolCall: CloudOsToolCall;
45
122
  }) {
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
- 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>
78
- );
123
+ return <OutputCard items={detailItems([tc])} />;
124
+ });
125
+
126
+ export const ToolGroupDetails = memo(function ToolGroupDetails({
127
+ calls,
128
+ }: {
129
+ calls: readonly CloudOsToolCall[];
130
+ }) {
131
+ return <OutputCard items={detailItems(calls)} />;
79
132
  });
80
133
 
81
134
  export const NestedToolCallRow = memo(function NestedToolCallRow({
@@ -118,7 +171,7 @@ export const NestedToolCallRow = memo(function NestedToolCallRow({
118
171
  </span>
119
172
  </button>
120
173
  {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">
174
+ <div className="ml-8 mt-1 space-y-3">
122
175
  <ToolCallDetails toolCall={tc} />
123
176
  </div>
124
177
  )}
@@ -128,14 +181,221 @@ export const NestedToolCallRow = memo(function NestedToolCallRow({
128
181
 
129
182
  export const ThinkingTraceRow = memo(function ThinkingTraceRow({
130
183
  reasoning,
184
+ running,
131
185
  }: {
132
186
  reasoning: string;
187
+ running: boolean;
133
188
  }) {
189
+ const [open, setOpen] = useState(false);
190
+ const contentId = useId();
191
+ const summary = reasoning.replace(/\s+/g, " ").trim();
192
+
134
193
  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} />
194
+ <div
195
+ className="flex min-w-0 flex-col items-start gap-2"
196
+ data-thinking-trace=""
197
+ data-state={open ? "open" : "closed"}
198
+ >
199
+ <button
200
+ type="button"
201
+ onClick={() => setOpen((current) => !current)}
202
+ 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"
203
+ aria-expanded={open}
204
+ aria-controls={contentId}
205
+ >
206
+ <span className="relative grid size-4 flex-shrink-0 place-items-center" aria-hidden="true">
207
+ <Atom
208
+ size={14}
209
+ className="transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0"
210
+ />
211
+ <CaretDown
212
+ size={14}
213
+ className={`absolute opacity-0 transition-[opacity,transform] group-hover:opacity-100 group-focus-visible:opacity-100 ${open ? "" : "-rotate-90"}`}
214
+ />
215
+ </span>
216
+ <span className="flex-shrink-0 text-kumo-subtle">Think</span>
217
+ <span aria-hidden="true">·</span>
218
+ <span
219
+ className="min-w-0 flex-1 truncate"
220
+ style={running ? { direction: "rtl" } : undefined}
221
+ >
222
+ <bdi dir="auto">{summary}</bdi>
223
+ </span>
224
+ </button>
225
+ {open && (
226
+ <div
227
+ id={contentId}
228
+ 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"
229
+ >
230
+ <MarkdownMessage message={reasoning} />
231
+ </div>
232
+ )}
233
+ </div>
234
+ );
235
+ });
236
+
237
+ export function formatWorkDuration(milliseconds: number): string {
238
+ const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000));
239
+ const hours = Math.floor(totalSeconds / 3600);
240
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
241
+ const seconds = totalSeconds % 60;
242
+ if (hours > 0) {
243
+ return `${hours}h ${minutes}m${seconds > 0 ? ` ${seconds}s` : ""}`;
244
+ }
245
+ if (minutes > 0) return `${minutes}m${seconds > 0 ? ` ${seconds}s` : ""}`;
246
+ return `${seconds}s`;
247
+ }
248
+
249
+ interface ActivitySummary {
250
+ leading: string;
251
+ emphasis?: string;
252
+ trailing?: string;
253
+ }
254
+
255
+ function plural(count: number, singular: string, plural = `${singular}s`): string {
256
+ return `${count} ${count === 1 ? singular : plural}`;
257
+ }
258
+
259
+ function activitySummary(groups: readonly ToolCallGroup[]): ActivitySummary | null {
260
+ const calls = groups.flatMap((group) => group.calls);
261
+ const count = (kind: CloudOsToolCall["kind"]) =>
262
+ calls.filter((call) => call.kind === kind).length;
263
+ const webSearches = count("web-search");
264
+ const webPages = count("web-fetch");
265
+ if (webSearches > 0 && webPages > 0) {
266
+ return {
267
+ leading: "searched the web and retrieved ",
268
+ emphasis: `${webPages} web`,
269
+ trailing: webPages === 1 ? " page" : " pages",
270
+ };
271
+ }
272
+ if (webSearches > 0) return { leading: "searched the web" };
273
+ if (webPages > 0) {
274
+ return {
275
+ leading: "retrieved ",
276
+ emphasis: `${webPages} web`,
277
+ trailing: webPages === 1 ? " page" : " pages",
278
+ };
279
+ }
280
+
281
+ const firstKnown = calls.find((call) =>
282
+ ["read", "write", "edit", "bash", "skill", "javascript"].includes(
283
+ call.kind,
284
+ ),
285
+ );
286
+ if (!firstKnown) return null;
287
+ const total = count(firstKnown.kind);
288
+ switch (firstKnown.kind) {
289
+ case "read":
290
+ return { leading: `read ${plural(total, "file")}` };
291
+ case "write":
292
+ return { leading: `wrote ${plural(total, "file")}` };
293
+ case "edit":
294
+ return { leading: `made ${plural(total, "edit")}` };
295
+ case "bash":
296
+ return { leading: `ran ${plural(total, "command")}` };
297
+ case "skill":
298
+ return { leading: `read ${plural(total, "instruction set")}` };
299
+ case "javascript":
300
+ return { leading: total === 1 ? "ran code" : `ran code ${total} times` };
301
+ default:
302
+ return null;
303
+ }
304
+ }
305
+
306
+ export const WorkTraceDisclosure = memo(function WorkTraceDisclosure({
307
+ durationMs,
308
+ groups,
309
+ completed = false,
310
+ open,
311
+ onToggle,
312
+ children,
313
+ }: {
314
+ durationMs?: number;
315
+ groups: readonly ToolCallGroup[];
316
+ completed?: boolean;
317
+ open: boolean;
318
+ onToggle: () => void;
319
+ children: ReactNode;
320
+ }) {
321
+ const contentId = useId();
322
+ const reduceMotion = useReducedMotion();
323
+ const activity = activitySummary(groups);
324
+ const duration = durationMs === undefined ? "" : ` for ${formatWorkDuration(durationMs)}`;
325
+ const verb = completed ? "Worked" : "Thought";
326
+ const label = `${verb}${duration}${activity ? `, ${activity.leading}${activity.emphasis ?? ""}${activity.trailing ?? ""}` : ""}`;
327
+
328
+ return (
329
+ <div data-agent-work="" data-state={open ? "open" : "closed"}>
330
+ <div className="w-full border-b border-kumo-line pb-2">
331
+ <button
332
+ type="button"
333
+ onClick={onToggle}
334
+ aria-expanded={open}
335
+ aria-controls={contentId}
336
+ aria-label={label}
337
+ 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"
338
+ >
339
+ <span className="grid size-4 shrink-0 place-items-center" aria-hidden="true">
340
+ <motion.img
341
+ src={thinkingSpark}
342
+ alt=""
343
+ className="size-[12.24px]"
344
+ initial={{ rotate: 0 }}
345
+ animate={
346
+ reduceMotion ? { rotate: 0 } : { rotate: [0, 360, -360, -360] }
347
+ }
348
+ transition={
349
+ reduceMotion
350
+ ? { duration: 0 }
351
+ : {
352
+ rotate: {
353
+ duration: 1.68,
354
+ times: [0, 0.8368, 0.8373, 1],
355
+ ease: [
356
+ [0.5, 0, 0.5, 1],
357
+ [0.5, 0, 0.5, 1],
358
+ "linear",
359
+ ],
360
+ repeat: Infinity,
361
+ },
362
+ }
363
+ }
364
+ />
365
+ </span>
366
+ <span className="min-w-0 truncate">
367
+ <span>{`${verb}${duration}${activity ? `, ${activity.leading}` : ""}`}</span>
368
+ {activity?.emphasis && (
369
+ <span className="text-kumo-default">{activity.emphasis}</span>
370
+ )}
371
+ {activity?.trailing}
372
+ </span>
373
+ <CaretRight
374
+ size={16}
375
+ className={`shrink-0 text-kumo-inactive transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`}
376
+ aria-hidden="true"
377
+ />
378
+ </button>
138
379
  </div>
380
+ {open && (
381
+ <div id={contentId} className="mt-4 space-y-3" data-agent-work-content="">
382
+ {children}
383
+ </div>
384
+ )}
385
+ </div>
386
+ );
387
+ });
388
+
389
+ export const WorkDescriptionRow = memo(function WorkDescriptionRow({
390
+ text,
391
+ resolveUrl,
392
+ }: {
393
+ text: string;
394
+ resolveUrl?: CloudOsUrlResolver;
395
+ }) {
396
+ return (
397
+ <div className="cos-markdown text-[14px] leading-[1.4] text-cos-work-description">
398
+ <MarkdownMessage message={text} resolveUrl={resolveUrl} />
139
399
  </div>
140
400
  );
141
401
  });
@@ -151,19 +411,20 @@ export const ToolGroupRow = memo(function ToolGroupRow({
151
411
  expandedKeys: ReadonlySet<string>;
152
412
  onToggle: (key: string) => void;
153
413
  }) {
414
+ void expandedKeys;
154
415
  return (
155
- <div className="group -ml-0.5">
416
+ <div className="group/tool-call">
156
417
  <button
157
418
  type="button"
158
419
  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]"
420
+ className="flex min-w-0 items-start gap-2 text-left text-[14px] leading-[1.4] text-cos-tool-group transition-colors duration-150 ease-out hover:text-kumo-subtle focus-visible:text-kumo-subtle focus-visible:outline-none"
160
421
  aria-expanded={open}
161
422
  >
162
- <span className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
423
+ <span className="mt-0.5 flex size-4 shrink-0 items-center justify-center">
163
424
  <WorkIcon Icon={group.Icon} />
164
425
  </span>
165
426
  <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]">
427
+ <span className="flex min-w-0 items-center gap-2">
167
428
  <span
168
429
  className={`min-w-0 truncate ${group.hasRunning ? "cos-thinking-shimmer" : ""}`}
169
430
  >
@@ -175,38 +436,22 @@ export const ToolGroupRow = memo(function ToolGroupRow({
175
436
  </span>
176
437
  )}
177
438
  <CaretRight
178
- size={13}
179
- weight="bold"
180
- className={`flex-shrink-0 text-kumo-inactive transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`}
439
+ size={16}
440
+ className={`shrink-0 text-kumo-inactive transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`}
181
441
  />
182
442
  </span>
183
443
  {group.detailLines.length > 1 && (
184
- <span className="mt-1 block truncate font-mono text-[12px] leading-4 text-kumo-inactive">
444
+ <span className="mt-2 block truncate text-[12px] leading-4 text-[rgba(0,0,0,0.3)]">
185
445
  {group.detailLines.join(" · ")}
186
446
  </span>
187
447
  )}
188
448
  </span>
189
449
  </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>
209
- ))}
450
+ {open && (
451
+ <div className="mt-3 pl-6">
452
+ <ToolGroupDetails calls={group.calls} />
453
+ </div>
454
+ )}
210
455
  </div>
211
456
  );
212
457
  });