@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.
@@ -27,6 +27,7 @@ export interface CloudOsMessageMetadata extends UnknownRecord {
27
27
  createdAt?: number;
28
28
  error?: string;
29
29
  interruptedByUser?: boolean;
30
+ turnId?: string;
30
31
  turnDurationMs?: number;
31
32
  turnStartedAt?: number;
32
33
  turnStatus?: string;
@@ -45,6 +46,25 @@ export interface CloudOsAttachment {
45
46
  mediaType?: string;
46
47
  }
47
48
 
49
+ interface CloudOsActionPresentationFile {
50
+ type: "file";
51
+ url: string;
52
+ mediaType?: string;
53
+ }
54
+
55
+ interface CloudOsActionProduces {
56
+ type: "image" | "audio" | "video" | "document" | "archive" | "data" | "other";
57
+ mediaType?: string;
58
+ }
59
+
60
+ export interface CloudOsActionPresentation {
61
+ type: "action-execution";
62
+ executionId: string;
63
+ state: "running" | "ready" | "failed" | "outcome_unknown";
64
+ produces?: CloudOsActionProduces;
65
+ content: CloudOsActionPresentationFile[];
66
+ }
67
+
48
68
  export interface PlanStep {
49
69
  text: string;
50
70
  status: "pending" | "in_progress" | "done";
@@ -79,6 +99,11 @@ export type AssistantBlock =
79
99
  mediaType?: string;
80
100
  }
81
101
  | { kind: "file"; key: string; file: CloudOsAttachment }
102
+ | {
103
+ kind: "actionPresentation";
104
+ key: string;
105
+ presentation: CloudOsActionPresentation;
106
+ }
82
107
  | { kind: "error"; key: string; title: string; body?: string };
83
108
 
84
109
  export interface ParallelTool {
@@ -118,8 +143,10 @@ export type CloudOsEntry =
118
143
  type: "assistant";
119
144
  key: string;
120
145
  messageId: string;
146
+ turnId?: string;
121
147
  blocks: AssistantBlock[];
122
148
  copyText: string;
149
+ durationMs?: number;
123
150
  timestamp?: number;
124
151
  terminal?: { kind: "interrupted" | "error"; title: string; body?: string };
125
152
  };
@@ -143,6 +170,10 @@ function nonNegativeNumber(value: unknown): number | undefined {
143
170
  : undefined;
144
171
  }
145
172
 
173
+ function nonEmptyString(value: unknown): string | undefined {
174
+ return typeof value === "string" && value.trim() ? value : undefined;
175
+ }
176
+
146
177
  export function cloudOsMetadata(message: UIMessage): CloudOsMessageMetadata {
147
178
  return recordOf(message.metadata) as CloudOsMessageMetadata;
148
179
  }
@@ -197,6 +228,54 @@ export function formatFullTimestamp(timestamp: number): string {
197
228
  });
198
229
  }
199
230
 
231
+ function turnDuration(metadata: CloudOsMessageMetadata): number | undefined {
232
+ const explicit = nonNegativeNumber(metadata.turnDurationMs);
233
+ if (explicit !== undefined) return explicit;
234
+ const startedAt = nonNegativeNumber(metadata.turnStartedAt);
235
+ const completedAt = nonNegativeNumber(metadata.completedAt);
236
+ return startedAt !== undefined && completedAt !== undefined
237
+ ? Math.max(0, completedAt - startedAt)
238
+ : undefined;
239
+ }
240
+
241
+ function isWorkBlock(block: AssistantBlock): boolean {
242
+ return (
243
+ block.kind === "reasoning" ||
244
+ block.kind === "toolGroup" ||
245
+ block.kind === "plan" ||
246
+ block.kind === "parallel" ||
247
+ block.kind === "subagents"
248
+ );
249
+ }
250
+
251
+ /**
252
+ * 把一条 assistant 消息拆成可折叠的工作过程与始终可见的最终内容。
253
+ * 最后一段工作之前的 text 是过程旁白;最后一次工作之后的 text 才是正文。
254
+ */
255
+ export function partitionAssistantBlocks(blocks: readonly AssistantBlock[]): {
256
+ work: AssistantBlock[];
257
+ content: AssistantBlock[];
258
+ } {
259
+ let lastWorkIndex = -1;
260
+ blocks.forEach((block, index) => {
261
+ if (isWorkBlock(block)) lastWorkIndex = index;
262
+ });
263
+
264
+ const work: AssistantBlock[] = [];
265
+ const content: AssistantBlock[] = [];
266
+ blocks.forEach((block, index) => {
267
+ if (
268
+ isWorkBlock(block) ||
269
+ (block.kind === "text" && index < lastWorkIndex)
270
+ ) {
271
+ work.push(block);
272
+ } else {
273
+ content.push(block);
274
+ }
275
+ });
276
+ return { work, content };
277
+ }
278
+
200
279
  function toolNameOf(part: unknown): string | null {
201
280
  const type = String(recordOf(part).type ?? "");
202
281
  if (type === "dynamic-tool") {
@@ -244,6 +323,71 @@ function normalizeStatus(value: unknown): ParallelTool["status"] {
244
323
  return "pending";
245
324
  }
246
325
 
326
+ function actionPresentationOf(value: unknown): CloudOsActionPresentation | null {
327
+ const presentation = recordOf(recordOf(value).presentation);
328
+ const state = presentation.state;
329
+ if (
330
+ presentation.type !== "action-execution" ||
331
+ typeof presentation.executionId !== "string" ||
332
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
333
+ presentation.executionId,
334
+ ) ||
335
+ (state !== "running" &&
336
+ state !== "ready" &&
337
+ state !== "failed" &&
338
+ state !== "outcome_unknown") ||
339
+ !Array.isArray(presentation.content)
340
+ ) return null;
341
+
342
+ let produces: CloudOsActionProduces | undefined;
343
+ if (presentation.produces !== undefined) {
344
+ const hint = recordOf(presentation.produces);
345
+ if (
346
+ !["image", "audio", "video", "document", "archive", "data", "other"].includes(
347
+ String(hint.type),
348
+ ) ||
349
+ (hint.mediaType !== undefined &&
350
+ (typeof hint.mediaType !== "string" || !/^[^\s/]+\/[^\s/]+$/.test(hint.mediaType)))
351
+ ) return null;
352
+ produces = {
353
+ type: hint.type as CloudOsActionProduces["type"],
354
+ ...(hint.mediaType === undefined ? {} : { mediaType: hint.mediaType as string }),
355
+ };
356
+ }
357
+ if (state !== "running" && produces) return null;
358
+
359
+ const content: CloudOsActionPresentationFile[] = [];
360
+ for (const value of presentation.content) {
361
+ const file = recordOf(value);
362
+ if (file.type !== "file" || typeof file.url !== "string") return null;
363
+ try {
364
+ if (new URL(file.url).protocol !== "https:") return null;
365
+ } catch {
366
+ return null;
367
+ }
368
+ if (
369
+ file.mediaType !== undefined &&
370
+ (typeof file.mediaType !== "string" || !file.mediaType.trim())
371
+ ) return null;
372
+ content.push({
373
+ type: "file" as const,
374
+ url: file.url,
375
+ ...(file.mediaType !== undefined
376
+ ? { mediaType: file.mediaType }
377
+ : {}),
378
+ });
379
+ }
380
+ if (state !== "ready" && content.length > 0) return null;
381
+
382
+ return {
383
+ type: "action-execution",
384
+ executionId: presentation.executionId,
385
+ state,
386
+ ...(produces ? { produces } : {}),
387
+ content,
388
+ };
389
+ }
390
+
247
391
  // ── 独立消息(非 assistant 回合)判定 ────────────────────────────────────────
248
392
 
249
393
  function standaloneEntry(
@@ -427,6 +571,13 @@ function assistantBlocks(
427
571
  if (toolName === null) return;
428
572
 
429
573
  const call = toCloudOsToolCall(part, key, isActive);
574
+ const presentation = actionPresentationOf(call.output);
575
+ if (presentation) {
576
+ pendingCalls.push(call);
577
+ flush();
578
+ blocks.push({ kind: "actionPresentation", key, presentation });
579
+ return;
580
+ }
430
581
 
431
582
  if (call.awaitingApproval) {
432
583
  const approvalId = approvalIdOf(part);
@@ -524,6 +675,9 @@ export function buildCloudOsEntries({
524
675
  }
525
676
  return -1;
526
677
  })();
678
+ const activeTurnId = isActive && lastAssistantIndex >= 0
679
+ ? nonEmptyString(cloudOsMetadata(messages[lastAssistantIndex]!).turnId)
680
+ : undefined;
527
681
 
528
682
  messages.forEach((message, index) => {
529
683
  if (message.role === "system") return;
@@ -578,8 +732,10 @@ export function buildCloudOsEntries({
578
732
  type: "assistant",
579
733
  key: `assistant-${message.id}-${index}`,
580
734
  messageId: message.id,
735
+ turnId: nonEmptyString(metadata.turnId),
581
736
  blocks,
582
737
  copyText: messageText(message),
738
+ durationMs: turnDuration(metadata),
583
739
  timestamp:
584
740
  nonNegativeNumber(metadata.completedAt) ??
585
741
  nonNegativeNumber(metadata.createdAt),
@@ -587,7 +743,100 @@ export function buildCloudOsEntries({
587
743
  });
588
744
  });
589
745
 
590
- return dropSupersededPlans(entries);
746
+ return aggregateCompletedTurnWork(
747
+ dropSupersededActionPresentations(dropSupersededPlans(entries)),
748
+ activeTurnId,
749
+ );
750
+ }
751
+
752
+ /** Collapse every completed Turn's assistant segments into one display entry. */
753
+ function aggregateCompletedTurnWork(
754
+ entries: CloudOsEntry[],
755
+ activeTurnId: string | undefined,
756
+ ): CloudOsEntry[] {
757
+ const indexesByTurn = new Map<string, number[]>();
758
+ entries.forEach((entry, index) => {
759
+ if (entry.type !== "assistant" || !entry.turnId) return;
760
+ const indexes = indexesByTurn.get(entry.turnId) ?? [];
761
+ indexes.push(index);
762
+ indexesByTurn.set(entry.turnId, indexes);
763
+ });
764
+
765
+ const hidden = new Set<number>();
766
+ const replacements = new Map<number, CloudOsEntry>();
767
+ for (const [turnId, indexes] of indexesByTurn) {
768
+ if (turnId === activeTurnId || indexes.length < 2) continue;
769
+ const assistantEntries = indexes.map(
770
+ (index) => entries[index] as Extract<CloudOsEntry, { type: "assistant" }>,
771
+ );
772
+ const anchorIndex = indexes[indexes.length - 1]!;
773
+ const anchor = assistantEntries[assistantEntries.length - 1]!;
774
+ const durations = assistantEntries.flatMap((entry) =>
775
+ entry.durationMs === undefined ? [] : [entry.durationMs]
776
+ );
777
+ const timestamps = assistantEntries.flatMap((entry) =>
778
+ entry.timestamp === undefined ? [] : [entry.timestamp]
779
+ );
780
+
781
+ indexes.slice(0, -1).forEach((index) => hidden.add(index));
782
+ replacements.set(anchorIndex, {
783
+ ...anchor,
784
+ key: `assistant-turn-${turnId}`,
785
+ blocks: assistantEntries.flatMap((entry) => entry.blocks),
786
+ ...(durations.length > 0 ? { durationMs: Math.max(...durations) } : {}),
787
+ ...(timestamps.length > 0 ? { timestamp: Math.max(...timestamps) } : {}),
788
+ });
789
+ }
790
+
791
+ return entries.flatMap((entry, index) =>
792
+ hidden.has(index) ? [] : [replacements.get(index) ?? entry]
793
+ );
794
+ }
795
+
796
+ function dropSupersededActionPresentations(entries: CloudOsEntry[]): CloudOsEntry[] {
797
+ const snapshots = new Map<string, {
798
+ entryIndex: number;
799
+ blockIndex: number;
800
+ presentation: CloudOsActionPresentation;
801
+ }>();
802
+ entries.forEach((entry, entryIndex) => {
803
+ if (entry.type !== "assistant") return;
804
+ entry.blocks.forEach((block, blockIndex) => {
805
+ if (block.kind !== "actionPresentation") return;
806
+ const current = snapshots.get(block.presentation.executionId);
807
+ if (!current) {
808
+ snapshots.set(block.presentation.executionId, {
809
+ entryIndex,
810
+ blockIndex,
811
+ presentation: block.presentation,
812
+ });
813
+ return;
814
+ }
815
+ if (block.presentation.state !== "running") {
816
+ current.presentation = block.presentation;
817
+ } else if (current.presentation.state === "running") {
818
+ current.presentation = {
819
+ ...block.presentation,
820
+ produces: block.presentation.produces ?? current.presentation.produces,
821
+ };
822
+ }
823
+ });
824
+ });
825
+
826
+ return entries.map((entry, entryIndex) =>
827
+ entry.type !== "assistant"
828
+ ? entry
829
+ : {
830
+ ...entry,
831
+ blocks: entry.blocks.flatMap<AssistantBlock>((block, blockIndex) => {
832
+ if (block.kind !== "actionPresentation") return [block];
833
+ const snapshot = snapshots.get(block.presentation.executionId)!;
834
+ return snapshot.entryIndex === entryIndex && snapshot.blockIndex === blockIndex
835
+ ? [{ ...block, presentation: snapshot.presentation }]
836
+ : [];
837
+ }),
838
+ }
839
+ );
591
840
  }
592
841
 
593
842
  /**
@@ -3,7 +3,6 @@ import {
3
3
  File as FileIcon,
4
4
  Plug,
5
5
  Plus,
6
- Terminal,
7
6
  } from "@phosphor-icons/react";
8
7
  import {
9
8
  AssistantRuntimeProvider,
@@ -106,7 +105,7 @@ export interface CloudOsChatInputProps {
106
105
  * 传了就以它为准来决定显示发送键还是停止键。
107
106
  */
108
107
  turnActive?: boolean;
109
- /** 可用的斜杠命令,也会显示在「+」菜单中。 */
108
+ /** 可用的斜杠命令。 */
110
109
  commands?: readonly CloudOsComposerCommand[];
111
110
  capabilities?: readonly CloudOsCapabilityView[];
112
111
  onCommandSelect?: (command: CloudOsComposerCommand) => void;
@@ -521,25 +520,6 @@ function CloudOsChatInputContent({
521
520
  </span>
522
521
  </DropdownMenu.Item>
523
522
  )}
524
- {commands.length > 0 && (
525
- <>
526
- {commands.map((command) => (
527
- <DropdownMenu.Item
528
- key={`${command.prefix ?? "/"}${command.name}`}
529
- onClick={() => selectCommand(command, false)}
530
- className="!h-auto rounded-xl !px-2 !py-1.5 text-[12px] leading-4 font-normal tracking-[-0.15px] text-kumo-subtle transition-colors data-highlighted:bg-kumo-tint/70 data-highlighted:text-kumo-default"
531
- >
532
- <span className="mr-2 inline-flex h-4 w-4 items-center justify-center text-kumo-inactive">
533
- <Terminal size={14} />
534
- </span>
535
- <span className="min-w-0 flex-1 truncate">
536
- {commandLabel(command)}
537
- </span>
538
- </DropdownMenu.Item>
539
- ))}
540
- <div className="my-1 border-t border-kumo-line/70" />
541
- </>
542
- )}
543
523
  {onFilesSelected && (
544
524
  <DropdownMenu.Item
545
525
  onClick={() => attachmentInputRef.current?.click()}
@@ -1,4 +1,13 @@
1
- import { File as FileIcon } from "@phosphor-icons/react";
1
+ import {
2
+ Database,
3
+ File as FileIcon,
4
+ FileArchive,
5
+ FileAudio,
6
+ FileImage,
7
+ FileText,
8
+ FileVideo,
9
+ type Icon,
10
+ } from "@phosphor-icons/react";
2
11
  import {
3
12
  CheckIcon,
4
13
  FileArchiveIcon,
@@ -14,6 +23,21 @@ import {
14
23
  useState,
15
24
  type ComponentType,
16
25
  } from "react";
26
+ import {
27
+ PRODUCT_PRESETS,
28
+ productTypeForMediaType,
29
+ type ProductType,
30
+ } from "./chat/image-generation";
31
+
32
+ const PRODUCT_ICONS = {
33
+ image: FileImage,
34
+ audio: FileAudio,
35
+ video: FileVideo,
36
+ document: FileText,
37
+ archive: FileArchive,
38
+ data: Database,
39
+ other: FileIcon,
40
+ } satisfies Record<ProductType, Icon>;
17
41
 
18
42
  export type FileViewPlacement =
19
43
  | "assistant-message"
@@ -21,6 +45,7 @@ export type FileViewPlacement =
21
45
  | "user-message";
22
46
 
23
47
  export type FileViewStatus = "error" | "ready" | "uploading";
48
+ export type FileViewVariant = "compact" | "product";
24
49
 
25
50
  export interface FileViewFile {
26
51
  url?: string;
@@ -32,6 +57,7 @@ export interface FileViewFile {
32
57
  export interface FileViewProps {
33
58
  file: FileViewFile;
34
59
  placement: FileViewPlacement;
60
+ variant?: FileViewVariant;
35
61
  status?: FileViewStatus;
36
62
  progress?: number;
37
63
  onRemove?: () => void;
@@ -59,10 +85,12 @@ function ImageFileView({
59
85
  src,
60
86
  label,
61
87
  placement,
88
+ frameClassName,
62
89
  }: {
63
90
  src: string;
64
91
  label: string;
65
92
  placement: Exclude<FileViewPlacement, "composer">;
93
+ frameClassName?: string;
66
94
  }) {
67
95
  const imageRef = useRef<HTMLImageElement>(null);
68
96
  const [loadedSrc, setLoadedSrc] = useState<string>();
@@ -78,9 +106,11 @@ function ImageFileView({
78
106
  }, [src]);
79
107
 
80
108
  const assistant = placement === "assistant-message";
81
- const previewClassName = assistant
82
- ? "relative inline-block max-w-full overflow-hidden rounded-xl border border-kumo-line align-bottom"
83
- : "themed-thumbnail-shadow relative inline-block max-w-64 overflow-hidden rounded-xl border border-kumo-line align-bottom";
109
+ const previewClassName = frameClassName
110
+ ? `themed-thumbnail-shadow relative inline-block max-w-full overflow-hidden rounded-2xl border border-kumo-line align-bottom ${frameClassName}`
111
+ : assistant
112
+ ? "relative inline-block max-w-full overflow-hidden rounded-xl border border-kumo-line align-bottom"
113
+ : "themed-thumbnail-shadow relative inline-block max-w-64 overflow-hidden rounded-xl border border-kumo-line align-bottom";
84
114
 
85
115
  return (
86
116
  <span
@@ -93,9 +123,11 @@ function ImageFileView({
93
123
  src={src}
94
124
  alt={label}
95
125
  className={`${
96
- assistant
97
- ? "block max-h-[28rem] max-w-full object-contain"
98
- : "block max-h-52 max-w-64 object-cover"
126
+ frameClassName
127
+ ? "block size-full object-contain"
128
+ : assistant
129
+ ? "block max-h-[28rem] max-w-full object-contain"
130
+ : "block max-h-52 max-w-64 object-cover"
99
131
  } ${loaded && !error ? "" : "invisible"}`}
100
132
  decoding="async"
101
133
  loading="lazy"
@@ -115,7 +147,7 @@ function ImageFileView({
115
147
  className="size-6 animate-pulse motion-reduce:animate-none"
116
148
  aria-hidden="true"
117
149
  />
118
- <span className="sr-only">Loading {label}</span>
150
+ <span className="sr-only">Loading preview...</span>
119
151
  </span>
120
152
  )}
121
153
  {error && (
@@ -123,7 +155,7 @@ function ImageFileView({
123
155
  className="absolute inset-0 flex min-h-24 min-w-36 items-center justify-center bg-kumo-elevated text-kumo-danger"
124
156
  data-image-state="error"
125
157
  role="img"
126
- aria-label={`Unable to load ${label}`}
158
+ aria-label="Unable to load preview"
127
159
  >
128
160
  <FileImageIcon className="size-6" aria-hidden="true" />
129
161
  </span>
@@ -132,12 +164,100 @@ function ImageFileView({
132
164
  );
133
165
  }
134
166
 
167
+ function ProductFileView({
168
+ file,
169
+ label,
170
+ placement,
171
+ }: {
172
+ file: FileViewFile;
173
+ label: string;
174
+ placement: Exclude<FileViewPlacement, "composer">;
175
+ }) {
176
+ const type = productTypeForMediaType(file.mediaType);
177
+ const { frame } = PRODUCT_PRESETS[type];
178
+ const image = type === "image" && file.url;
179
+ const video = type === "video" && file.url;
180
+ const ProductIcon = PRODUCT_ICONS[type];
181
+ const preview = image
182
+ ? (
183
+ <ImageFileView
184
+ src={file.url!}
185
+ label={label}
186
+ placement={placement}
187
+ frameClassName={frame}
188
+ />
189
+ )
190
+ : video
191
+ ? (
192
+ <video
193
+ src={file.url}
194
+ controls
195
+ preload="metadata"
196
+ playsInline
197
+ aria-label={label}
198
+ className={`themed-thumbnail-shadow block max-w-full rounded-2xl border border-kumo-line bg-kumo-elevated object-contain ${frame}`}
199
+ data-video-preview=""
200
+ />
201
+ )
202
+ : (
203
+ <span className={`themed-thumbnail-shadow relative inline-grid max-w-full place-items-center overflow-hidden rounded-2xl border border-kumo-line bg-kumo-elevated ${frame}`}>
204
+ <ProductIcon
205
+ size={24}
206
+ weight="light"
207
+ className="text-kumo-subtle"
208
+ aria-hidden="true"
209
+ data-product-icon={type}
210
+ />
211
+ {file.mediaType && (
212
+ <span className="absolute end-2.5 top-2.5 font-mono text-[10px] text-kumo-inactive">
213
+ {file.mediaType}
214
+ </span>
215
+ )}
216
+ </span>
217
+ );
218
+ const content = (
219
+ <span className="flex w-fit max-w-full flex-col gap-2.5" data-product-file-view={type}>
220
+ {preview}
221
+ <span className="flex min-w-0 max-w-full items-center gap-1 text-xs text-kumo-inactive">
222
+ <span className="capitalize text-kumo-subtle">{type}</span>
223
+ <span aria-hidden="true">·</span>
224
+ <span className="min-w-0 truncate">{label}</span>
225
+ </span>
226
+ </span>
227
+ );
228
+
229
+ return file.url && !video ? (
230
+ <a
231
+ href={file.url}
232
+ target="_blank"
233
+ rel="noopener noreferrer"
234
+ aria-label={`Open ${label}`}
235
+ className="inline-block max-w-full rounded-2xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
236
+ data-file-view={image ? undefined : ""}
237
+ data-placement={placement}
238
+ data-file-variant="product"
239
+ >
240
+ {content}
241
+ </a>
242
+ ) : (
243
+ <span
244
+ className="inline-block max-w-full"
245
+ data-file-view=""
246
+ data-placement={placement}
247
+ data-file-variant="product"
248
+ >
249
+ {content}
250
+ </span>
251
+ );
252
+ }
253
+
135
254
  /**
136
255
  * Cloud OS 的默认文件视图。宿主可通过 `renderFile` 在同一 seam 替换它。
137
256
  */
138
257
  export function FileView({
139
258
  file,
140
259
  placement,
260
+ variant = "compact",
141
261
  status = "ready",
142
262
  progress = 0,
143
263
  onRemove,
@@ -238,6 +358,10 @@ export function FileView({
238
358
  );
239
359
  }
240
360
 
361
+ if (variant === "product") {
362
+ return <ProductFileView file={file} label={label} placement={placement} />;
363
+ }
364
+
241
365
  if (isImage) {
242
366
  return (
243
367
  <ImageFileView src={file.url!} label={label} placement={placement} />
@@ -251,7 +375,28 @@ export function FileView({
251
375
  data-placement={placement}
252
376
  >
253
377
  <FileIcon size={13} className="text-kumo-inactive" />
254
- {label}
378
+ <span className="min-w-0 truncate">{label}</span>
379
+ {file.url && (
380
+ <span className="ms-1 inline-flex items-center gap-1.5 border-s border-kumo-line ps-2">
381
+ <a
382
+ href={file.url}
383
+ target="_blank"
384
+ rel="noopener noreferrer"
385
+ aria-label={`Open ${label}`}
386
+ className="rounded text-kumo-default hover:text-kumo-default-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
387
+ >
388
+ Open
389
+ </a>
390
+ <a
391
+ href={file.url}
392
+ download={label}
393
+ aria-label={`Download ${label}`}
394
+ className="rounded text-kumo-default hover:text-kumo-default-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
395
+ >
396
+ Download
397
+ </a>
398
+ </span>
399
+ )}
255
400
  </span>
256
401
  );
257
402
  }
package/cloud-os/index.ts CHANGED
@@ -20,6 +20,7 @@ export type {
20
20
  FileViewPlacement,
21
21
  FileViewProps,
22
22
  FileViewStatus,
23
+ FileViewVariant,
23
24
  } from "./file-view";
24
25
  export {
25
26
  CloudOsConversationLoading,
@@ -64,8 +65,12 @@ export type { CloudOsUrlResolver } from "./chat/markdown-message";
64
65
  export {
65
66
  ThinkingTraceRow,
66
67
  ToolCallDetails,
68
+ ToolGroupDetails,
67
69
  ToolGroupRow,
70
+ WorkDescriptionRow,
68
71
  WorkIcon,
72
+ WorkTraceDisclosure,
73
+ formatWorkDuration,
69
74
  } from "./chat/tool-rows";
70
75
  export {
71
76
  ApprovalBlock,
@@ -90,6 +95,7 @@ export {
90
95
  formatClockTime,
91
96
  formatFullTimestamp,
92
97
  messageText,
98
+ partitionAssistantBlocks,
93
99
  requestedCapabilitiesOf,
94
100
  rhythmTopClass,
95
101
  } from "./chat/transcript-model";
@@ -51,6 +51,8 @@
51
51
  --color-kumo-success: oklch(72.3% 0.219 149.579);
52
52
  --color-kumo-success-tint: oklch(92% 0.08 150);
53
53
 
54
+ --color-operation-base: #ffffff;
55
+
54
56
  --color-kumo-tip-shadow: #14111014;
55
57
  --color-kumo-tip-stroke: transparent;
56
58
 
@@ -73,6 +75,10 @@
73
75
  --color-cos-status-live: oklch(72.3% 0.219 149.579);
74
76
  --color-cos-status-draft: oklch(68.1% 0.162 75.834);
75
77
  --color-cos-status-building: oklch(62.3% 0.214 259.815);
78
+ --text-color-cos-work-description: rgba(0, 0, 0, 0.5);
79
+ --text-color-cos-work-description-subtle: rgba(0, 0, 0, 0.7);
80
+ --text-color-cos-tool-group: rgba(0, 0, 0, 0.5);
81
+ --text-color-cos-output: #000000b3;
76
82
  }
77
83
 
78
84
  /* ── 作用域根:通用 token 只在 cloud-os 子树内生效 ─────────────────────────── */
@@ -152,6 +158,8 @@
152
158
  --color-kumo-success: oklch(52.7% 0.154 150.069);
153
159
  --color-kumo-success-tint: oklch(0.25 0.06 150.069);
154
160
 
161
+ --color-operation-base: var(--color-kumo-base);
162
+
155
163
  --color-kumo-tip-shadow: #00000066;
156
164
  --color-kumo-tip-stroke: oklch(0.34 0.022 285);
157
165
 
@@ -172,6 +180,10 @@
172
180
  --color-cos-status-live: oklch(79.2% 0.209 151.711);
173
181
  --color-cos-status-draft: oklch(82.8% 0.189 84.429);
174
182
  --color-cos-status-building: oklch(74% 0.16 250);
183
+ --text-color-cos-work-description: var(--text-color-kumo-subtle);
184
+ --text-color-cos-work-description-subtle: var(--text-color-kumo-subtle);
185
+ --text-color-cos-tool-group: var(--text-color-kumo-inactive);
186
+ --text-color-cos-output: var(--text-color-kumo-subtle);
175
187
 
176
188
  --color-selection-bg: color-mix(in srgb, var(--color-kumo-brand) 28%, transparent);
177
189
  --color-selection-text: oklch(0.97 0.006 285);