@springbrand/message-panel 0.1.3-alpha.2 → 0.1.3-alpha.22

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 (41) 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/capability-chip.tsx +35 -0
  8. package/cloud-os/chat/activity-indicator.tsx +29 -0
  9. package/cloud-os/chat/cloud-os-chat-messages.tsx +130 -70
  10. package/cloud-os/chat/markdown-message.tsx +85 -40
  11. package/cloud-os/chat/rich-blocks.tsx +494 -190
  12. package/cloud-os/chat/tool-presentation.ts +67 -6
  13. package/cloud-os/chat/tool-rows.tsx +87 -43
  14. package/cloud-os/chat/transcript-model.ts +215 -23
  15. package/cloud-os/composer/cloud-os-chat-input.tsx +242 -127
  16. package/cloud-os/composer/cloud-os-model-select.tsx +105 -0
  17. package/cloud-os/file-view.tsx +266 -0
  18. package/cloud-os/index.ts +29 -2
  19. package/cloud-os/layout/cloud-os-workspace-split.tsx +107 -24
  20. package/cloud-os/primitives/workshop-controls.tsx +2 -2
  21. package/cloud-os/styles/cloud-os.css +93 -19
  22. package/demo/camel-chat-showcase.tsx +21 -13
  23. package/demo/chat-scenarios.ts +100 -40
  24. package/demo/cloud-os-chat-showcase.tsx +51 -17
  25. package/demo/fixtures.ts +4 -5
  26. package/demo/message-panel-gallery.tsx +16 -2
  27. package/package.json +3 -4
  28. package/src/camel/camel-chat-messages.tsx +82 -77
  29. package/src/camel/camel-prompt-input.tsx +2 -1
  30. package/src/camel/camel-tool-presentation.tsx +19 -15
  31. package/src/camel/camel-turn.ts +1 -17
  32. package/src/chat-summary-panel.tsx +1 -1
  33. package/src/composer/chat-composer.tsx +2 -1
  34. package/src/composer/composer-trigger-popover.tsx +1 -1
  35. package/src/composer/composer.tsx +2 -1
  36. package/src/composer/index.ts +1 -0
  37. package/src/composer/key-rules.ts +12 -0
  38. package/src/contracts.ts +0 -2
  39. package/src/message-panel.tsx +0 -23
  40. package/src/parts/index.tsx +102 -63
  41. package/src/styles/index.css +4 -1
@@ -0,0 +1,266 @@
1
+ import { File as FileIcon } from "@phosphor-icons/react";
2
+ import {
3
+ CheckIcon,
4
+ FileArchiveIcon,
5
+ FileImageIcon,
6
+ FileTextIcon,
7
+ Loader2Icon,
8
+ RotateCcwIcon,
9
+ XIcon,
10
+ } from "lucide-react";
11
+ import {
12
+ useEffect,
13
+ useRef,
14
+ useState,
15
+ type ComponentType,
16
+ } from "react";
17
+
18
+ export type FileViewPlacement =
19
+ | "assistant-message"
20
+ | "composer"
21
+ | "user-message";
22
+
23
+ export type FileViewStatus = "error" | "ready" | "uploading";
24
+
25
+ export interface FileViewFile {
26
+ url?: string;
27
+ filename?: string;
28
+ mediaType?: string;
29
+ size?: number;
30
+ }
31
+
32
+ export interface FileViewProps {
33
+ file: FileViewFile;
34
+ placement: FileViewPlacement;
35
+ status?: FileViewStatus;
36
+ progress?: number;
37
+ onRemove?: () => void;
38
+ onRetry?: () => void;
39
+ }
40
+
41
+ export type FileRenderer = ComponentType<FileViewProps>;
42
+
43
+ function fileMeta(file: FileViewFile): string {
44
+ if (file.size === undefined) return "Ready";
45
+ if (file.size < 1024) return `${file.size} B`;
46
+ if (file.size < 1024 * 1024) return `${Math.round(file.size / 1024)} KB`;
47
+ return `${(file.size / 1024 / 1024).toFixed(1)} MB`;
48
+ }
49
+
50
+ function attachmentIcon(file: FileViewFile) {
51
+ if (file.mediaType?.startsWith("image/")) return FileImageIcon;
52
+ if (/zip|archive|compressed/i.test(file.mediaType ?? "")) {
53
+ return FileArchiveIcon;
54
+ }
55
+ return FileTextIcon;
56
+ }
57
+
58
+ function ImageFileView({
59
+ src,
60
+ label,
61
+ placement,
62
+ }: {
63
+ src: string;
64
+ label: string;
65
+ placement: Exclude<FileViewPlacement, "composer">;
66
+ }) {
67
+ const imageRef = useRef<HTMLImageElement>(null);
68
+ const [loadedSrc, setLoadedSrc] = useState<string>();
69
+ const [errorSrc, setErrorSrc] = useState<string>();
70
+ const loaded = loadedSrc === src;
71
+ const error = errorSrc === src;
72
+
73
+ useEffect(() => {
74
+ const image = imageRef.current;
75
+ if (!image?.complete) return;
76
+ if (image.naturalWidth > 0) setLoadedSrc(src);
77
+ else setErrorSrc(src);
78
+ }, [src]);
79
+
80
+ 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";
84
+
85
+ return (
86
+ <span
87
+ className={`${previewClassName} ${loaded && !error ? "" : "min-h-24 min-w-36"}`}
88
+ data-file-view=""
89
+ data-placement={placement}
90
+ >
91
+ <img
92
+ ref={imageRef}
93
+ src={src}
94
+ alt={label}
95
+ className={`${
96
+ assistant
97
+ ? "block max-h-[28rem] max-w-full object-contain"
98
+ : "block max-h-52 max-w-64 object-cover"
99
+ } ${loaded && !error ? "" : "invisible"}`}
100
+ decoding="async"
101
+ loading="lazy"
102
+ onLoad={() => {
103
+ setLoadedSrc(src);
104
+ setErrorSrc(undefined);
105
+ }}
106
+ onError={() => setErrorSrc(src)}
107
+ />
108
+ {!loaded && !error && (
109
+ <span
110
+ className="absolute inset-0 flex min-h-24 min-w-36 items-center justify-center bg-kumo-elevated text-kumo-inactive"
111
+ data-image-state="loading"
112
+ role="status"
113
+ >
114
+ <FileImageIcon
115
+ className="size-6 animate-pulse motion-reduce:animate-none"
116
+ aria-hidden="true"
117
+ />
118
+ <span className="sr-only">Loading {label}</span>
119
+ </span>
120
+ )}
121
+ {error && (
122
+ <span
123
+ className="absolute inset-0 flex min-h-24 min-w-36 items-center justify-center bg-kumo-elevated text-kumo-danger"
124
+ data-image-state="error"
125
+ role="img"
126
+ aria-label={`Unable to load ${label}`}
127
+ >
128
+ <FileImageIcon className="size-6" aria-hidden="true" />
129
+ </span>
130
+ )}
131
+ </span>
132
+ );
133
+ }
134
+
135
+ /**
136
+ * Cloud OS 的默认文件视图。宿主可通过 `renderFile` 在同一 seam 替换它。
137
+ */
138
+ export function FileView({
139
+ file,
140
+ placement,
141
+ status = "ready",
142
+ progress = 0,
143
+ onRemove,
144
+ onRetry,
145
+ }: FileViewProps) {
146
+ const isImage = Boolean(file.url && file.mediaType?.startsWith("image/"));
147
+ const label = file.filename ?? "Attachment";
148
+
149
+ if (placement === "composer") {
150
+ const AttachmentIcon = attachmentIcon(file);
151
+ const meta =
152
+ status === "uploading"
153
+ ? "Uploading"
154
+ : status === "error"
155
+ ? "Upload failed"
156
+ : fileMeta(file);
157
+
158
+ return (
159
+ <div
160
+ className="relative flex items-center gap-2.5 overflow-hidden rounded-[14px] bg-kumo-elevated py-1.5 ps-1.5 pe-2.5"
161
+ data-file-view=""
162
+ data-placement={placement}
163
+ data-status={status}
164
+ data-slot="composer-attachment"
165
+ data-state={status === "ready" ? "done" : status}
166
+ >
167
+ <span className="flex size-8 shrink-0 items-center justify-center rounded-[10px] bg-kumo-control text-kumo-inactive themed-thumbnail-shadow">
168
+ {isImage ? (
169
+ <img
170
+ src={file.url}
171
+ alt=""
172
+ className="size-8 object-cover"
173
+ />
174
+ ) : (
175
+ <AttachmentIcon className="size-4" aria-hidden="true" />
176
+ )}
177
+ </span>
178
+ <span className="flex min-w-0 flex-col">
179
+ <span className="max-w-36 truncate text-xs font-medium text-kumo-default">
180
+ {label}
181
+ </span>
182
+ <span
183
+ className={
184
+ status === "error"
185
+ ? "text-[11px] text-kumo-danger"
186
+ : "text-[11px] text-kumo-inactive"
187
+ }
188
+ >
189
+ {meta}
190
+ </span>
191
+ </span>
192
+ <span className="ms-1 flex w-5 items-center justify-end">
193
+ {status === "uploading" ? (
194
+ <Loader2Icon
195
+ className="size-3.5 animate-spin text-kumo-inactive motion-reduce:animate-none"
196
+ aria-label="Uploading"
197
+ />
198
+ ) : status === "error" && onRetry ? (
199
+ <button
200
+ type="button"
201
+ aria-label={`Retry ${label}`}
202
+ onClick={onRetry}
203
+ 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"
204
+ >
205
+ <RotateCcwIcon className="size-3" aria-hidden="true" />
206
+ </button>
207
+ ) : status === "ready" && onRemove ? (
208
+ <button
209
+ type="button"
210
+ aria-label={`Remove ${label}`}
211
+ onClick={onRemove}
212
+ 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"
213
+ >
214
+ <XIcon className="size-3" aria-hidden="true" />
215
+ </button>
216
+ ) : status === "ready" ? (
217
+ <CheckIcon className="size-3.5 text-kumo-success" aria-label="Ready" />
218
+ ) : null}
219
+ </span>
220
+ {status !== "ready" && onRemove && (
221
+ <button
222
+ type="button"
223
+ aria-label={`Remove ${label}`}
224
+ onClick={onRemove}
225
+ 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"
226
+ >
227
+ <XIcon className="size-3" aria-hidden="true" />
228
+ </button>
229
+ )}
230
+ {status === "uploading" && (
231
+ <span
232
+ aria-hidden="true"
233
+ className="absolute inset-x-0 bottom-0 h-0.5 bg-kumo-brand/70 transition-[width] duration-300"
234
+ style={{ width: `${Math.min(100, Math.max(0, progress * 100))}%` }}
235
+ />
236
+ )}
237
+ </div>
238
+ );
239
+ }
240
+
241
+ if (isImage) {
242
+ return (
243
+ <ImageFileView src={file.url!} label={label} placement={placement} />
244
+ );
245
+ }
246
+
247
+ return (
248
+ <span
249
+ 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"
250
+ data-file-view=""
251
+ data-placement={placement}
252
+ >
253
+ <FileIcon size={13} className="text-kumo-inactive" />
254
+ {label}
255
+ </span>
256
+ );
257
+ }
258
+
259
+ /** 宿主没有接管时回退到包内默认视图。 */
260
+ export function renderFileView(
261
+ renderFile: FileRenderer | undefined,
262
+ props: FileViewProps,
263
+ ) {
264
+ const Renderer = renderFile ?? FileView;
265
+ return <Renderer {...props} />;
266
+ }
package/cloud-os/index.ts CHANGED
@@ -11,7 +11,20 @@
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
+ } from "./file-view";
24
+ export {
25
+ CloudOsConversationLoading,
26
+ CloudOsWorkspaceSplit,
27
+ } from "./layout/cloud-os-workspace-split";
15
28
  export type { CloudOsWorkspaceSplitProps } from "./layout/cloud-os-workspace-split";
16
29
 
17
30
  export { CloudOsChatMessages } from "./chat/cloud-os-chat-messages";
@@ -23,10 +36,15 @@ export type {
23
36
  export { CloudOsChatInput } from "./composer/cloud-os-chat-input";
24
37
  export type {
25
38
  CloudOsAttachmentView,
39
+ CloudOsCapabilityView,
26
40
  CloudOsChatInputProps,
27
41
  CloudOsComposerCommand,
28
- CloudOsModelOption,
29
42
  } from "./composer/cloud-os-chat-input";
43
+ export { CloudOsModelSelect } from "./composer/cloud-os-model-select";
44
+ export type {
45
+ CloudOsModelOption,
46
+ CloudOsModelSelectProps,
47
+ } from "./composer/cloud-os-model-select";
30
48
 
31
49
  export {
32
50
  CloudOsWorkspacePanel,
@@ -55,24 +73,32 @@ export {
55
73
  ErrorBlock,
56
74
  ParallelBlock,
57
75
  PlanBlock,
76
+ PermissionGrant,
58
77
  ScheduleBlock,
78
+ ScheduleConfirmation,
59
79
  SubAgentsBlock,
60
80
  SuggestionsBlock,
61
81
  } from "./chat/rich-blocks";
62
82
  export type { ApprovalDecision } from "./chat/rich-blocks";
63
83
 
84
+ export { CloudOsActivityIndicator } from "./chat/activity-indicator";
85
+
64
86
  export {
65
87
  buildCloudOsEntries,
66
88
  cloudOsMetadata,
89
+ deriveTurnActivity,
67
90
  formatClockTime,
68
91
  formatFullTimestamp,
69
92
  messageText,
93
+ requestedCapabilitiesOf,
70
94
  rhythmTopClass,
71
95
  } from "./chat/transcript-model";
72
96
  export type {
73
97
  AssistantBlock,
98
+ CloudOsActivity,
74
99
  CloudOsAttachment,
75
100
  CloudOsEntry,
101
+ CloudOsRequestedCapability,
76
102
  ParallelTool,
77
103
  PlanStep,
78
104
  SubAgentView,
@@ -83,6 +109,7 @@ export {
83
109
  canonicalToolKind,
84
110
  getToolCallSummary,
85
111
  getToolIcon,
112
+ humanizeToolName,
86
113
  toCloudOsToolCall,
87
114
  toolNameOfPart,
88
115
  } 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,11 +25,62 @@ const WORKSPACE_TRANSITION_MS = 200;
24
25
 
25
26
  const isBrowser = typeof window !== "undefined";
26
27
 
27
- function clampChatWidth(width: number) {
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
+
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));
30
81
  }
31
- const max = Math.max(MIN_CHAT_WIDTH, window.innerWidth - MIN_WORKSPACE_WIDTH);
82
+ const available = containerWidth ?? window.innerWidth;
83
+ const max = Math.max(MIN_CHAT_WIDTH, available - MIN_WORKSPACE_WIDTH);
32
84
  return Math.max(MIN_CHAT_WIDTH, Math.min(max, width));
33
85
  }
34
86
 
@@ -48,43 +100,65 @@ function getInitialChatWidth() {
48
100
  return clampChatWidth(Number.isFinite(parsed) ? parsed : fallback);
49
101
  }
50
102
 
103
+ type ResizeSession = {
104
+ startX: number;
105
+ startWidth: number;
106
+ };
107
+
51
108
  export interface CloudOsWorkspaceSplitProps {
52
109
  chat: ReactNode;
53
110
  workspace: ReactNode;
54
111
  /** 关掉右栏时聊天占满整宽。 */
55
112
  workspaceOpen: boolean;
56
- /** 顶部那条推进条:回合进行中显示。 */
57
- isAgentActive?: boolean;
58
113
  /** 右栏收起时依然保留的窄边栏宽度(原文件的 Outputs rail)。 */
59
114
  railWidth?: number;
60
115
  rail?: ReactNode;
116
+ /** 覆盖整个聊天 + 工作区主体;页面级导航应放在分栏外。 */
117
+ isLoading?: boolean;
61
118
  }
62
119
 
63
120
  export function CloudOsWorkspaceSplit({
64
121
  chat,
65
122
  workspace,
66
123
  workspaceOpen,
67
- isAgentActive = false,
68
124
  railWidth = 0,
69
125
  rail,
126
+ isLoading = false,
70
127
  }: CloudOsWorkspaceSplitProps) {
128
+ const containerRef = useRef<HTMLDivElement>(null);
129
+ const resizeSessionRef = useRef<ResizeSession | null>(null);
71
130
  const [chatWidth, setChatWidth] = useState(getInitialChatWidth);
72
131
  const [isResizing, setIsResizing] = useState(false);
73
132
  const [transitionEnabled, setTransitionEnabled] = useState(false);
74
133
  const chatWidthRef = useRef(chatWidth);
75
134
  chatWidthRef.current = chatWidth;
76
135
 
136
+ const getContainerWidth = useCallback(
137
+ () => containerRef.current?.getBoundingClientRect().width,
138
+ [],
139
+ );
140
+
141
+ const clampToContainer = useCallback(
142
+ (width: number) => clampChatWidth(width, getContainerWidth()),
143
+ [getContainerWidth],
144
+ );
145
+
77
146
  // 首帧不要动画:否则每次挂载都会看到面板「滑进来」。
78
147
  useEffect(() => {
79
148
  const timer = window.setTimeout(() => setTransitionEnabled(true), 0);
80
149
  return () => window.clearTimeout(timer);
81
150
  }, []);
82
151
 
152
+ // 挂载后按真实容器宽度重新 clamp 一次(宿主可能有左侧 sidebar 等偏移)。
83
153
  useEffect(() => {
84
- const onResize = () => setChatWidth((width) => clampChatWidth(width));
154
+ setChatWidth((width) => clampToContainer(width));
155
+ }, [clampToContainer]);
156
+
157
+ useEffect(() => {
158
+ const onResize = () => setChatWidth((width) => clampToContainer(width));
85
159
  window.addEventListener("resize", onResize);
86
160
  return () => window.removeEventListener("resize", onResize);
87
- }, []);
161
+ }, [clampToContainer]);
88
162
 
89
163
  const persistChatWidth = useCallback((width: number) => {
90
164
  try {
@@ -94,11 +168,24 @@ export function CloudOsWorkspaceSplit({
94
168
  }
95
169
  }, []);
96
170
 
171
+ const widthFromPointer = useCallback(
172
+ (clientX: number) => {
173
+ const session = resizeSessionRef.current;
174
+ if (!session) return chatWidthRef.current;
175
+ return clampToContainer(session.startWidth + clientX - session.startX);
176
+ },
177
+ [clampToContainer],
178
+ );
179
+
97
180
  // 用 pointer capture:拖过右侧 iframe 时事件也不会丢。
98
181
  const handleResizePointerDown = useCallback(
99
182
  (event: ReactPointerEvent<HTMLDivElement>) => {
100
183
  if (!workspaceOpen) return;
101
184
  event.preventDefault();
185
+ resizeSessionRef.current = {
186
+ startX: event.clientX,
187
+ startWidth: chatWidthRef.current,
188
+ };
102
189
  event.currentTarget.setPointerCapture(event.pointerId);
103
190
  setIsResizing(true);
104
191
  },
@@ -107,9 +194,9 @@ export function CloudOsWorkspaceSplit({
107
194
  const handleResizePointerMove = useCallback(
108
195
  (event: ReactPointerEvent<HTMLDivElement>) => {
109
196
  if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
110
- setChatWidth(clampChatWidth(event.clientX));
197
+ setChatWidth(widthFromPointer(event.clientX));
111
198
  },
112
- [],
199
+ [widthFromPointer],
113
200
  );
114
201
  const handleResizePointerUp = useCallback(
115
202
  (event: ReactPointerEvent<HTMLDivElement>) => {
@@ -119,12 +206,13 @@ export function CloudOsWorkspaceSplit({
119
206
  const width =
120
207
  event.type === "pointercancel"
121
208
  ? chatWidthRef.current
122
- : clampChatWidth(event.clientX);
209
+ : widthFromPointer(event.clientX);
210
+ resizeSessionRef.current = null;
123
211
  setChatWidth(width);
124
212
  persistChatWidth(width);
125
213
  setIsResizing(false);
126
214
  },
127
- [persistChatWidth],
215
+ [persistChatWidth, widthFromPointer],
128
216
  );
129
217
 
130
218
  useEffect(() => {
@@ -147,18 +235,11 @@ export function CloudOsWorkspaceSplit({
147
235
  return (
148
236
  // h-full 而不是只靠 flex-1:宿主给的容器不一定是 flex,那样 flex-1 不生效,
149
237
  // 整个分栏会塌成内容高度(聊天区不铺满、输入框吊在半空)。
150
- <div className="relative flex h-full min-h-0 flex-1 overflow-hidden bg-kumo-base">
151
- {isAgentActive && (
152
- <div
153
- className="absolute left-0 z-10 h-0"
154
- style={{ top: 0, right: railWidth }}
155
- >
156
- <div className="absolute left-0 right-0 h-0.5 overflow-hidden bg-kumo-fill">
157
- <div className="cos-progress-sweep absolute inset-y-0 w-1/3 bg-kumo-brand" />
158
- </div>
159
- </div>
160
- )}
161
-
238
+ <div
239
+ ref={containerRef}
240
+ className="relative flex h-full min-h-0 flex-1 overflow-hidden bg-kumo-base"
241
+ aria-busy={isLoading}
242
+ >
162
243
  {/* ── 左:聊天 ──────────────────────────────────────────────────────── */}
163
244
  <div
164
245
  className={`flex h-full min-h-0 flex-shrink-0 flex-col ${transitionClass} ${
@@ -197,6 +278,8 @@ export function CloudOsWorkspaceSplit({
197
278
  </div>
198
279
 
199
280
  {rail}
281
+
282
+ {isLoading && <CloudOsConversationLoading />}
200
283
  </div>
201
284
  );
202
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";