@springbrand/message-panel 0.1.3-alpha.23 → 0.1.3-alpha.25

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,9 @@
1
+ <svg preserveAspectRatio="none" overflow="visible" width="12.2369" height="12.2369" viewBox="0 0 12.2369 12.2369" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M5.18061 0.652963C5.50276 -0.217655 6.73415 -0.217654 7.05631 0.652964L7.64089 2.23277C8.04603 3.32764 8.90927 4.19089 10.0042 4.59603L11.584 5.18061C12.4546 5.50276 12.4546 6.73415 11.584 7.05631L10.0041 7.64089C8.90927 8.04603 8.04603 8.90927 7.64089 10.0042L7.05631 11.584C6.73415 12.4546 5.50276 12.4546 5.18061 11.584L4.59603 10.0041C4.19089 8.90927 3.32764 8.04603 2.23277 7.64089L0.652963 7.05631C-0.217655 6.73415 -0.217654 5.50276 0.652964 5.18061L2.23277 4.59603C3.32764 4.19089 4.19089 3.32764 4.59603 2.23277L5.18061 0.652963Z" fill="url(#thinking-spark-gradient)"/>
3
+ <defs>
4
+ <radialGradient id="thinking-spark-gradient" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(6.11846 2.11846) rotate(90) scale(12)">
5
+ <stop stop-color="#FF9938"/>
6
+ <stop offset="1" stop-color="#FFE46C"/>
7
+ </radialGradient>
8
+ </defs>
9
+ </svg>
@@ -33,8 +33,14 @@ import {
33
33
  SuggestionsBlock,
34
34
  type ApprovalDecision,
35
35
  } from "./rich-blocks";
36
- import { ThinkingTraceRow, ToolGroupRow } from "./tool-rows";
36
+ import {
37
+ ToolGroupRow,
38
+ WorkDescriptionRow,
39
+ WorkTraceDisclosure,
40
+ } from "./tool-rows";
37
41
  import { CloudOsActivityIndicator } from "./activity-indicator";
42
+ import { ProductGeneration } from "./image-generation";
43
+ import { GenerationLoader } from "./loading-state";
38
44
  import { CapabilityChip } from "../capability-chip";
39
45
  import {
40
46
  renderFileView,
@@ -45,8 +51,10 @@ import {
45
51
  deriveTurnActivity,
46
52
  formatClockTime,
47
53
  formatFullTimestamp,
54
+ partitionAssistantBlocks,
48
55
  rhythmTopClass,
49
56
  type CloudOsEntry,
57
+ type CloudOsActionPresentation,
50
58
  } from "./transcript-model";
51
59
 
52
60
  export type CloudOsChatStatus = "ready" | "submitted" | "streaming" | "error";
@@ -100,6 +108,18 @@ export interface CloudOsChatMessagesProps {
100
108
  onCopy?: (text: string) => void;
101
109
  }
102
110
 
111
+ export function collapseEntriesWithNewNarrative(
112
+ expanded: ReadonlySet<string>,
113
+ previousNarrativeKeys: ReadonlySet<string>,
114
+ narrativeKeys: ReadonlySet<string>,
115
+ ): ReadonlySet<string> {
116
+ const next = new Set(expanded);
117
+ for (const key of narrativeKeys) {
118
+ if (!previousNarrativeKeys.has(key)) next.delete(key);
119
+ }
120
+ return next.size === expanded.size ? expanded : next;
121
+ }
122
+
103
123
  function useCopyAction(): {
104
124
  copied: string | null;
105
125
  copy: (text: string) => void;
@@ -200,6 +220,83 @@ function UserBubble({
200
220
  );
201
221
  }
202
222
 
223
+ function ActionPresentationBlock({
224
+ presentation,
225
+ renderFile,
226
+ resolveUrl,
227
+ }: {
228
+ presentation: CloudOsActionPresentation;
229
+ renderFile?: FileRenderer;
230
+ resolveUrl?: CloudOsUrlResolver;
231
+ }) {
232
+ const { state } = presentation;
233
+ if (state === "ready") {
234
+ return (
235
+ <div
236
+ data-action-presentation={state}
237
+ role="status"
238
+ aria-label="Completed"
239
+ className="space-y-2"
240
+ >
241
+ {presentation.content.length === 0
242
+ ? <p className="m-0 text-[14px] leading-5 text-kumo-subtle">Completed</p>
243
+ : presentation.content.map((file, index) => (
244
+ <Fragment key={`${file.url}:${index}`}>
245
+ {renderFileView(renderFile, {
246
+ file: {
247
+ ...file,
248
+ filename: "Generated file",
249
+ url: resolveUrl?.(file.url) ?? file.url,
250
+ },
251
+ placement: "assistant-message",
252
+ variant: "product",
253
+ })}
254
+ </Fragment>
255
+ ))}
256
+ </div>
257
+ );
258
+ }
259
+
260
+ const product = presentation.produces;
261
+ const productName = product?.type ?? "other";
262
+ const isFileProduct = productName !== "data" && productName !== "other";
263
+ const body = state === "running"
264
+ ? isFileProduct
265
+ ? `Generating ${productName}${product?.mediaType ? ` (${product.mediaType})` : ""}`
266
+ : "Generating"
267
+ : state === "outcome_unknown"
268
+ ? "The provider may have completed this action. Check the status before trying again."
269
+ : undefined;
270
+ if (state === "running") {
271
+ return (
272
+ <div
273
+ data-action-presentation={state}
274
+ data-product-type={productName}
275
+ role="status"
276
+ aria-label={body}
277
+ >
278
+ {isFileProduct
279
+ ? <ProductGeneration type={productName} mediaType={product?.mediaType} />
280
+ : <GenerationLoader />}
281
+ </div>
282
+ );
283
+ }
284
+ return (
285
+ <div
286
+ data-action-presentation={state}
287
+ role={state === "failed" ? "alert" : "status"}
288
+ className="inline-flex max-w-72 items-start rounded-xl border border-kumo-line bg-kumo-elevated px-3 py-2 text-[13px] leading-5"
289
+ >
290
+ <span>
291
+ <strong className="block font-medium text-kumo-default">
292
+ {state === "failed" ? "Generation failed" : "Status unavailable"}
293
+ </strong>
294
+ {body && <span className="block text-kumo-subtle">{body}</span>}
295
+ </span>
296
+ </div>
297
+ );
298
+ }
299
+
203
300
  export function CloudOsChatMessages({
204
301
  messages,
205
302
  status,
@@ -231,6 +328,9 @@ export function CloudOsChatMessages({
231
328
  const [expandedToolCalls, setExpandedToolCalls] = useState<ReadonlySet<string>>(
232
329
  () => new Set(),
233
330
  );
331
+ const [expandedWorkEntries, setExpandedWorkEntries] = useState<
332
+ ReadonlySet<string>
333
+ >(() => new Set());
234
334
  const [expandedCompactions, setExpandedCompactions] = useState<
235
335
  ReadonlySet<string>
236
336
  >(() => new Set());
@@ -248,6 +348,31 @@ export function CloudOsChatMessages({
248
348
  }),
249
349
  [approvalIdOf, isActive, messages, showThinkingTraces],
250
350
  );
351
+ const narrativeEntryKeys = useMemo(
352
+ () =>
353
+ new Set(
354
+ entries.flatMap((entry) =>
355
+ entry.type === "assistant" &&
356
+ partitionAssistantBlocks(entry.blocks).content.some(
357
+ (block) => block.kind === "text",
358
+ )
359
+ ? [entry.key]
360
+ : [],
361
+ ),
362
+ ),
363
+ [entries],
364
+ );
365
+ const previousNarrativeEntryKeys = useRef<ReadonlySet<string>>(
366
+ narrativeEntryKeys,
367
+ );
368
+
369
+ useEffect(() => {
370
+ const previous = previousNarrativeEntryKeys.current;
371
+ previousNarrativeEntryKeys.current = narrativeEntryKeys;
372
+ setExpandedWorkEntries((expanded) =>
373
+ collapseEntriesWithNewNarrative(expanded, previous, narrativeEntryKeys),
374
+ );
375
+ }, [narrativeEntryKeys]);
251
376
 
252
377
  const entryTopClasses = useMemo(
253
378
  () =>
@@ -266,6 +391,15 @@ export function CloudOsChatMessages({
266
391
  });
267
392
  }, []);
268
393
 
394
+ const toggleWorkEntry = useCallback((key: string) => {
395
+ setExpandedWorkEntries((current) => {
396
+ const next = new Set(current);
397
+ if (next.has(key)) next.delete(key);
398
+ else next.add(key);
399
+ return next;
400
+ });
401
+ }, []);
402
+
269
403
  const toggleCompaction = useCallback((key: string) => {
270
404
  setExpandedCompactions((current) => {
271
405
  const next = new Set(current);
@@ -413,7 +547,7 @@ export function CloudOsChatMessages({
413
547
  <span className="h-px flex-1 bg-kumo-line" aria-hidden="true" />
414
548
  </div>
415
549
  {expanded && (
416
- <div className="themed-surface-inset cos-markdown mt-3 rounded-2xl border border-kumo-line/70 bg-kumo-elevated/45 p-3.5 text-[13px] leading-[19px]">
550
+ <div className="cos-markdown mt-3 rounded-2xl border border-kumo-line/70 bg-kumo-elevated/45 p-3.5 text-[13px] leading-[19px]">
417
551
  <MarkdownMessage
418
552
  message={entry.summary}
419
553
  resolveUrl={resolveUrl}
@@ -424,24 +558,90 @@ export function CloudOsChatMessages({
424
558
  );
425
559
  }
426
560
 
427
- const hasNarrative = entry.blocks.some(
561
+ const { work: workBlocks, content: contentBlocks } =
562
+ partitionAssistantBlocks(entry.blocks);
563
+ const workGroups = workBlocks.flatMap((block) =>
564
+ block.kind === "toolGroup" ? [block.group] : [],
565
+ );
566
+ const hasNarrative = contentBlocks.some(
428
567
  (block) => block.kind === "text",
429
568
  );
430
569
 
431
570
  return (
432
571
  <div
433
572
  key={entry.key}
434
- className={`${topClass} min-w-0 w-full max-w-[860px] space-y-2`}
573
+ className={`${topClass} min-w-0 w-full max-w-[860px]`}
435
574
  >
436
- <div className="group/agentMessage relative space-y-1.5">
437
- {entry.blocks.map((block) => {
575
+ <div className="group/agentMessage relative space-y-4">
576
+ {workBlocks.length > 0 && (
577
+ <WorkTraceDisclosure
578
+ durationMs={entry.durationMs}
579
+ groups={workGroups}
580
+ completed={!isActive}
581
+ open={expandedWorkEntries.has(entry.key)}
582
+ onToggle={() => toggleWorkEntry(entry.key)}
583
+ >
584
+ {workBlocks.map((block) => {
585
+ switch (block.kind) {
586
+ case "reasoning":
587
+ case "text":
588
+ return (
589
+ <WorkDescriptionRow
590
+ key={block.key}
591
+ text={block.text}
592
+ resolveUrl={resolveUrl}
593
+ />
594
+ );
595
+ case "toolGroup":
596
+ return (
597
+ <ToolGroupRow
598
+ key={block.key}
599
+ group={block.group}
600
+ open={expandedToolCalls.has(block.group.key)}
601
+ expandedKeys={expandedToolCalls}
602
+ onToggle={toggleToolCall}
603
+ />
604
+ );
605
+ case "plan":
606
+ return (
607
+ <PlanBlock
608
+ key={block.key}
609
+ steps={block.steps}
610
+ running={block.running}
611
+ defaultOpen={false}
612
+ showCollapsedSummary={false}
613
+ />
614
+ );
615
+ case "parallel":
616
+ return (
617
+ <ParallelBlock
618
+ key={block.key}
619
+ label={block.label}
620
+ tools={block.tools}
621
+ />
622
+ );
623
+ case "subagents":
624
+ return (
625
+ <SubAgentsBlock
626
+ key={block.key}
627
+ agents={block.agents}
628
+ />
629
+ );
630
+ default:
631
+ return null;
632
+ }
633
+ })}
634
+ </WorkTraceDisclosure>
635
+ )}
636
+
637
+ {contentBlocks.map((block) => {
438
638
  switch (block.kind) {
439
639
  case "reasoning":
440
640
  return (
441
- <ThinkingTraceRow
641
+ <WorkDescriptionRow
442
642
  key={block.key}
443
- reasoning={block.text}
444
- running={block.running}
643
+ text={block.text}
644
+ resolveUrl={resolveUrl}
445
645
  />
446
646
  );
447
647
  case "text": {
@@ -564,6 +764,15 @@ export function CloudOsChatMessages({
564
764
  })}
565
765
  </Fragment>
566
766
  );
767
+ case "actionPresentation":
768
+ return (
769
+ <ActionPresentationBlock
770
+ key={block.key}
771
+ presentation={block.presentation}
772
+ renderFile={renderFile}
773
+ resolveUrl={resolveUrl}
774
+ />
775
+ );
567
776
  case "error":
568
777
  return (
569
778
  <ErrorBlock
@@ -0,0 +1,76 @@
1
+ import type { CloudOsActionPresentation } from "./transcript-model";
2
+
3
+ export type ProductType = NonNullable<CloudOsActionPresentation["produces"]>["type"];
4
+
5
+ export const PRODUCT_PRESETS = {
6
+ image: { frame: "aspect-square w-52", columns: 6, rows: 6 },
7
+ video: { frame: "aspect-video w-64", columns: 8, rows: 4 },
8
+ audio: { frame: "h-24 w-64", columns: 9, rows: 2 },
9
+ document: { frame: "aspect-[4/5] w-44", columns: 5, rows: 7 },
10
+ archive: { frame: "h-28 w-44", columns: 5, rows: 3 },
11
+ data: { frame: "h-32 w-52", columns: 6, rows: 3 },
12
+ other: { frame: "h-28 w-44", columns: 5, rows: 3 },
13
+ } satisfies Record<ProductType, { frame: string; columns: number; rows: number }>;
14
+
15
+ export function productTypeForMediaType(mediaType?: string): ProductType {
16
+ const type = mediaType?.toLowerCase() ?? "";
17
+ if (type.startsWith("image/")) return "image";
18
+ if (type.startsWith("audio/")) return "audio";
19
+ if (type.startsWith("video/")) return "video";
20
+ if (/zip|archive|compressed|gzip|tar/.test(type)) return "archive";
21
+ if (/json|csv|spreadsheet|excel|parquet|sql/.test(type)) return "data";
22
+ if (type.startsWith("text/") || /pdf|document|word|rtf|presentation/.test(type)) {
23
+ return "document";
24
+ }
25
+ return "other";
26
+ }
27
+
28
+ /** Product-aware adaptation of https://www.assistant-ui.com/elements/image-generation. */
29
+ export function ProductGeneration({
30
+ type,
31
+ mediaType,
32
+ }: {
33
+ type: ProductType;
34
+ mediaType?: string;
35
+ }) {
36
+ const { frame, columns, rows } = PRODUCT_PRESETS[type];
37
+ const dots = Array.from({ length: columns * rows }, (_, index) => index);
38
+ return (
39
+ <div className="flex w-fit max-w-full flex-col gap-2.5" data-product-generation={type}>
40
+ <div className={`themed-thumbnail-shadow relative max-w-full overflow-hidden rounded-2xl border border-kumo-line bg-kumo-elevated ${frame}`}>
41
+ <div
42
+ className="absolute inset-0 grid place-items-center p-5"
43
+ data-generation-grid={`${columns}x${rows}`}
44
+ style={{
45
+ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
46
+ gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
47
+ }}
48
+ aria-hidden="true"
49
+ >
50
+ {dots.map((dot) => {
51
+ const row = Math.floor(dot / columns);
52
+ const column = dot % columns;
53
+ return (
54
+ <span
55
+ key={dot}
56
+ data-generation-dot=""
57
+ className="size-1 rounded-full bg-kumo-fill-hover animate-pulse motion-reduce:animate-none"
58
+ style={{ animationDelay: `${(row + column) * 90}ms` }}
59
+ />
60
+ );
61
+ })}
62
+ </div>
63
+ {mediaType && (
64
+ <span className="absolute end-2.5 top-2.5 font-mono text-[10px] text-kumo-inactive">
65
+ {mediaType}
66
+ </span>
67
+ )}
68
+ </div>
69
+ <p className="m-0 min-w-0 truncate text-xs text-kumo-inactive">
70
+ <span className="capitalize text-kumo-subtle">{type}</span>
71
+ <span aria-hidden="true"> · </span>
72
+ <span className="cos-thinking-shimmer motion-reduce:animate-none">Generating</span>
73
+ </p>
74
+ </div>
75
+ );
76
+ }
@@ -0,0 +1,25 @@
1
+ const CELLS = Array.from({ length: 9 }, (_, index) => index);
2
+
3
+ /** CSS-driven adaptation of https://www.assistant-ui.com/elements/loading-state. */
4
+ export function GenerationLoader() {
5
+ return (
6
+ <div
7
+ data-slot="generation-loader"
8
+ className="inline-flex flex-col items-center gap-3 py-2"
9
+ >
10
+ <div aria-hidden="true" className="grid grid-cols-3 gap-1">
11
+ {CELLS.map((cell) => (
12
+ <span
13
+ key={cell}
14
+ data-loading-cell=""
15
+ className="size-2 animate-pulse rounded-full bg-[var(--text-color-kumo-default)] motion-reduce:animate-none"
16
+ style={{ animationDelay: `${cell * 100}ms` }}
17
+ />
18
+ ))}
19
+ </div>
20
+ <span className="cos-thinking-shimmer text-sm text-kumo-inactive motion-reduce:animate-none">
21
+ Generating
22
+ </span>
23
+ </div>
24
+ );
25
+ }
@@ -1,3 +1,9 @@
1
+ import {
2
+ TextMessagePartProvider,
3
+ useMessagePartText,
4
+ useSmooth,
5
+ type SmoothOptions,
6
+ } from "@assistant-ui/react";
1
7
  import { memo, useMemo, type ReactNode } from "react";
2
8
  import {
3
9
  defaultRehypePlugins,
@@ -14,7 +20,10 @@ import {
14
20
  export type CloudOsUrlResolver = (url: string) => string | null | undefined;
15
21
 
16
22
  const ALLOWED_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
17
- const CJK_TEXT = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
23
+ const SMOOTH_STREAMING = {
24
+ maxCharsPerFrame: 12,
25
+ minCommitMs: 32,
26
+ } satisfies SmoothOptions;
18
27
 
19
28
  export function safeExternalUrl(href: string | undefined): string | undefined {
20
29
  if (!href) return undefined;
@@ -101,6 +110,20 @@ export const MarkdownMessage = memo(function MarkdownMessage({
101
110
  resolveUrl?: CloudOsUrlResolver;
102
111
  streaming?: boolean;
103
112
  }): ReactNode {
113
+ return (
114
+ <TextMessagePartProvider text={message} isRunning={streaming}>
115
+ <MarkdownMessageContent resolveUrl={resolveUrl} />
116
+ </TextMessagePartProvider>
117
+ );
118
+ });
119
+
120
+ function MarkdownMessageContent({
121
+ resolveUrl,
122
+ }: {
123
+ resolveUrl?: CloudOsUrlResolver;
124
+ }) {
125
+ const part = useSmooth(useMessagePartText(), SMOOTH_STREAMING);
126
+ const streaming = part.status.type === "running";
104
127
  const rehypePlugins = useMemo(
105
128
  () =>
106
129
  resolveUrl
@@ -110,13 +133,7 @@ export const MarkdownMessage = memo(function MarkdownMessage({
110
133
  );
111
134
  return (
112
135
  <Streamdown
113
- animated={
114
- streaming
115
- ? CJK_TEXT.test(message)
116
- ? { sep: "char", duration: 100, stagger: 5 }
117
- : true
118
- : false
119
- }
136
+ animated={false}
120
137
  className="space-y-0"
121
138
  controls={false}
122
139
  mode={streaming ? "streaming" : "static"}
@@ -125,7 +142,7 @@ export const MarkdownMessage = memo(function MarkdownMessage({
125
142
  skipHtml
126
143
  components={DEFAULT_COMPONENTS}
127
144
  >
128
- {message}
145
+ {part.text}
129
146
  </Streamdown>
130
147
  );
131
- });
148
+ }