@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
@@ -0,0 +1,441 @@
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";
11
+ import {
12
+ CheckIcon,
13
+ FileArchiveIcon,
14
+ FileImageIcon,
15
+ FileTextIcon,
16
+ Loader2Icon,
17
+ RotateCcwIcon,
18
+ XIcon,
19
+ } from "lucide-react";
20
+ import {
21
+ useEffect,
22
+ useRef,
23
+ useState,
24
+ type ComponentType,
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>;
41
+
42
+ export type FileViewPlacement =
43
+ | "assistant-message"
44
+ | "composer"
45
+ | "user-message";
46
+
47
+ export type FileViewStatus = "error" | "ready" | "uploading";
48
+ export type FileViewVariant = "compact" | "product";
49
+
50
+ export interface FileViewFile {
51
+ url?: string;
52
+ filename?: string;
53
+ mediaType?: string;
54
+ size?: number;
55
+ }
56
+
57
+ export interface FileViewProps {
58
+ file: FileViewFile;
59
+ placement: FileViewPlacement;
60
+ variant?: FileViewVariant;
61
+ status?: FileViewStatus;
62
+ progress?: number;
63
+ onOpen?: () => void;
64
+ onRemove?: () => void;
65
+ onRetry?: () => void;
66
+ }
67
+
68
+ export type FileRenderer = ComponentType<FileViewProps>;
69
+
70
+ function fileMeta(file: FileViewFile): string {
71
+ if (file.size === undefined) return "Ready";
72
+ if (file.size < 1024) return `${file.size} B`;
73
+ if (file.size < 1024 * 1024) return `${Math.round(file.size / 1024)} KB`;
74
+ return `${(file.size / 1024 / 1024).toFixed(1)} MB`;
75
+ }
76
+
77
+ function attachmentIcon(file: FileViewFile) {
78
+ if (file.mediaType?.startsWith("image/")) return FileImageIcon;
79
+ if (/zip|archive|compressed/i.test(file.mediaType ?? "")) {
80
+ return FileArchiveIcon;
81
+ }
82
+ return FileTextIcon;
83
+ }
84
+
85
+ function ImageFileView({
86
+ src,
87
+ label,
88
+ placement,
89
+ frameClassName,
90
+ }: {
91
+ src: string;
92
+ label: string;
93
+ placement: Exclude<FileViewPlacement, "composer">;
94
+ frameClassName?: string;
95
+ }) {
96
+ const imageRef = useRef<HTMLImageElement>(null);
97
+ const [loadedSrc, setLoadedSrc] = useState<string>();
98
+ const [errorSrc, setErrorSrc] = useState<string>();
99
+ const loaded = loadedSrc === src;
100
+ const error = errorSrc === src;
101
+
102
+ useEffect(() => {
103
+ const image = imageRef.current;
104
+ if (!image?.complete) return;
105
+ if (image.naturalWidth > 0) setLoadedSrc(src);
106
+ else setErrorSrc(src);
107
+ }, [src]);
108
+
109
+ const assistant = placement === "assistant-message";
110
+ const previewClassName = frameClassName
111
+ ? `themed-thumbnail-shadow relative inline-block max-w-full overflow-hidden rounded-2xl border border-kumo-line align-bottom ${frameClassName}`
112
+ : assistant
113
+ ? "relative inline-block max-w-full overflow-hidden rounded-xl border border-kumo-line align-bottom"
114
+ : "themed-thumbnail-shadow relative inline-block max-w-64 overflow-hidden rounded-xl border border-kumo-line align-bottom";
115
+
116
+ return (
117
+ <span
118
+ className={`${previewClassName} ${loaded && !error ? "" : "min-h-24 min-w-36"}`}
119
+ data-file-view=""
120
+ data-placement={placement}
121
+ >
122
+ <img
123
+ ref={imageRef}
124
+ src={src}
125
+ alt={label}
126
+ className={`${
127
+ frameClassName
128
+ ? "block size-full object-contain"
129
+ : assistant
130
+ ? "block max-h-[28rem] max-w-full object-contain"
131
+ : "block max-h-52 max-w-64 object-cover"
132
+ } ${loaded && !error ? "" : "invisible"}`}
133
+ decoding="async"
134
+ loading="lazy"
135
+ onLoad={() => {
136
+ setLoadedSrc(src);
137
+ setErrorSrc(undefined);
138
+ }}
139
+ onError={() => setErrorSrc(src)}
140
+ />
141
+ {!loaded && !error && (
142
+ <span
143
+ className="absolute inset-0 flex min-h-24 min-w-36 items-center justify-center bg-kumo-elevated text-kumo-inactive"
144
+ data-image-state="loading"
145
+ role="status"
146
+ >
147
+ <FileImageIcon
148
+ className="size-6 animate-pulse motion-reduce:animate-none"
149
+ aria-hidden="true"
150
+ />
151
+ <span className="sr-only">Loading preview...</span>
152
+ </span>
153
+ )}
154
+ {error && (
155
+ <span
156
+ className="absolute inset-0 flex min-h-24 min-w-36 items-center justify-center bg-kumo-elevated text-kumo-danger"
157
+ data-image-state="error"
158
+ role="img"
159
+ aria-label="Unable to load preview"
160
+ >
161
+ <FileImageIcon className="size-6" aria-hidden="true" />
162
+ </span>
163
+ )}
164
+ </span>
165
+ );
166
+ }
167
+
168
+ function ProductFileView({
169
+ file,
170
+ label,
171
+ placement,
172
+ onOpen,
173
+ }: {
174
+ file: FileViewFile;
175
+ label: string;
176
+ placement: Exclude<FileViewPlacement, "composer">;
177
+ onOpen?: () => void;
178
+ }) {
179
+ const type = productTypeForMediaType(file.mediaType);
180
+ const { frame } = PRODUCT_PRESETS[type];
181
+ const image = type === "image" && file.url;
182
+ const video = type === "video" && file.url;
183
+ const ProductIcon = PRODUCT_ICONS[type];
184
+ const preview = image
185
+ ? (
186
+ <ImageFileView
187
+ src={file.url!}
188
+ label={label}
189
+ placement={placement}
190
+ frameClassName={frame}
191
+ />
192
+ )
193
+ : video
194
+ ? (
195
+ <video
196
+ src={file.url}
197
+ controls
198
+ preload="metadata"
199
+ playsInline
200
+ aria-label={label}
201
+ className={`themed-thumbnail-shadow block max-w-full rounded-2xl border border-kumo-line bg-kumo-elevated object-contain ${frame}`}
202
+ data-video-preview=""
203
+ />
204
+ )
205
+ : (
206
+ <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}`}>
207
+ <ProductIcon
208
+ size={24}
209
+ weight="light"
210
+ className="text-kumo-subtle"
211
+ aria-hidden="true"
212
+ data-product-icon={type}
213
+ />
214
+ {file.mediaType && (
215
+ <span className="absolute end-2.5 top-2.5 font-mono text-[10px] text-kumo-inactive">
216
+ {file.mediaType}
217
+ </span>
218
+ )}
219
+ </span>
220
+ );
221
+ const content = (
222
+ <span className="flex w-fit max-w-full flex-col gap-2.5" data-product-file-view={type}>
223
+ {preview}
224
+ <span className="flex min-w-0 max-w-full items-center gap-1 text-xs text-kumo-inactive">
225
+ <span className="capitalize text-kumo-subtle">{type}</span>
226
+ <span aria-hidden="true">·</span>
227
+ <span className="min-w-0 truncate">{label}</span>
228
+ </span>
229
+ </span>
230
+ );
231
+
232
+ return onOpen ? (
233
+ <button
234
+ type="button"
235
+ onClick={onOpen}
236
+ aria-label={`Open ${label} in Creation View`}
237
+ className="inline-block max-w-full cursor-pointer rounded-2xl text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
238
+ >
239
+ {content}
240
+ </button>
241
+ ) : file.url && !video ? (
242
+ <a
243
+ href={file.url}
244
+ target="_blank"
245
+ rel="noopener noreferrer"
246
+ aria-label={`Open ${label}`}
247
+ className="inline-block max-w-full rounded-2xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
248
+ data-file-view={image ? undefined : ""}
249
+ data-placement={placement}
250
+ data-file-variant="product"
251
+ >
252
+ {content}
253
+ </a>
254
+ ) : (
255
+ <span
256
+ className="inline-block max-w-full"
257
+ data-file-view=""
258
+ data-placement={placement}
259
+ data-file-variant="product"
260
+ >
261
+ {content}
262
+ </span>
263
+ );
264
+ }
265
+
266
+ /**
267
+ * Cloud OS 的默认文件视图。宿主可通过 `renderFile` 在同一 seam 替换它。
268
+ */
269
+ export function FileView({
270
+ file,
271
+ placement,
272
+ variant = "compact",
273
+ status = "ready",
274
+ progress = 0,
275
+ onOpen,
276
+ onRemove,
277
+ onRetry,
278
+ }: FileViewProps) {
279
+ const isImage = Boolean(file.url && file.mediaType?.startsWith("image/"));
280
+ const label = file.filename ?? "Attachment";
281
+
282
+ if (placement === "composer") {
283
+ const AttachmentIcon = attachmentIcon(file);
284
+ const meta =
285
+ status === "uploading"
286
+ ? "Uploading"
287
+ : status === "error"
288
+ ? "Upload failed"
289
+ : fileMeta(file);
290
+
291
+ return (
292
+ <div
293
+ className="relative flex items-center gap-2.5 overflow-hidden rounded-[14px] bg-kumo-elevated py-1.5 ps-1.5 pe-2.5"
294
+ data-file-view=""
295
+ data-placement={placement}
296
+ data-status={status}
297
+ data-slot="composer-attachment"
298
+ data-state={status === "ready" ? "done" : status}
299
+ >
300
+ <span className="flex size-8 shrink-0 items-center justify-center rounded-[10px] bg-kumo-control text-kumo-inactive themed-thumbnail-shadow">
301
+ {isImage ? (
302
+ <img
303
+ src={file.url}
304
+ alt=""
305
+ className="size-8 object-cover"
306
+ />
307
+ ) : (
308
+ <AttachmentIcon className="size-4" aria-hidden="true" />
309
+ )}
310
+ </span>
311
+ <span className="flex min-w-0 flex-col">
312
+ <span className="max-w-36 truncate text-xs font-medium text-kumo-default">
313
+ {label}
314
+ </span>
315
+ <span
316
+ className={
317
+ status === "error"
318
+ ? "text-[11px] text-kumo-danger"
319
+ : "text-[11px] text-kumo-inactive"
320
+ }
321
+ >
322
+ {meta}
323
+ </span>
324
+ </span>
325
+ <span className="ms-1 flex w-5 items-center justify-end">
326
+ {status === "uploading" ? (
327
+ <Loader2Icon
328
+ className="size-3.5 animate-spin text-kumo-inactive motion-reduce:animate-none"
329
+ aria-label="Uploading"
330
+ />
331
+ ) : status === "error" && onRetry ? (
332
+ <button
333
+ type="button"
334
+ aria-label={`Retry ${label}`}
335
+ onClick={onRetry}
336
+ className="grid size-5 place-items-center rounded-full text-kumo-danger hover:bg-kumo-tint focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
337
+ >
338
+ <RotateCcwIcon className="size-3" aria-hidden="true" />
339
+ </button>
340
+ ) : status === "ready" && onRemove ? (
341
+ <button
342
+ type="button"
343
+ aria-label={`Remove ${label}`}
344
+ onClick={onRemove}
345
+ className="grid size-5 place-items-center rounded-full text-kumo-inactive hover:bg-kumo-tint hover:text-kumo-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
346
+ >
347
+ <XIcon className="size-3" aria-hidden="true" />
348
+ </button>
349
+ ) : status === "ready" ? (
350
+ <CheckIcon className="size-3.5 text-kumo-success" aria-label="Ready" />
351
+ ) : null}
352
+ </span>
353
+ {status !== "ready" && onRemove && (
354
+ <button
355
+ type="button"
356
+ aria-label={`Remove ${label}`}
357
+ onClick={onRemove}
358
+ className="grid size-5 place-items-center rounded-full text-kumo-inactive hover:bg-kumo-tint hover:text-kumo-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
359
+ >
360
+ <XIcon className="size-3" aria-hidden="true" />
361
+ </button>
362
+ )}
363
+ {status === "uploading" && (
364
+ <span
365
+ aria-hidden="true"
366
+ className="absolute inset-x-0 bottom-0 h-0.5 bg-kumo-brand/70 transition-[width] duration-300"
367
+ style={{ width: `${Math.min(100, Math.max(0, progress * 100))}%` }}
368
+ />
369
+ )}
370
+ </div>
371
+ );
372
+ }
373
+
374
+ if (variant === "product") {
375
+ return (
376
+ <ProductFileView
377
+ file={file}
378
+ label={label}
379
+ placement={placement}
380
+ onOpen={onOpen}
381
+ />
382
+ );
383
+ }
384
+
385
+ if (isImage) {
386
+ const image = (
387
+ <ImageFileView src={file.url!} label={label} placement={placement} />
388
+ );
389
+ return onOpen ? (
390
+ <button
391
+ type="button"
392
+ onClick={onOpen}
393
+ aria-label={`Open ${label} in Creation View`}
394
+ className="inline-block max-w-full cursor-pointer rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
395
+ >
396
+ {image}
397
+ </button>
398
+ ) : image;
399
+ }
400
+
401
+ return (
402
+ <span
403
+ className="inline-flex items-center gap-1.5 rounded-lg border border-kumo-line bg-kumo-elevated px-2.5 py-1.5 text-[12px] leading-4 text-kumo-subtle"
404
+ data-file-view=""
405
+ data-placement={placement}
406
+ >
407
+ <FileIcon size={13} className="text-kumo-inactive" />
408
+ <span className="min-w-0 truncate">{label}</span>
409
+ {file.url && (
410
+ <span className="ms-1 inline-flex items-center gap-1.5 border-s border-kumo-line ps-2">
411
+ <a
412
+ href={file.url}
413
+ target="_blank"
414
+ rel="noopener noreferrer"
415
+ aria-label={`Open ${label}`}
416
+ className="rounded text-kumo-default hover:text-kumo-default-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
417
+ >
418
+ Open
419
+ </a>
420
+ <a
421
+ href={file.url}
422
+ download={label}
423
+ aria-label={`Download ${label}`}
424
+ className="rounded text-kumo-default hover:text-kumo-default-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
425
+ >
426
+ Download
427
+ </a>
428
+ </span>
429
+ )}
430
+ </span>
431
+ );
432
+ }
433
+
434
+ /** 宿主没有接管时回退到包内默认视图。 */
435
+ export function renderFileView(
436
+ renderFile: FileRenderer | undefined,
437
+ props: FileViewProps,
438
+ ) {
439
+ const Renderer = renderFile ?? FileView;
440
+ return <Renderer {...props} />;
441
+ }
package/cloud-os/index.ts CHANGED
@@ -11,7 +11,21 @@
11
11
  */
12
12
 
13
13
  export { CloudOsRoot } from "./layout/cloud-os-root";
14
- export { CloudOsWorkspaceSplit } from "./layout/cloud-os-workspace-split";
14
+ export { CapabilityChip } from "./capability-chip";
15
+ export type { CapabilityChipProps } from "./capability-chip";
16
+ export { FileView } from "./file-view";
17
+ export type {
18
+ FileRenderer,
19
+ FileViewFile,
20
+ FileViewPlacement,
21
+ FileViewProps,
22
+ FileViewStatus,
23
+ FileViewVariant,
24
+ } from "./file-view";
25
+ export {
26
+ CloudOsConversationLoading,
27
+ CloudOsWorkspaceSplit,
28
+ } from "./layout/cloud-os-workspace-split";
15
29
  export type { CloudOsWorkspaceSplitProps } from "./layout/cloud-os-workspace-split";
16
30
 
17
31
  export { CloudOsChatMessages } from "./chat/cloud-os-chat-messages";
@@ -23,10 +37,15 @@ export type {
23
37
  export { CloudOsChatInput } from "./composer/cloud-os-chat-input";
24
38
  export type {
25
39
  CloudOsAttachmentView,
40
+ CloudOsCapabilityView,
26
41
  CloudOsChatInputProps,
27
42
  CloudOsComposerCommand,
28
- CloudOsModelOption,
29
43
  } from "./composer/cloud-os-chat-input";
44
+ export { CloudOsModelSelect } from "./composer/cloud-os-model-select";
45
+ export type {
46
+ CloudOsModelOption,
47
+ CloudOsModelSelectProps,
48
+ } from "./composer/cloud-os-model-select";
30
49
 
31
50
  export {
32
51
  CloudOsWorkspacePanel,
@@ -42,12 +61,19 @@ export type {
42
61
  } from "./workspace/cloud-os-workspace-panel";
43
62
 
44
63
  export { MarkdownMessage } from "./chat/markdown-message";
45
- export type { CloudOsUrlResolver } from "./chat/markdown-message";
64
+ export type {
65
+ CloudOsArtifactResolver,
66
+ CloudOsUrlResolver,
67
+ } from "./chat/markdown-message";
46
68
  export {
47
69
  ThinkingTraceRow,
48
70
  ToolCallDetails,
71
+ ToolGroupDetails,
49
72
  ToolGroupRow,
73
+ WorkDescriptionRow,
50
74
  WorkIcon,
75
+ WorkTraceDisclosure,
76
+ formatWorkDuration,
51
77
  } from "./chat/tool-rows";
52
78
  export {
53
79
  ApprovalBlock,
@@ -55,7 +81,9 @@ export {
55
81
  ErrorBlock,
56
82
  ParallelBlock,
57
83
  PlanBlock,
84
+ PermissionGrant,
58
85
  ScheduleBlock,
86
+ ScheduleConfirmation,
59
87
  SubAgentsBlock,
60
88
  SuggestionsBlock,
61
89
  } from "./chat/rich-blocks";
@@ -70,6 +98,8 @@ export {
70
98
  formatClockTime,
71
99
  formatFullTimestamp,
72
100
  messageText,
101
+ partitionAssistantBlocks,
102
+ requestedCapabilitiesOf,
73
103
  rhythmTopClass,
74
104
  } from "./chat/transcript-model";
75
105
  export type {
@@ -77,6 +107,7 @@ export type {
77
107
  CloudOsActivity,
78
108
  CloudOsAttachment,
79
109
  CloudOsEntry,
110
+ CloudOsRequestedCapability,
80
111
  ParallelTool,
81
112
  PlanStep,
82
113
  SubAgentView,
@@ -87,6 +118,7 @@ export {
87
118
  canonicalToolKind,
88
119
  getToolCallSummary,
89
120
  getToolIcon,
121
+ humanizeToolName,
90
122
  toCloudOsToolCall,
91
123
  toolNameOfPart,
92
124
  } from "./chat/tool-presentation";
@@ -6,11 +6,12 @@ import {
6
6
  type PointerEvent as ReactPointerEvent,
7
7
  type ReactNode,
8
8
  } from "react";
9
+ import { motion, useReducedMotion } from "motion/react";
9
10
 
10
11
  /**
11
12
  * 左聊天 / 右工作区的双栏骨架。从 cloudflare-os-main `GadgetEditor.tsx` 的 BODY
12
13
  * 段拷来:同一条 1px 的 kumo-line 分隔即拖拽把手、同一套 pointer capture 拖拽
13
- * (拖过 iframe 也不丢)、同一个 200ms 的收起/展开过渡、同一条顶部推进条。
14
+ * (拖过 iframe 也不丢)、同一个 200ms 的收起/展开过渡。
14
15
  *
15
16
  * 适配点:原文件把左右两侧的内容写死成 ChatInterface / GadgetUI,这里换成
16
17
  * children 插槽;chatWidth 的 localStorage key 换成 cloud-os 自己的。
@@ -24,6 +25,56 @@ const WORKSPACE_TRANSITION_MS = 200;
24
25
 
25
26
  const isBrowser = typeof window !== "undefined";
26
27
 
28
+ const loadingAssets = {
29
+ spark: new URL("../assets/loading-spark.svg", import.meta.url).href,
30
+ corner: new URL("../assets/loading-corner.svg", import.meta.url).href,
31
+ mark: new URL("../assets/loading-mark.svg", import.meta.url).href,
32
+ };
33
+
34
+ const loadingSpring = (t: number) =>
35
+ 1 - Math.exp(-t * 7.6657) * (Math.cos(t * 6.7605) + 1.1339 * Math.sin(t * 6.7605));
36
+
37
+ export function CloudOsConversationLoading() {
38
+ const reducedMotion = useReducedMotion();
39
+ const repeat = reducedMotion ? 0 : Infinity;
40
+
41
+ return (
42
+ <div className="absolute inset-0 z-30 grid place-items-center bg-kumo-base/90">
43
+ <div role="status" aria-label="Loading conversation..." className="flex flex-col items-center gap-4">
44
+ <div className="relative h-[49px] w-[49px] overflow-hidden" aria-hidden="true">
45
+ <motion.img
46
+ src={loadingAssets.spark}
47
+ alt=""
48
+ className="absolute bottom-[18.5%] right-[61.46%] h-[20.04%] w-[20.04%] will-change-transform"
49
+ initial={reducedMotion ? false : { opacity: 0, rotate: 200, scaleX: 0.5, scaleY: 0.5, x: -140, y: 120 }}
50
+ animate={reducedMotion ? { opacity: 1, rotate: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 } : { opacity: [0, 0, 1, 1, 0, 0], rotate: [200, 200, 10, 0, 0, 200, 200], scaleX: [0.5, 0.5, 1.3, 0.8, 1, 1], scaleY: [0.5, 0.5, 0.75, 1.2, 1, 1], x: [-140, -140, 2, 0, 0, -140, -140], y: [120, 120, -2, 0, 0, 120, 120] }}
51
+ transition={{ opacity: { duration: 1.68, times: [0, 0.2083, 0.25, 0.9998, 0.9999, 1], ease: ["linear", "linear", "linear", "easeIn", "linear"], repeat }, rotate: { duration: 1.68, times: [0, 0.2083, 0.3869, 0.506, 0.9998, 0.9999, 1], ease: ["linear", [0, 0, 0.15, 1], loadingSpring, "linear", "easeIn", "linear"], repeat }, scaleX: { duration: 1.68, times: [0, 0.2083, 0.3274, 0.3869, 0.506, 1], ease: ["linear", [0, 0, 0.15, 1], "easeOut", loadingSpring, "linear"], repeat }, scaleY: { duration: 1.68, times: [0, 0.2083, 0.3274, 0.3869, 0.506, 1], ease: ["linear", [0, 0, 0.15, 1], "easeOut", loadingSpring, "linear"], repeat }, x: { duration: 1.68, times: [0, 0.2083, 0.3869, 0.506, 0.9998, 0.9999, 1], ease: ["linear", [0, 0, 0.15, 1], loadingSpring, "linear", "easeIn", "linear"], repeat }, y: { duration: 1.68, times: [0, 0.2083, 0.3869, 0.506, 0.9998, 0.9999, 1], ease: ["linear", [0, 0, 0.15, 1], loadingSpring, "linear", "easeIn", "linear"], repeat } }}
52
+ />
53
+ <motion.img
54
+ src={loadingAssets.corner}
55
+ alt=""
56
+ className="absolute bottom-[11.65%] right-[43.79%] h-[44.74%] w-[44.67%] will-change-transform"
57
+ initial={reducedMotion ? false : { opacity: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }}
58
+ animate={reducedMotion ? { opacity: 1, scaleX: 1, scaleY: 1, x: 0, y: 0 } : { opacity: [0, 1, 1, 0, 0], scaleX: [1, 1, 1.06, 1, 1], scaleY: [1, 1, 0.95, 1, 1], x: [0, 0, 4, 0, 0], y: [0, 0, -2, 0, 0] }}
59
+ transition={{ opacity: { duration: 1.68, times: [0, 0.1786, 0.9998, 0.9999, 1], ease: [[0, 0, 0.3, 1], "linear", "easeIn", "linear"], repeat }, scaleX: { duration: 1.68, times: [0, 0.3988, 0.4345, 0.6548, 1], ease: ["linear", [0.2, 0.9, 0.3, 1], loadingSpring, "linear"], repeat }, scaleY: { duration: 1.68, times: [0, 0.3988, 0.4345, 0.6548, 1], ease: ["linear", [0.2, 0.9, 0.3, 1], loadingSpring, "linear"], repeat }, x: { duration: 1.68, times: [0, 0.3988, 0.4345, 0.6845, 1], ease: ["linear", [0.2, 0.9, 0.3, 1], loadingSpring, "linear"], repeat }, y: { duration: 1.68, times: [0, 0.3988, 0.4345, 0.6845, 1], ease: ["linear", [0.2, 0.9, 0.3, 1], loadingSpring, "linear"], repeat } }}
60
+ />
61
+ <motion.img
62
+ src={loadingAssets.mark}
63
+ alt=""
64
+ className="absolute bottom-[19.14%] right-[11.53%] h-[69.22%] w-[69.33%] will-change-transform"
65
+ initial={reducedMotion ? false : { opacity: 0, rotate: 0, scaleX: 0.85, scaleY: 0.85, x: 0, y: 0 }}
66
+ animate={reducedMotion ? { opacity: 1, rotate: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 } : { opacity: [0, 1, 1, 0, 0], rotate: [0, 0, -3, 0, 0], scaleX: [0.85, 1, 1, 1.1, 0.95, 1, 1], scaleY: [0.85, 1, 1, 0.92, 1.05, 1, 1], x: [0, 0, 6, 0, 0], y: [0, 0, -4, 0, 0] }}
67
+ transition={{ opacity: { duration: 1.68, times: [0, 0.2083, 0.9998, 0.9999, 1], ease: [[0, 0, 0.3, 1], "linear", "easeIn", "linear"], repeat }, rotate: { duration: 1.68, times: [0, 0.3869, 0.4286, 0.6548, 1], ease: ["linear", [0.2, 0.9, 0.3, 1], loadingSpring, "linear"], repeat }, scaleX: { duration: 1.68, times: [0, 0.2083, 0.3869, 0.4167, 0.4643, 0.6548, 1], ease: [[0, 0, 0.3, 1], "linear", [0.2, 0.9, 0.3, 1], "easeOut", loadingSpring, "linear"], repeat }, scaleY: { duration: 1.68, times: [0, 0.2083, 0.3869, 0.4167, 0.4643, 0.6548, 1], ease: [[0, 0, 0.3, 1], "linear", [0.2, 0.9, 0.3, 1], "easeOut", loadingSpring, "linear"], repeat }, x: { duration: 1.68, times: [0, 0.3869, 0.4167, 0.6548, 1], ease: ["linear", [0.2, 0.9, 0.3, 1], loadingSpring, "linear"], repeat }, y: { duration: 1.68, times: [0, 0.3869, 0.4167, 0.6548, 1], ease: ["linear", [0.2, 0.9, 0.3, 1], loadingSpring, "linear"], repeat } }}
68
+ />
69
+ </div>
70
+ <p className="m-0 text-center text-[14px] font-normal leading-[1.4] text-kumo-default">
71
+ Loading conversation...
72
+ </p>
73
+ </div>
74
+ </div>
75
+ );
76
+ }
77
+
27
78
  function clampChatWidth(width: number, containerWidth?: number) {
28
79
  if (!isBrowser) {
29
80
  return Math.max(MIN_CHAT_WIDTH, Math.min(DEFAULT_CHAT_WIDTH, width));
@@ -59,20 +110,20 @@ export interface CloudOsWorkspaceSplitProps {
59
110
  workspace: ReactNode;
60
111
  /** 关掉右栏时聊天占满整宽。 */
61
112
  workspaceOpen: boolean;
62
- /** 顶部那条推进条:回合进行中显示。 */
63
- isAgentActive?: boolean;
64
113
  /** 右栏收起时依然保留的窄边栏宽度(原文件的 Outputs rail)。 */
65
114
  railWidth?: number;
66
115
  rail?: ReactNode;
116
+ /** 覆盖整个聊天 + 工作区主体;页面级导航应放在分栏外。 */
117
+ isLoading?: boolean;
67
118
  }
68
119
 
69
120
  export function CloudOsWorkspaceSplit({
70
121
  chat,
71
122
  workspace,
72
123
  workspaceOpen,
73
- isAgentActive = false,
74
124
  railWidth = 0,
75
125
  rail,
126
+ isLoading = false,
76
127
  }: CloudOsWorkspaceSplitProps) {
77
128
  const containerRef = useRef<HTMLDivElement>(null);
78
129
  const resizeSessionRef = useRef<ResizeSession | null>(null);
@@ -187,18 +238,8 @@ export function CloudOsWorkspaceSplit({
187
238
  <div
188
239
  ref={containerRef}
189
240
  className="relative flex h-full min-h-0 flex-1 overflow-hidden bg-kumo-base"
241
+ aria-busy={isLoading}
190
242
  >
191
- {isAgentActive && (
192
- <div
193
- className="absolute left-0 z-10 h-0"
194
- style={{ top: 0, right: railWidth }}
195
- >
196
- <div className="absolute left-0 right-0 h-0.5 overflow-hidden bg-kumo-fill">
197
- <div className="cos-progress-sweep absolute inset-y-0 w-1/3 bg-kumo-brand" />
198
- </div>
199
- </div>
200
- )}
201
-
202
243
  {/* ── 左:聊天 ──────────────────────────────────────────────────────── */}
203
244
  <div
204
245
  className={`flex h-full min-h-0 flex-shrink-0 flex-col ${transitionClass} ${
@@ -237,6 +278,8 @@ export function CloudOsWorkspaceSplit({
237
278
  </div>
238
279
 
239
280
  {rail}
281
+
282
+ {isLoading && <CloudOsConversationLoading />}
240
283
  </div>
241
284
  );
242
285
  }
@@ -11,7 +11,7 @@ const buttonBaseClassName =
11
11
 
12
12
  const buttonToneClassNames = {
13
13
  primary:
14
- "!h-9 bg-kumo-contrast px-3 text-kumo-inverse enabled:hover:bg-kumo-strong disabled:opacity-50",
14
+ "!h-9 bg-gradient-to-br from-[#FFD077] to-[#FF9938] px-3 text-white enabled:hover:opacity-90 disabled:opacity-50",
15
15
  secondary:
16
16
  "!h-8 border border-kumo-line bg-kumo-base px-3 text-kumo-default enabled:hover:bg-kumo-elevated disabled:opacity-40",
17
17
  danger:
@@ -59,7 +59,7 @@ export function WorkshopIconButton({
59
59
  }: WorkshopIconButtonProps) {
60
60
  const toneClassName =
61
61
  tone === "primary"
62
- ? "bg-kumo-contrast text-kumo-inverse enabled:hover:bg-kumo-strong enabled:hover:text-kumo-inverse"
62
+ ? "bg-[var(--primary,var(--color-kumo-brand))] text-[var(--primary-foreground,var(--text-color-kumo-inverse))] enabled:hover:bg-[var(--primary-hover,var(--color-kumo-brand-hover))]"
63
63
  : danger
64
64
  ? "text-kumo-subtle enabled:hover:bg-kumo-danger-tint enabled:hover:text-kumo-danger"
65
65
  : "text-kumo-subtle enabled:hover:bg-kumo-tint enabled:hover:text-kumo-default";