@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.
- package/cloud-os/README.md +6 -6
- package/cloud-os/assets/followup-arrow.svg +3 -0
- package/cloud-os/assets/loading-corner.svg +3 -0
- package/cloud-os/assets/loading-mark.svg +16 -0
- package/cloud-os/assets/loading-spark.svg +3 -0
- package/cloud-os/assets/model-selected.svg +5 -0
- package/cloud-os/capability-chip.tsx +35 -0
- package/cloud-os/chat/activity-indicator.tsx +29 -0
- package/cloud-os/chat/cloud-os-chat-messages.tsx +130 -70
- package/cloud-os/chat/markdown-message.tsx +85 -40
- package/cloud-os/chat/rich-blocks.tsx +494 -190
- package/cloud-os/chat/tool-presentation.ts +67 -6
- package/cloud-os/chat/tool-rows.tsx +87 -43
- package/cloud-os/chat/transcript-model.ts +215 -23
- package/cloud-os/composer/cloud-os-chat-input.tsx +242 -127
- package/cloud-os/composer/cloud-os-model-select.tsx +105 -0
- package/cloud-os/file-view.tsx +266 -0
- package/cloud-os/index.ts +29 -2
- package/cloud-os/layout/cloud-os-workspace-split.tsx +107 -24
- package/cloud-os/primitives/workshop-controls.tsx +2 -2
- package/cloud-os/styles/cloud-os.css +93 -19
- package/demo/camel-chat-showcase.tsx +21 -13
- package/demo/chat-scenarios.ts +100 -40
- package/demo/cloud-os-chat-showcase.tsx +51 -17
- package/demo/fixtures.ts +4 -5
- package/demo/message-panel-gallery.tsx +16 -2
- package/package.json +3 -4
- package/src/camel/camel-chat-messages.tsx +82 -77
- package/src/camel/camel-prompt-input.tsx +2 -1
- package/src/camel/camel-tool-presentation.tsx +19 -15
- package/src/camel/camel-turn.ts +1 -17
- package/src/chat-summary-panel.tsx +1 -1
- package/src/composer/chat-composer.tsx +2 -1
- package/src/composer/composer-trigger-popover.tsx +1 -1
- package/src/composer/composer.tsx +2 -1
- package/src/composer/index.ts +1 -0
- package/src/composer/key-rules.ts +12 -0
- package/src/contracts.ts +0 -2
- package/src/message-panel.tsx +0 -23
- package/src/parts/index.tsx +102 -63
- 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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
:
|
|
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
|
|
151
|
-
{
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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-
|
|
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-
|
|
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";
|