@springbrand/message-panel 0.3.0-alpha.6 → 0.3.0-alpha.9

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.
@@ -237,6 +237,7 @@ function UserBubble({
237
237
 
238
238
  function ActionPresentationBlock({
239
239
  presentation,
240
+ compact = false,
240
241
  failureCount = 1,
241
242
  partial = false,
242
243
  renderFile,
@@ -245,6 +246,7 @@ function ActionPresentationBlock({
245
246
  onOpenArtifact,
246
247
  }: {
247
248
  presentation: CloudOsActionPresentation;
249
+ compact?: boolean;
248
250
  failureCount?: number;
249
251
  partial?: boolean;
250
252
  renderFile?: FileRenderer;
@@ -254,16 +256,41 @@ function ActionPresentationBlock({
254
256
  }) {
255
257
  const { state } = presentation;
256
258
  if (state === "ready") {
259
+ const hasFileContent = presentation.content.length > 0;
260
+ const statusLabel = hasFileContent
261
+ ? presentation.capabilityName || "Completed"
262
+ : presentation.toolBrief || "Completed";
263
+ const hasStatusMetadata = hasFileContent
264
+ ? Boolean(presentation.capabilityName || presentation.iconUrl)
265
+ : Boolean(presentation.toolBrief || presentation.iconUrl);
266
+ const status = (
267
+ <span
268
+ className="flex shrink-0 items-center gap-2 text-[14px] leading-5 text-kumo-subtle"
269
+ data-action-status=""
270
+ >
271
+ {presentation.iconUrl && (
272
+ <img
273
+ src={presentation.iconUrl}
274
+ alt=""
275
+ aria-hidden="true"
276
+ className="size-4 rounded-sm object-contain"
277
+ data-action-icon=""
278
+ />
279
+ )}
280
+ <span>{statusLabel}</span>
281
+ </span>
282
+ );
257
283
  return (
258
284
  <div
259
285
  data-action-presentation={state}
260
286
  role="status"
261
- aria-label="Completed"
262
- className="space-y-2"
287
+ aria-label={statusLabel}
288
+ className={compact
289
+ ? "inline-flex items-center rounded-full border border-kumo-line bg-kumo-elevated px-3 py-1.5"
290
+ : "space-y-2"}
263
291
  >
264
- {presentation.content.length === 0
265
- ? <p className="m-0 text-[14px] leading-5 text-kumo-subtle">Completed</p>
266
- : presentation.content.map((file, index) => {
292
+ {presentation.content.length === 0 && status}
293
+ {presentation.content.map((file, index) => {
267
294
  const url = resolveUrl?.(file.url) ?? file.url;
268
295
  const artifactId = resolveArtifactId?.(url);
269
296
  return (
@@ -276,6 +303,7 @@ function ActionPresentationBlock({
276
303
  },
277
304
  placement: "assistant-message",
278
305
  variant: "product",
306
+ leadingMeta: hasStatusMetadata ? status : undefined,
279
307
  onOpen: file.mediaType?.startsWith("image/") && artifactId && onOpenArtifact
280
308
  ? () => onOpenArtifact(artifactId)
281
309
  : undefined,
@@ -897,10 +925,40 @@ export function CloudOsChatMessages({
897
925
 
898
926
  {actionResultBlocks.length > 0 && (
899
927
  <div data-action-results="" className="space-y-4">
928
+ {actionResultBlocks.filter((block) =>
929
+ block.presentation.state === "ready" &&
930
+ block.presentation.content.length === 0,
931
+ ).length > 0 && (
932
+ <div data-action-empty-results="" className="flex flex-wrap gap-2">
933
+ {actionResultBlocks.map((block) => {
934
+ if (
935
+ block.presentation.state !== "ready" ||
936
+ block.presentation.content.length !== 0 ||
937
+ (block.presentation.failureCode === "insufficient_credits" &&
938
+ block !== firstInsufficientFailure)
939
+ ) return null;
940
+ return (
941
+ <ActionPresentationBlock
942
+ key={block.key}
943
+ presentation={block.presentation}
944
+ compact
945
+ failureCount={insufficientFailures.length}
946
+ partial={hasCompletedAction}
947
+ renderFile={renderFile}
948
+ resolveUrl={resolveUrl}
949
+ resolveArtifactId={resolveArtifactId}
950
+ onOpenArtifact={onOpenArtifact}
951
+ />
952
+ );
953
+ })}
954
+ </div>
955
+ )}
900
956
  {actionResultBlocks.map((block) => {
901
957
  if (
902
958
  block.presentation.failureCode === "insufficient_credits" &&
903
- block !== firstInsufficientFailure
959
+ block !== firstInsufficientFailure ||
960
+ (block.presentation.state === "ready" &&
961
+ block.presentation.content.length === 0)
904
962
  ) return null;
905
963
  return (
906
964
  <ActionPresentationBlock
@@ -21,6 +21,7 @@ export interface ToolCallProps {
21
21
  query: string;
22
22
  result: string;
23
23
  running: boolean;
24
+ iconUrl?: string;
24
25
  open: boolean;
25
26
  onOpenChange: (open: boolean) => void;
26
27
  className?: string;
@@ -33,6 +34,7 @@ export function ToolCall({
33
34
  query,
34
35
  result,
35
36
  running,
37
+ iconUrl,
36
38
  open,
37
39
  onOpenChange,
38
40
  className,
@@ -47,6 +49,15 @@ export function ToolCall({
47
49
  >
48
50
  <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
51
  <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" />
52
+ {iconUrl && (
53
+ <img
54
+ src={iconUrl}
55
+ alt=""
56
+ aria-hidden="true"
57
+ className="size-4 shrink-0 rounded-sm object-contain"
58
+ data-tool-icon=""
59
+ />
60
+ )}
50
61
  <SwapLabel active={running ? 0 : 1} className="text-start">
51
62
  <ShimmerLabel
52
63
  active={running}
@@ -64,6 +64,9 @@ export interface CloudOsToolCall {
64
64
  input: UnknownRecord;
65
65
  output: unknown;
66
66
  outputText: string;
67
+ capabilityName?: string;
68
+ toolBrief?: string;
69
+ iconUrl?: string;
67
70
  errorText: string;
68
71
  /** UIMessage 的 part.state 原值。 */
69
72
  state: string;
@@ -77,6 +80,7 @@ export interface CloudOsToolCall {
77
80
  export interface ToolCallGroup {
78
81
  key: string;
79
82
  Icon: PhosphorIcon;
83
+ iconUrl?: string;
80
84
  label: string;
81
85
  detailLines: string[];
82
86
  calls: CloudOsToolCall[];
@@ -380,6 +384,12 @@ export function getToolCallSummary(call: CloudOsToolCall): {
380
384
  break;
381
385
  }
382
386
  const displayName = humanizeToolName(toolName);
387
+ if (call.capabilityName) {
388
+ const capabilityLabel = [call.capabilityName, call.toolBrief]
389
+ .filter(Boolean)
390
+ .join(" · ");
391
+ return { verb: running ? `Using ${capabilityLabel}` : `Used ${capabilityLabel}` };
392
+ }
383
393
  return { verb: running ? `Using ${displayName}` : `Used ${displayName}` };
384
394
  }
385
395
 
@@ -448,10 +458,12 @@ export function toCloudOsToolCall(
448
458
  part: unknown,
449
459
  fallbackId: string,
450
460
  isActive: boolean,
461
+ liveMetadata?: Record<string, unknown>,
451
462
  ): CloudOsToolCall {
452
463
  const record = recordOf(part);
453
464
  const toolName = toolNameOfPart(part) ?? stringField(record, "name") ?? "tool";
454
465
  const data = recordOf(record.data);
466
+ const liveMeta = recordOf(liveMetadata);
455
467
  const input =
456
468
  record.input != null
457
469
  ? recordOf(record.input)
@@ -459,6 +471,18 @@ export function toCloudOsToolCall(
459
471
  ? recordOf(data.input)
460
472
  : data;
461
473
  const output = record.output ?? data.output ?? record.result;
474
+ const outputRecord = recordOf(output);
475
+ const presentation = recordOf(outputRecord.presentation);
476
+ const toolMeta = recordOf(outputRecord.toolMeta);
477
+ const capabilityName =
478
+ stringField(presentation, "capabilityName") || stringField(toolMeta, "capabilityName") ||
479
+ stringField(liveMeta, "capabilityName") || undefined;
480
+ const toolBrief =
481
+ stringField(presentation, "toolBrief") || stringField(toolMeta, "toolBrief") ||
482
+ stringField(liveMeta, "toolBrief") || undefined;
483
+ const iconUrl =
484
+ stringField(presentation, "iconUrl") || stringField(toolMeta, "iconUrl") ||
485
+ stringField(liveMeta, "iconUrl") || undefined;
462
486
  const errorText = stringField(record, "errorText", "error");
463
487
  const state = stringField(record, "state", "status");
464
488
  const failed =
@@ -475,9 +499,12 @@ export function toCloudOsToolCall(
475
499
  input,
476
500
  output,
477
501
  outputText: normalizeOutputText(output),
502
+ capabilityName,
503
+ toolBrief,
504
+ iconUrl,
478
505
  errorText,
479
506
  state,
480
- running: isActive && !failed && RUNNING_STATES.has(state),
507
+ running: isActive && !failed && (RUNNING_STATES.has(state) || record.preliminary === true),
481
508
  failed,
482
509
  awaitingApproval,
483
510
  raw: part,
@@ -608,10 +635,16 @@ export function buildToolCallGroups(calls: CloudOsToolCall[]): ToolCallGroup[] {
608
635
  labelParts.push(`${summary.verb}${summary.target ? ` ${summary.target}` : ""}`);
609
636
  } else if (distinctKinds.length === 1) {
610
637
  const summary = getToolCallSummary(calls[0]);
638
+ const sameCapability = calls.every(
639
+ (call) => call.capabilityName === calls[0].capabilityName &&
640
+ call.toolBrief === calls[0].toolBrief,
641
+ );
611
642
  labelParts.push(
612
- detailLines.length === 1 && summary.target
613
- ? `${summary.verb} ${summary.target}`
614
- : describeToolCallCount(calls[0].kind, calls[0].toolName, calls.length),
643
+ sameCapability && calls[0].capabilityName
644
+ ? `${summary.verb} ${formatTimes(calls.length)}`
645
+ : detailLines.length === 1 && summary.target
646
+ ? `${summary.verb} ${summary.target}`
647
+ : describeToolCallCount(calls[0].kind, calls[0].toolName, calls.length),
615
648
  );
616
649
  } else if (distinctKinds.length <= 3) {
617
650
  labelParts.push(
@@ -629,6 +662,7 @@ export function buildToolCallGroups(calls: CloudOsToolCall[]): ToolCallGroup[] {
629
662
  // 用第一条 work item 的 id 作 key:展开态在「流式 → 已落库」之间要活下来。
630
663
  key: `group-${calls[0].toolCallId}`,
631
664
  Icon: getToolIcon(calls[0].kind),
665
+ iconUrl: calls.find((call) => call.iconUrl)?.iconUrl,
632
666
  label: labelParts
633
667
  .map((part, index) => (index === 0 ? part : lowerFirst(part)))
634
668
  .join(", "),
@@ -54,6 +54,7 @@ function toolCallView(tc: CloudOsToolCall) {
54
54
  return {
55
55
  label: settled.verb,
56
56
  activeLabel: active.verb,
57
+ iconUrl: tc.iconUrl,
57
58
  query: settled.target ?? "",
58
59
  result:
59
60
  tc.errorText ||
@@ -246,6 +247,7 @@ export const ToolGroupRow = memo(function ToolGroupRow({
246
247
  restingLabel={group.label}
247
248
  activeLabel={`Working for ${elapsed}s`}
248
249
  stats={[]}
250
+ iconUrl={group.iconUrl}
249
251
  className="max-w-none"
250
252
  >
251
253
  {group.calls.map((toolCall) => {
@@ -32,6 +32,7 @@ export interface ToolTimelineProps {
32
32
  restingLabel: string;
33
33
  activeLabel: string;
34
34
  stats: TimelineStat[];
35
+ iconUrl?: string;
35
36
  className?: string;
36
37
  children?: ReactNode;
37
38
  }
@@ -45,6 +46,7 @@ export function ToolTimeline({
45
46
  restingLabel,
46
47
  activeLabel,
47
48
  stats,
49
+ iconUrl,
48
50
  className,
49
51
  children,
50
52
  }: ToolTimelineProps) {
@@ -57,6 +59,15 @@ export function ToolTimeline({
57
59
  >
58
60
  <CollapsibleTrigger className="group/trigger text-foreground/55 hover:text-foreground/90 flex items-center gap-1.5 rounded-md py-1 text-[13.5px] transition-colors outline-none">
59
61
  <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" />
62
+ {iconUrl && (
63
+ <img
64
+ src={iconUrl}
65
+ alt=""
66
+ aria-hidden="true"
67
+ className="size-4 shrink-0 rounded-sm object-contain"
68
+ data-tool-icon=""
69
+ />
70
+ )}
60
71
  <SwapLabel
61
72
  active={streaming ? 0 : 1}
62
73
  className="text-start tabular-nums"
@@ -62,6 +62,9 @@ export interface CloudOsActionPresentation {
62
62
  executionId: string;
63
63
  state: "running" | "ready" | "failed" | "outcome_unknown";
64
64
  failureCode?: "insufficient_credits";
65
+ capabilityName?: string;
66
+ toolBrief?: string;
67
+ iconUrl?: string;
65
68
  produces?: CloudOsActionProduces;
66
69
  content: CloudOsActionPresentationFile[];
67
70
  }
@@ -338,6 +341,23 @@ function actionPresentationOf(value: unknown): CloudOsActionPresentation | null
338
341
  }
339
342
  if (state !== "ready" && content.length > 0) return null;
340
343
 
344
+ const toolBrief = typeof presentation.toolBrief === "string" && presentation.toolBrief.trim()
345
+ ? presentation.toolBrief.trim()
346
+ : undefined;
347
+ const capabilityName = typeof presentation.capabilityName === "string" && presentation.capabilityName.trim()
348
+ ? presentation.capabilityName.trim()
349
+ : undefined;
350
+ let iconUrl: string | undefined;
351
+ if (presentation.iconUrl !== undefined) {
352
+ if (typeof presentation.iconUrl !== "string" || !presentation.iconUrl.trim()) return null;
353
+ try {
354
+ if (new URL(presentation.iconUrl).protocol !== "https:") return null;
355
+ } catch {
356
+ return null;
357
+ }
358
+ iconUrl = presentation.iconUrl;
359
+ }
360
+
341
361
  return {
342
362
  type: "action-execution",
343
363
  executionId: presentation.executionId,
@@ -345,6 +365,9 @@ function actionPresentationOf(value: unknown): CloudOsActionPresentation | null
345
365
  ...(state === "failed" && presentation.failureCode === "insufficient_credits"
346
366
  ? { failureCode: presentation.failureCode }
347
367
  : {}),
368
+ ...(capabilityName ? { capabilityName } : {}),
369
+ ...(toolBrief ? { toolBrief } : {}),
370
+ ...(iconUrl ? { iconUrl } : {}),
348
371
  ...(produces ? { produces } : {}),
349
372
  content,
350
373
  };
@@ -408,9 +431,26 @@ function assistantBlocks(
408
431
  approvalIdOf: (part: unknown) => string | undefined,
409
432
  ): AssistantBlock[] {
410
433
  const blocks: AssistantBlock[] = [];
434
+ const actionToolMetaByCallId = new Map<string, UnknownRecord>();
435
+ for (const part of message.parts) {
436
+ if (dataKindOf(part) !== "action_tool_meta") continue;
437
+ const record = recordOf(part);
438
+ const data = dataOf(part);
439
+ const toolCallId = String(data.toolCallId ?? record.id ?? "");
440
+ if (toolCallId) actionToolMetaByCallId.set(toolCallId, data);
441
+ }
411
442
  let pendingCalls: CloudOsToolCall[] = [];
443
+ let pendingActionCalls: CloudOsToolCall[] = [];
412
444
 
413
- const flush = () => {
445
+ const flushActionCalls = () => {
446
+ if (pendingActionCalls.length === 0) return;
447
+ for (const group of buildToolCallGroups(pendingActionCalls)) {
448
+ blocks.push({ kind: "toolGroup", key: group.key, group });
449
+ }
450
+ pendingActionCalls = [];
451
+ };
452
+
453
+ const flushRegularCalls = () => {
414
454
  if (pendingCalls.length === 0) return;
415
455
  for (const group of buildToolCallGroups(pendingCalls)) {
416
456
  blocks.push({ kind: "toolGroup", key: group.key, group });
@@ -418,6 +458,11 @@ function assistantBlocks(
418
458
  pendingCalls = [];
419
459
  };
420
460
 
461
+ const flush = () => {
462
+ flushActionCalls();
463
+ flushRegularCalls();
464
+ };
465
+
421
466
  message.parts.forEach((part, partIndex) => {
422
467
  const key = `${message.id}:${partIndex}`;
423
468
  const record = recordOf(part);
@@ -461,6 +506,7 @@ function assistantBlocks(
461
506
  }
462
507
 
463
508
  const dataKind = dataKindOf(part);
509
+ if (dataKind === "action_tool_meta") return;
464
510
  if (dataKind === "error") {
465
511
  const data = dataOf(part);
466
512
  flush();
@@ -546,11 +592,23 @@ function assistantBlocks(
546
592
  const toolName = toolNameOf(part);
547
593
  if (toolName === null) return;
548
594
 
549
- const call = toCloudOsToolCall(part, key, isActive);
595
+ const toolCallId = String(record.toolCallId ?? record.id ?? "");
596
+ const call = toCloudOsToolCall(
597
+ part,
598
+ key,
599
+ isActive,
600
+ actionToolMetaByCallId.get(toolCallId),
601
+ );
550
602
  const presentation = actionPresentationOf(call.output);
551
603
  if (presentation) {
552
- pendingCalls.push(call);
553
- flush();
604
+ if (presentation.state === "running") {
605
+ flushActionCalls();
606
+ pendingCalls.push(call);
607
+ flush();
608
+ } else {
609
+ flushRegularCalls();
610
+ pendingActionCalls.push(call);
611
+ }
554
612
  blocks.push({ kind: "actionPresentation", key, presentation });
555
613
  return;
556
614
  }
@@ -600,6 +658,7 @@ function assistantBlocks(
600
658
  return;
601
659
  }
602
660
 
661
+ flushActionCalls();
603
662
  pendingCalls.push(call);
604
663
  });
605
664
 
@@ -830,11 +889,19 @@ function dropSupersededActionPresentations(entries: CloudOsEntry[]): CloudOsEntr
830
889
  return;
831
890
  }
832
891
  if (block.presentation.state !== "running") {
833
- current.presentation = block.presentation;
892
+ current.presentation = {
893
+ ...block.presentation,
894
+ capabilityName: block.presentation.capabilityName ?? current.presentation.capabilityName,
895
+ toolBrief: block.presentation.toolBrief ?? current.presentation.toolBrief,
896
+ iconUrl: block.presentation.iconUrl ?? current.presentation.iconUrl,
897
+ };
834
898
  } else if (current.presentation.state === "running") {
835
899
  current.presentation = {
836
900
  ...block.presentation,
837
901
  produces: block.presentation.produces ?? current.presentation.produces,
902
+ capabilityName: block.presentation.capabilityName ?? current.presentation.capabilityName,
903
+ toolBrief: block.presentation.toolBrief ?? current.presentation.toolBrief,
904
+ iconUrl: block.presentation.iconUrl ?? current.presentation.iconUrl,
838
905
  };
839
906
  }
840
907
  });
@@ -61,6 +61,7 @@ export interface FileViewProps {
61
61
  file: FileViewFile;
62
62
  placement: FileViewPlacement;
63
63
  variant?: FileViewVariant;
64
+ leadingMeta?: ReactNode;
64
65
  status?: FileViewStatus;
65
66
  progress?: number;
66
67
  onOpen?: () => void;
@@ -172,11 +173,13 @@ function ProductFileView({
172
173
  file,
173
174
  label,
174
175
  placement,
176
+ leadingMeta,
175
177
  onOpen,
176
178
  }: {
177
179
  file: FileViewFile;
178
180
  label: string;
179
181
  placement: Exclude<FileViewPlacement, "composer">;
182
+ leadingMeta?: ReactNode;
180
183
  onOpen?: () => void;
181
184
  }) {
182
185
  const type = productTypeForMediaType(file.mediaType);
@@ -225,6 +228,8 @@ function ProductFileView({
225
228
  <span className="flex w-fit max-w-full flex-col gap-2.5" data-product-file-view={type}>
226
229
  {preview}
227
230
  <span className="flex min-w-0 max-w-full items-center gap-1 text-xs text-kumo-inactive">
231
+ {leadingMeta}
232
+ {leadingMeta && <span aria-hidden="true">·</span>}
228
233
  <span className="capitalize text-kumo-subtle">{type}</span>
229
234
  <span aria-hidden="true">·</span>
230
235
  <span className="min-w-0 truncate">{label}</span>
@@ -306,6 +311,7 @@ export function FileView({
306
311
  file,
307
312
  placement,
308
313
  variant = "compact",
314
+ leadingMeta,
309
315
  status = "ready",
310
316
  progress = 0,
311
317
  onOpen,
@@ -413,6 +419,7 @@ export function FileView({
413
419
  file={file}
414
420
  label={label}
415
421
  placement={placement}
422
+ leadingMeta={leadingMeta}
416
423
  onOpen={onOpen}
417
424
  />
418
425
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/message-panel",
3
- "version": "0.3.0-alpha.6",
3
+ "version": "0.3.0-alpha.9",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -1 +1 @@
1
- {"version":"4.1.10","results":[[":bot/cycles.test.ts",{"duration":55.58770800000002,"failed":false}],[":bot/skins.test.ts",{"duration":3032.4465840000003,"failed":false}],[":bot/engine.test.ts",{"duration":100.501834,"failed":false}],[":bot/shape.test.ts",{"duration":16.04204200000001,"failed":false}],[":bot/expressions.test.ts",{"duration":8.212458999999996,"failed":false}],[":bot/face.test.ts",{"duration":3.6211250000000064,"failed":false}],[":drag.test.ts",{"duration":1.7712499999999807,"failed":false}],[":animation.test.ts",{"duration":2.3806249999999807,"failed":false}]]}
1
+ {"version":"4.1.10","results":[[":bot/cycles.test.ts",{"duration":54.568250000000006,"failed":false}],[":bot/skins.test.ts",{"duration":2830.836917,"failed":false}],[":bot/engine.test.ts",{"duration":80.72770900000003,"failed":false}],[":bot/shape.test.ts",{"duration":10.531834000000003,"failed":false}],[":bot/expressions.test.ts",{"duration":8.110375000000005,"failed":false}],[":bot/face.test.ts",{"duration":2.7700000000000102,"failed":false}],[":drag.test.ts",{"duration":1.9160829999999862,"failed":false}],[":animation.test.ts",{"duration":2.0596669999999904,"failed":false}]]}