@springbrand/message-panel 0.1.3-alpha.0 → 0.1.3-alpha.10

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 (38) hide show
  1. package/cloud-os/README.md +71 -0
  2. package/cloud-os/capability-chip.tsx +35 -0
  3. package/cloud-os/chat/activity-indicator.tsx +29 -0
  4. package/cloud-os/chat/cloud-os-chat-messages.tsx +660 -0
  5. package/cloud-os/chat/markdown-message.tsx +78 -0
  6. package/cloud-os/chat/rich-blocks.tsx +787 -0
  7. package/cloud-os/chat/tool-presentation.ts +586 -0
  8. package/cloud-os/chat/tool-rows.tsx +216 -0
  9. package/cloud-os/chat/transcript-model.ts +780 -0
  10. package/cloud-os/composer/cloud-os-chat-input.tsx +686 -0
  11. package/cloud-os/file-view.tsx +258 -0
  12. package/cloud-os/index.ts +123 -0
  13. package/cloud-os/internal/cn.ts +6 -0
  14. package/cloud-os/internal/theme-context.tsx +16 -0
  15. package/cloud-os/layout/cloud-os-root.tsx +34 -0
  16. package/cloud-os/layout/cloud-os-workspace-split.tsx +228 -0
  17. package/cloud-os/primitives/dropdown-menu.tsx +82 -0
  18. package/cloud-os/primitives/tooltip.tsx +68 -0
  19. package/cloud-os/primitives/workshop-controls.tsx +114 -0
  20. package/cloud-os/styles/cloud-os.css +553 -0
  21. package/cloud-os/workspace/cloud-os-workspace-panel.tsx +272 -0
  22. package/demo/camel-chat-showcase.tsx +13 -917
  23. package/demo/chat-scenarios.ts +926 -0
  24. package/demo/cloud-os-chat-showcase.tsx +481 -0
  25. package/demo/index.ts +2 -0
  26. package/package.json +17 -5
  27. package/src/camel/camel-chat-messages.tsx +55 -20
  28. package/src/camel/camel-prompt-input.tsx +2 -1
  29. package/src/camel/camel-turn.ts +21 -3
  30. package/src/chat-summary-panel.tsx +1 -1
  31. package/src/composer/chat-composer.tsx +2 -1
  32. package/src/composer/composer-trigger-popover.tsx +1 -1
  33. package/src/composer/composer.tsx +2 -1
  34. package/src/composer/index.ts +1 -0
  35. package/src/composer/key-rules.ts +12 -0
  36. package/src/message.tsx +0 -8
  37. package/src/parts/plan.ts +3 -2
  38. package/src/styles/index.css +4 -1
@@ -0,0 +1,686 @@
1
+ import {
2
+ Brain,
3
+ CaretDown,
4
+ Check,
5
+ File as FileIcon,
6
+ Plug,
7
+ Plus,
8
+ Terminal,
9
+ } from "@phosphor-icons/react";
10
+ import {
11
+ AssistantRuntimeProvider,
12
+ ComposerPrimitive,
13
+ unstable_useSlashCommandAdapter,
14
+ unstable_useTriggerPopoverAriaProps,
15
+ unstable_useTriggerPopoverTriggers,
16
+ useExternalStoreRuntime,
17
+ type AssistantRuntime,
18
+ type ThreadMessage,
19
+ } from "@assistant-ui/react";
20
+ import { ComposerTriggerPopover } from "@springbrand/message-panel/composer";
21
+ import {
22
+ Fragment,
23
+ useCallback,
24
+ useEffect,
25
+ useMemo,
26
+ useRef,
27
+ useState,
28
+ type DragEvent as ReactDragEvent,
29
+ type Ref,
30
+ type ReactNode,
31
+ } from "react";
32
+ import { DropdownMenu } from "../primitives/dropdown-menu";
33
+ import { Tooltip } from "../primitives/tooltip";
34
+ import { WorkshopIconButton } from "../primitives/workshop-controls";
35
+ import { CapabilityChip } from "../capability-chip";
36
+ import {
37
+ renderFileView,
38
+ type FileRenderer,
39
+ } from "../file-view";
40
+
41
+ const isComposingKeyEvent = (event: {
42
+ isComposing?: boolean;
43
+ keyCode?: number;
44
+ }) => event.isComposing === true || event.keyCode === 229;
45
+
46
+ /**
47
+ * 从 cloudflare-os-main `ChatInterface.tsx` 的 `ChatInput` 拷来。
48
+ *
49
+ * 保留:prompt card 的形与投影、自动增高的 textarea、拖拽落文件的整卡覆盖层、
50
+ * 附件缩略图行、底部左「+ / 添加资源」右「模型 / 发送-停止」的布局、
51
+ * draftUpdateBanner 插槽、blockedReason 占位文案、Enter 发送 / Shift+Enter 换行。
52
+ *
53
+ * 删掉:capsule(URL → 资源胶囊)、ComposerMirror、GatekeeperModal —— 这三样都直挂
54
+ * gadgets 的 capnweb RPC,在 UIMessage 协议下没有对应物。「添加资源」按钮保留成一个
55
+ * 可选回调,宿主不给就不渲染。Command picker 使用 package 内的
56
+ * assistant-ui ComposerTriggerPopover。
57
+ */
58
+
59
+ export interface CloudOsModelOption {
60
+ id: string;
61
+ name: string;
62
+ }
63
+
64
+ export interface CloudOsComposerCommand {
65
+ prefix?: "/" | "$";
66
+ name: string;
67
+ description: string;
68
+ }
69
+
70
+ export interface CloudOsCapabilityView {
71
+ kind: "skill" | "plan";
72
+ name: string;
73
+ label: string;
74
+ }
75
+
76
+ export interface CloudOsAttachmentView {
77
+ id: string;
78
+ filename?: string;
79
+ mediaType?: string;
80
+ size?: number;
81
+ previewUrl?: string;
82
+ progress?: number;
83
+ status: "uploading" | "ready" | "error";
84
+ }
85
+
86
+ export interface CloudOsChatInputProps {
87
+ value: string;
88
+ onChange: (value: string) => void;
89
+ onSubmit: () => void;
90
+ onStop?: () => void;
91
+ status: "ready" | "submitted" | "streaming" | "error";
92
+ models?: readonly CloudOsModelOption[];
93
+ selectedModel?: string | null;
94
+ onModelChange?: (modelId: string | null) => void;
95
+ attachments?: readonly CloudOsAttachmentView[];
96
+ onFilesSelected?: (files: readonly File[]) => void;
97
+ onAttachmentRemove?: (id: string) => void;
98
+ onAttachmentRetry?: (id: string) => void;
99
+ /** 接管输入框附件视图;未提供时使用包内 FileView。 */
100
+ renderFile?: FileRenderer;
101
+ /** 底部左侧的额外控件(宿主的 Agent 档位设置等)。 */
102
+ tools?: ReactNode;
103
+ /** 发送键左边的额外控件(宿主的「引导 / 排队」投递方式)。 */
104
+ submitControl?: ReactNode;
105
+ /** 宿主侧判定「此刻不能提交」(缺必填设置、引导不可用…)。 */
106
+ submissionBlocked?: boolean;
107
+ /**
108
+ * 回合是否进行中。宿主的 turn 状态比 status 更准(status 会在 steer 间隙回落),
109
+ * 传了就以它为准来决定显示发送键还是停止键。
110
+ */
111
+ turnActive?: boolean;
112
+ /** 可用的斜杠命令,也会显示在「+」菜单中。 */
113
+ commands?: readonly CloudOsComposerCommand[];
114
+ capabilities?: readonly CloudOsCapabilityView[];
115
+ onCommandSelect?: (command: CloudOsComposerCommand) => void;
116
+ onCapabilityRemove?: (kind: CloudOsCapabilityView["kind"], name: string) => void;
117
+ /** 有值时渲染「添加资源」按钮。 */
118
+ onAttachResource?: () => void;
119
+ attachLabel?: string;
120
+ showThinkingTraces?: boolean;
121
+ onToggleThinkingTraces?: () => void;
122
+ /** 非空时锁住输入框,并把它当占位文案显示。 */
123
+ blockedReason?: string;
124
+ /** 贴在 prompt card 顶部的横幅(原文件用来放「待接受的改动」)。 */
125
+ banner?: ReactNode;
126
+ placeholder?: string;
127
+ minRows?: number;
128
+ maxRows?: number;
129
+ autoFocus?: boolean;
130
+ /** Gives the host direct access to the chat textarea. */
131
+ textareaRef?: Ref<HTMLTextAreaElement>;
132
+ /** 输入框下方一行(原文件放 token / cost)。 */
133
+ footnote?: ReactNode;
134
+ }
135
+
136
+ const MAX_PENDING_ATTACHMENTS = 8;
137
+
138
+ function commandLabel(command: CloudOsComposerCommand): string {
139
+ return `${command.prefix ?? "/"}${command.name.replaceAll("-", " ")}`;
140
+ }
141
+
142
+ function autoResizeTextarea(
143
+ textarea: HTMLTextAreaElement,
144
+ minRows: number,
145
+ maxRows: number,
146
+ ) {
147
+ textarea.style.height = "auto";
148
+ const cs = getComputedStyle(textarea);
149
+ const lineHeight = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.5;
150
+ const paddingY = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
151
+ const borderY =
152
+ parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth);
153
+ const minH = lineHeight * minRows + paddingY + borderY;
154
+ const maxH = lineHeight * maxRows + paddingY + borderY;
155
+ textarea.style.height = `${Math.min(Math.max(textarea.scrollHeight, minH), maxH)}px`;
156
+ textarea.style.overflow = textarea.scrollHeight > maxH ? "auto" : "hidden";
157
+ }
158
+
159
+ function CloudOsChatInputContent({
160
+ value,
161
+ onChange,
162
+ onSubmit,
163
+ onStop,
164
+ status,
165
+ models = [],
166
+ selectedModel = null,
167
+ onModelChange,
168
+ attachments = [],
169
+ onFilesSelected,
170
+ onAttachmentRemove,
171
+ onAttachmentRetry,
172
+ renderFile,
173
+ tools,
174
+ submitControl,
175
+ submissionBlocked = false,
176
+ turnActive,
177
+ commands = [],
178
+ capabilities = [],
179
+ onCommandSelect,
180
+ onCapabilityRemove,
181
+ onAttachResource,
182
+ attachLabel,
183
+ showThinkingTraces,
184
+ onToggleThinkingTraces,
185
+ blockedReason,
186
+ banner,
187
+ placeholder,
188
+ minRows = 1,
189
+ maxRows = 6,
190
+ autoFocus = false,
191
+ textareaRef: forwardedTextareaRef,
192
+ footnote,
193
+ runtime,
194
+ }: CloudOsChatInputProps & { runtime: AssistantRuntime }) {
195
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null);
196
+ const attachmentInputRef = useRef<HTMLInputElement>(null);
197
+ const dragDepthRef = useRef(0);
198
+ const [dragActive, setDragActive] = useState(false);
199
+
200
+ const isAgentActive =
201
+ turnActive ?? (status === "submitted" || status === "streaming");
202
+ const isBlocked = Boolean(blockedReason);
203
+ const canAttachMore = attachments.length < MAX_PENDING_ATTACHMENTS;
204
+ const hasReadyAttachment = attachments.some(
205
+ (attachment) => attachment.status === "ready",
206
+ );
207
+ const hasUnreadyAttachment = attachments.some(
208
+ (attachment) => attachment.status !== "ready",
209
+ );
210
+ // 回合进行中时依然可以提交(引导 / 排队),由宿主的 submissionBlocked 说了算。
211
+ // 只有「回合在跑 + 输入框是空的」才把发送键换成停止键 —— 否则用户打了一半字
212
+ // 按回车会变成停止,那是最难受的一种误触。
213
+ const showStopButton =
214
+ isAgentActive && Boolean(onStop) && !value.trim() && !hasReadyAttachment;
215
+ const canSend =
216
+ !isBlocked &&
217
+ !submissionBlocked &&
218
+ (value.trim().length > 0 || hasReadyAttachment) &&
219
+ !hasUnreadyAttachment;
220
+
221
+ const selectedModelLabel =
222
+ selectedModel === null
223
+ ? "No agent"
224
+ : (models.find((model) => model.id === selectedModel)?.name ?? selectedModel);
225
+
226
+ useEffect(() => {
227
+ const element = textareaRef.current;
228
+ if (element) autoResizeTextarea(element, minRows, maxRows);
229
+ }, [maxRows, minRows, value]);
230
+
231
+ const selectCommand = useCallback((
232
+ command: CloudOsComposerCommand,
233
+ clearTrigger: boolean,
234
+ ) => {
235
+ onCommandSelect?.(command);
236
+ if (clearTrigger) {
237
+ runtime.thread.composer.setText("");
238
+ onChange("");
239
+ }
240
+ requestAnimationFrame(() => textareaRef.current?.focus());
241
+ }, [onChange, onCommandSelect, runtime]);
242
+
243
+ const slashCommands = useMemo(
244
+ () => commands
245
+ .filter((command) => (command.prefix ?? "/") === "/")
246
+ .map((command) => ({
247
+ id: command.name,
248
+ label: commandLabel(command),
249
+ description: command.description,
250
+ execute: () => selectCommand(command, true),
251
+ })),
252
+ [commands, selectCommand],
253
+ );
254
+ const skillCommands = useMemo(
255
+ () => commands
256
+ .filter((command) => command.prefix === "$")
257
+ .map((command) => ({
258
+ id: command.name,
259
+ label: commandLabel(command),
260
+ description: command.description,
261
+ execute: () => selectCommand(command, true),
262
+ })),
263
+ [commands, selectCommand],
264
+ );
265
+ const slashAdapter = unstable_useSlashCommandAdapter({
266
+ commands: slashCommands,
267
+ removeOnExecute: true,
268
+ });
269
+ const skillAdapter = unstable_useSlashCommandAdapter({
270
+ commands: skillCommands,
271
+ removeOnExecute: true,
272
+ });
273
+ // assistant-ui only re-runs a TriggerPopover search when the adapter changes.
274
+ const slash = useMemo(() => ({
275
+ ...slashAdapter,
276
+ adapter: { ...slashAdapter.adapter },
277
+ }), [slashAdapter, slashCommands]);
278
+ const skills = useMemo(() => ({
279
+ ...skillAdapter,
280
+ adapter: { ...skillAdapter.adapter },
281
+ }), [skillAdapter, skillCommands]);
282
+ const triggers = unstable_useTriggerPopoverTriggers();
283
+ const triggerAria = unstable_useTriggerPopoverAriaProps();
284
+
285
+ const setTriggerCursor = useCallback(
286
+ (position: number) => {
287
+ for (const trigger of triggers.values()) {
288
+ trigger.resource.setCursorPosition(position);
289
+ }
290
+ },
291
+ [triggers],
292
+ );
293
+
294
+ useEffect(() => {
295
+ setTriggerCursor(textareaRef.current?.selectionStart ?? value.length);
296
+ }, [commands, setTriggerCursor, value]);
297
+
298
+ const addFiles = useCallback(
299
+ (files: readonly File[]) => {
300
+ if (files.length === 0) return;
301
+ onFilesSelected?.(files.slice(0, MAX_PENDING_ATTACHMENTS - attachments.length));
302
+ },
303
+ [attachments.length, onFilesSelected],
304
+ );
305
+
306
+ const handleDragEnter = (event: ReactDragEvent<HTMLDivElement>) => {
307
+ if (!onFilesSelected) return;
308
+ if (!Array.from(event.dataTransfer.types).includes("Files")) return;
309
+ dragDepthRef.current += 1;
310
+ setDragActive(true);
311
+ };
312
+ const handleDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
313
+ if (!onFilesSelected) return;
314
+ if (!Array.from(event.dataTransfer.types).includes("Files")) return;
315
+ event.preventDefault();
316
+ event.dataTransfer.dropEffect = canAttachMore ? "copy" : "none";
317
+ };
318
+ const handleDragLeave = () => {
319
+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
320
+ if (dragDepthRef.current === 0) setDragActive(false);
321
+ };
322
+ const handleDrop = (event: ReactDragEvent<HTMLDivElement>) => {
323
+ if (!onFilesSelected) return;
324
+ event.preventDefault();
325
+ dragDepthRef.current = 0;
326
+ setDragActive(false);
327
+ addFiles(Array.from(event.dataTransfer.files));
328
+ };
329
+
330
+ return (
331
+ // isolation: isolate 把输入框内部的 z-index 关起来,免得盖到 portal 出去的模型下拉。
332
+ <div className="cos-chat-input-root relative isolate px-4 py-4">
333
+ <ComposerTriggerPopover
334
+ char="/"
335
+ {...slash}
336
+ aria-label="Commands"
337
+ emptyItemsLabel="No matching commands"
338
+ className="!w-72 !border-kumo-line/70 !bg-kumo-base !text-kumo-default themed-floating-shadow-lg"
339
+ />
340
+ <ComposerTriggerPopover
341
+ char="$"
342
+ {...skills}
343
+ aria-label="Skills"
344
+ emptyItemsLabel="No matching skills"
345
+ className="!w-72 !border-kumo-line/70 !bg-kumo-base !text-kumo-default themed-floating-shadow-lg"
346
+ />
347
+ <input
348
+ ref={attachmentInputRef}
349
+ type="file"
350
+ multiple
351
+ className="hidden"
352
+ onChange={(event) => {
353
+ const files = Array.from(event.currentTarget.files ?? []);
354
+ event.currentTarget.value = "";
355
+ addFiles(files);
356
+ }}
357
+ />
358
+
359
+ {/* Prompt card:比页面底色更亮(kumo-control vs kumo-base),配柔和中性投影,
360
+ 让输入框读起来是一块独立表面;聚焦时投影再抬一档。 */}
361
+ <div
362
+ className="themed-prompt-card-shadow relative overflow-visible rounded-2xl border border-kumo-line bg-kumo-control transition-shadow duration-150 ease-out"
363
+ onDragEnter={handleDragEnter}
364
+ onDragOver={handleDragOver}
365
+ onDragLeave={handleDragLeave}
366
+ onDrop={handleDrop}
367
+ >
368
+ {dragActive && (
369
+ <div
370
+ className={`themed-inset-outline pointer-events-none absolute inset-0 z-20 grid place-items-center rounded-2xl border-2 border-dashed p-4 backdrop-blur-[1px] transition-[opacity,transform] duration-150 ease-out ${
371
+ canAttachMore
372
+ ? "border-kumo-brand/55 bg-kumo-brand/10"
373
+ : "border-kumo-warning/60 bg-kumo-warning/10"
374
+ }`}
375
+ >
376
+ <div
377
+ className={`themed-floating-shadow flex items-center gap-2 rounded-full border bg-kumo-base/90 px-3 py-2 text-[13px] font-medium leading-4 tracking-[-0.2px] text-kumo-default ${
378
+ canAttachMore ? "border-kumo-brand/25" : "border-kumo-warning/30"
379
+ }`}
380
+ >
381
+ <span
382
+ className={`grid h-7 w-7 place-items-center rounded-full ${
383
+ canAttachMore
384
+ ? "bg-kumo-brand/12 text-kumo-brand"
385
+ : "bg-kumo-warning/15 text-kumo-warning"
386
+ }`}
387
+ >
388
+ <FileIcon size={16} weight="duotone" />
389
+ </span>
390
+ {canAttachMore
391
+ ? "Drop files to attach"
392
+ : `Messages are limited to ${MAX_PENDING_ATTACHMENTS} attachments`}
393
+ </div>
394
+ </div>
395
+ )}
396
+
397
+ {banner}
398
+
399
+ <div className="relative flex flex-nowrap items-center gap-2 px-4 pb-1 pt-3">
400
+ {capabilities.length > 0 && (
401
+ <div className="flex max-w-[65%] shrink-0 flex-nowrap gap-1.5 overflow-x-auto">
402
+ {capabilities.map((capability) => (
403
+ <CapabilityChip
404
+ key={`${capability.kind}:${capability.name}`}
405
+ kind={capability.kind}
406
+ label={capability.label}
407
+ removable
408
+ onRemove={() =>
409
+ onCapabilityRemove?.(capability.kind, capability.name)
410
+ }
411
+ />
412
+ ))}
413
+ </div>
414
+ )}
415
+ <textarea
416
+ ref={(element) => {
417
+ textareaRef.current = element;
418
+ if (typeof forwardedTextareaRef === "function") {
419
+ forwardedTextareaRef(element);
420
+ } else if (forwardedTextareaRef) {
421
+ forwardedTextareaRef.current = element;
422
+ }
423
+ if (element) autoResizeTextarea(element, minRows, maxRows);
424
+ }}
425
+ value={value}
426
+ disabled={isBlocked}
427
+ autoFocus={autoFocus}
428
+ rows={minRows}
429
+ placeholder={
430
+ isBlocked
431
+ ? blockedReason
432
+ : isAgentActive
433
+ ? "Waiting for agent…"
434
+ : (placeholder ?? "Ask a follow-up…")
435
+ }
436
+ onChange={(event) => {
437
+ const next = event.target.value;
438
+ runtime.thread.composer.setText(next);
439
+ onChange(next);
440
+ setTriggerCursor(event.target.selectionStart ?? next.length);
441
+ autoResizeTextarea(event.target, minRows, maxRows);
442
+ }}
443
+ onSelect={(event) =>
444
+ setTriggerCursor(
445
+ event.currentTarget.selectionStart ?? value.length,
446
+ )
447
+ }
448
+ onPaste={(event) => {
449
+ const files = Array.from(event.clipboardData.items)
450
+ .filter((item) => item.kind === "file")
451
+ .map((item) => item.getAsFile())
452
+ .filter((file): file is File => file !== null);
453
+ if (files.length > 0) {
454
+ event.preventDefault();
455
+ addFiles(files);
456
+ }
457
+ }}
458
+ onKeyDown={(event) => {
459
+ for (const trigger of triggers.values()) {
460
+ if (trigger.resource.handleKeyDown(event)) return;
461
+ }
462
+ // Enter 发送(按住 Shift 换行); IME 组合中不发送
463
+ if (
464
+ event.key === "Enter" &&
465
+ !event.shiftKey &&
466
+ !isComposingKeyEvent(event.nativeEvent)
467
+ ) {
468
+ event.preventDefault();
469
+ if (canSend) onSubmit();
470
+ }
471
+ }}
472
+ className="relative z-[1] min-w-0 flex-1 resize-none border-none bg-transparent p-0 text-[14px] leading-[22px] tracking-[-0.25px] text-kumo-default outline-none placeholder:text-kumo-inactive disabled:cursor-not-allowed"
473
+ {...triggerAria}
474
+ />
475
+ </div>
476
+
477
+ {attachments.length > 0 && (
478
+ <div className="flex flex-wrap items-center gap-2 px-3 pb-2 pt-1">
479
+ {attachments.map((attachment) => (
480
+ <Fragment key={attachment.id}>
481
+ {renderFileView(renderFile, {
482
+ file: {
483
+ url: attachment.previewUrl,
484
+ filename: attachment.filename,
485
+ mediaType: attachment.mediaType,
486
+ size: attachment.size,
487
+ },
488
+ placement: "composer",
489
+ status: attachment.status,
490
+ progress: attachment.progress,
491
+ onRemove: onAttachmentRemove
492
+ ? () => onAttachmentRemove(attachment.id)
493
+ : undefined,
494
+ onRetry: onAttachmentRetry
495
+ ? () => onAttachmentRetry(attachment.id)
496
+ : undefined,
497
+ })}
498
+ </Fragment>
499
+ ))}
500
+ </div>
501
+ )}
502
+
503
+ {/* 底部一行:左侧选项,右侧模型 + 发送 */}
504
+ <div className="flex items-center justify-between gap-1.5 px-3 pb-1.5">
505
+ <div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
506
+ <DropdownMenu>
507
+ <DropdownMenu.Trigger
508
+ render={
509
+ <button
510
+ type="button"
511
+ className="group flex h-8 w-8 flex-shrink-0 cursor-pointer items-center justify-center rounded-lg text-kumo-inactive transition-[background-color,color,transform] duration-150 ease-out hover:bg-kumo-tint hover:text-kumo-subtle focus-visible:bg-kumo-tint focus-visible:text-kumo-subtle focus-visible:outline-none active:scale-[0.96] data-[state=open]:bg-kumo-tint data-[state=open]:text-kumo-subtle"
512
+ aria-label="Open chat options"
513
+ >
514
+ <Plus size={18} />
515
+ </button>
516
+ }
517
+ />
518
+ <DropdownMenu.Content className="themed-floating-shadow-lg !min-w-[170px] rounded-2xl border border-kumo-line/70 bg-kumo-base p-1">
519
+ {onToggleThinkingTraces && (
520
+ <DropdownMenu.Item
521
+ onClick={onToggleThinkingTraces}
522
+ 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"
523
+ >
524
+ <span className="mr-2 inline-flex h-4 w-4 items-center justify-center text-kumo-inactive">
525
+ <Brain size={14} />
526
+ </span>
527
+ <span className="flex-1">
528
+ {showThinkingTraces ? "Hide thinking" : "Show thinking"}
529
+ </span>
530
+ </DropdownMenu.Item>
531
+ )}
532
+ {commands.length > 0 && (
533
+ <>
534
+ {commands.map((command) => (
535
+ <DropdownMenu.Item
536
+ key={`${command.prefix ?? "/"}${command.name}`}
537
+ onClick={() => selectCommand(command, false)}
538
+ 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"
539
+ >
540
+ <span className="mr-2 inline-flex h-4 w-4 items-center justify-center text-kumo-inactive">
541
+ <Terminal size={14} />
542
+ </span>
543
+ <span className="min-w-0 flex-1 truncate">
544
+ {commandLabel(command)}
545
+ </span>
546
+ </DropdownMenu.Item>
547
+ ))}
548
+ <div className="my-1 border-t border-kumo-line/70" />
549
+ </>
550
+ )}
551
+ {onFilesSelected && (
552
+ <DropdownMenu.Item
553
+ onClick={() => attachmentInputRef.current?.click()}
554
+ 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"
555
+ >
556
+ <span className="mr-2 inline-flex h-4 w-4 items-center justify-center text-kumo-inactive">
557
+ <FileIcon size={14} />
558
+ </span>
559
+ <span className="flex-1">Upload file</span>
560
+ </DropdownMenu.Item>
561
+ )}
562
+ </DropdownMenu.Content>
563
+ </DropdownMenu>
564
+ {onAttachResource && (
565
+ <button
566
+ type="button"
567
+ onClick={onAttachResource}
568
+ className="inline-flex h-8 flex-shrink-0 cursor-pointer items-center gap-1.5 rounded-lg px-2 text-[13px] leading-none tracking-[-0.25px] text-kumo-inactive transition-[background-color,color,transform] duration-150 ease-out hover:bg-kumo-tint hover:text-kumo-subtle focus-visible:bg-kumo-tint focus-visible:text-kumo-subtle focus-visible:outline-none active:scale-[0.97]"
569
+ >
570
+ <Plug size={15} className="flex-shrink-0" />
571
+ <span className="cos-attach-label leading-none">
572
+ {attachLabel ?? "Add resource"}
573
+ </span>
574
+ </button>
575
+ )}
576
+ {tools}
577
+ </div>
578
+
579
+ <div className="ml-auto flex min-w-0 flex-shrink items-center gap-1.5">
580
+ {onModelChange && (
581
+ <DropdownMenu>
582
+ <DropdownMenu.Trigger
583
+ render={
584
+ <button
585
+ type="button"
586
+ className="group inline-flex h-8 min-w-0 max-w-[180px] cursor-pointer items-center gap-1.5 rounded-lg px-2 text-[13px] leading-5 tracking-[-0.25px] text-kumo-subtle transition-[background-color,color,transform] duration-150 ease-out hover:bg-kumo-tint hover:text-kumo-default focus-visible:bg-kumo-tint focus-visible:text-kumo-default focus-visible:outline-none active:scale-[0.97] data-[state=open]:bg-kumo-tint data-[state=open]:text-kumo-default"
587
+ aria-label="Select model"
588
+ >
589
+ <span className="min-w-0 truncate">{selectedModelLabel}</span>
590
+ <CaretDown
591
+ size={12}
592
+ weight="bold"
593
+ className="flex-shrink-0 text-kumo-inactive transition-transform duration-150 ease-out group-data-[state=open]:rotate-180"
594
+ />
595
+ </button>
596
+ }
597
+ />
598
+ <DropdownMenu.Content
599
+ align="end"
600
+ className="themed-floating-shadow-lg !min-w-[190px] rounded-2xl border border-kumo-line/70 bg-kumo-base p-1"
601
+ >
602
+ {models.map((model) => (
603
+ <DropdownMenu.Item
604
+ key={model.id}
605
+ onClick={() => onModelChange(model.id)}
606
+ 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"
607
+ >
608
+ <span className="min-w-0 flex-1 truncate">{model.name}</span>
609
+ {selectedModel === model.id && (
610
+ <Check
611
+ size={12}
612
+ weight="bold"
613
+ className="ml-3 flex-shrink-0 text-kumo-inactive"
614
+ />
615
+ )}
616
+ </DropdownMenu.Item>
617
+ ))}
618
+ </DropdownMenu.Content>
619
+ </DropdownMenu>
620
+ )}
621
+ {!showStopButton && submitControl}
622
+ {showStopButton ? (
623
+ <WorkshopIconButton
624
+ onClick={onStop}
625
+ tone="primary"
626
+ className="!h-8 !w-8"
627
+ aria-label="Stop agent"
628
+ >
629
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
630
+ <rect x="5" y="5" width="14" height="14" rx="2" />
631
+ </svg>
632
+ </WorkshopIconButton>
633
+ ) : (
634
+ <WorkshopIconButton
635
+ onClick={onSubmit}
636
+ disabled={!canSend}
637
+ tone="primary"
638
+ className="!h-8 !w-8 disabled:cursor-not-allowed disabled:opacity-30"
639
+ aria-label="Send message"
640
+ >
641
+ <svg
642
+ width="16"
643
+ height="16"
644
+ viewBox="0 0 24 24"
645
+ fill="none"
646
+ stroke="currentColor"
647
+ strokeWidth="2.5"
648
+ >
649
+ <line x1="12" y1="19" x2="12" y2="5" />
650
+ <polyline points="5 12 12 5 19 12" />
651
+ </svg>
652
+ </WorkshopIconButton>
653
+ )}
654
+ </div>
655
+ </div>
656
+ </div>
657
+
658
+ {footnote && (
659
+ <div className="-mt-1 flex min-h-[1.25rem] items-start justify-end gap-4 px-4 pb-1 font-mono text-[11px] leading-4 text-kumo-inactive">
660
+ {footnote}
661
+ </div>
662
+ )}
663
+ </div>
664
+ );
665
+ }
666
+
667
+ const EMPTY_MESSAGES: readonly ThreadMessage[] = [];
668
+
669
+ export function CloudOsChatInput(props: CloudOsChatInputProps) {
670
+ const runtime = useExternalStoreRuntime<ThreadMessage>({
671
+ messages: EMPTY_MESSAGES,
672
+ onNew: async () => undefined,
673
+ });
674
+
675
+ useEffect(() => {
676
+ runtime.thread.composer.setText(props.value);
677
+ }, [props.value, runtime]);
678
+
679
+ return (
680
+ <AssistantRuntimeProvider runtime={runtime}>
681
+ <ComposerPrimitive.Unstable_TriggerPopoverRoot>
682
+ <CloudOsChatInputContent {...props} runtime={runtime} />
683
+ </ComposerPrimitive.Unstable_TriggerPopoverRoot>
684
+ </AssistantRuntimeProvider>
685
+ );
686
+ }