@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
@@ -1,24 +1,43 @@
1
1
  import {
2
2
  Brain,
3
- CaretDown,
4
- Check,
5
3
  File as FileIcon,
6
4
  Plug,
7
5
  Plus,
8
- Terminal,
9
- X,
10
6
  } from "@phosphor-icons/react";
11
7
  import {
8
+ AssistantRuntimeProvider,
9
+ ComposerPrimitive,
10
+ unstable_useSlashCommandAdapter,
11
+ unstable_useTriggerPopoverAriaProps,
12
+ unstable_useTriggerPopoverTriggers,
13
+ useExternalStoreRuntime,
14
+ type AssistantRuntime,
15
+ type ThreadMessage,
16
+ } from "@assistant-ui/react";
17
+ import { ComposerTriggerPopover } from "@springbrand/message-panel/composer";
18
+ import {
19
+ Fragment,
12
20
  useCallback,
13
21
  useEffect,
22
+ useMemo,
14
23
  useRef,
15
24
  useState,
16
25
  type DragEvent as ReactDragEvent,
26
+ type Ref,
17
27
  type ReactNode,
18
28
  } from "react";
19
29
  import { DropdownMenu } from "../primitives/dropdown-menu";
20
30
  import { Tooltip } from "../primitives/tooltip";
21
31
  import { WorkshopIconButton } from "../primitives/workshop-controls";
32
+ import { CapabilityChip } from "../capability-chip";
33
+ import {
34
+ renderFileView,
35
+ type FileRenderer,
36
+ } from "../file-view";
37
+ import {
38
+ CloudOsModelSelect,
39
+ type CloudOsModelOption,
40
+ } from "./cloud-os-model-select";
22
41
 
23
42
  const isComposingKeyEvent = (event: {
24
43
  isComposing?: boolean;
@@ -32,21 +51,22 @@ const isComposingKeyEvent = (event: {
32
51
  * 附件缩略图行、底部左「+ / 添加资源」右「模型 / 发送-停止」的布局、
33
52
  * draftUpdateBanner 插槽、blockedReason 占位文案、Enter 发送 / Shift+Enter 换行。
34
53
  *
35
- * 删掉:capsule(URL → 资源胶囊)、slash command picker、ComposerMirror、
36
- * GatekeeperModal —— 这四样都直挂 gadgets 的 capnweb RPC,在 UIMessage 协议下
37
- * 没有对应物。「添加资源」按钮保留成一个可选回调,宿主不给就不渲染。
54
+ * 删掉:capsule(URL → 资源胶囊)、ComposerMirror、GatekeeperModal —— 这三样都直挂
55
+ * gadgets 的 capnweb RPC,在 UIMessage 协议下没有对应物。「添加资源」按钮保留成一个
56
+ * 可选回调,宿主不给就不渲染。Command picker 使用 package 内的
57
+ * assistant-ui ComposerTriggerPopover。
38
58
  */
39
59
 
40
- export interface CloudOsModelOption {
41
- id: string;
60
+ export interface CloudOsComposerCommand {
61
+ prefix?: "/" | "$";
42
62
  name: string;
63
+ description: string;
43
64
  }
44
65
 
45
- export interface CloudOsComposerCommand {
66
+ export interface CloudOsCapabilityView {
67
+ kind: "skill" | "tool" | "plan";
46
68
  name: string;
47
- description: string;
48
- /** 选中后填进输入框的起手文本。 */
49
- prompt: string;
69
+ label: string;
50
70
  }
51
71
 
52
72
  export interface CloudOsAttachmentView {
@@ -55,6 +75,7 @@ export interface CloudOsAttachmentView {
55
75
  mediaType?: string;
56
76
  size?: number;
57
77
  previewUrl?: string;
78
+ progress?: number;
58
79
  status: "uploading" | "ready" | "error";
59
80
  }
60
81
 
@@ -71,6 +92,8 @@ export interface CloudOsChatInputProps {
71
92
  onFilesSelected?: (files: readonly File[]) => void;
72
93
  onAttachmentRemove?: (id: string) => void;
73
94
  onAttachmentRetry?: (id: string) => void;
95
+ /** 接管输入框附件视图;未提供时使用包内 FileView。 */
96
+ renderFile?: FileRenderer;
74
97
  /** 底部左侧的额外控件(宿主的 Agent 档位设置等)。 */
75
98
  tools?: ReactNode;
76
99
  /** 发送键左边的额外控件(宿主的「引导 / 排队」投递方式)。 */
@@ -82,11 +105,11 @@ export interface CloudOsChatInputProps {
82
105
  * 传了就以它为准来决定显示发送键还是停止键。
83
106
  */
84
107
  turnActive?: boolean;
85
- /**
86
- * 可用的斜杠命令。cloudflare-os 原版是一个直连 RPC 的行内 picker;这里退成
87
- * 「+」菜单里的一组条目 —— 能力保住,但**没有**边打字边过滤的行内补全。
88
- */
108
+ /** 可用的斜杠命令。 */
89
109
  commands?: readonly CloudOsComposerCommand[];
110
+ capabilities?: readonly CloudOsCapabilityView[];
111
+ onCommandSelect?: (command: CloudOsComposerCommand) => void;
112
+ onCapabilityRemove?: (kind: CloudOsCapabilityView["kind"], name: string) => void;
90
113
  /** 有值时渲染「添加资源」按钮。 */
91
114
  onAttachResource?: () => void;
92
115
  attachLabel?: string;
@@ -100,11 +123,17 @@ export interface CloudOsChatInputProps {
100
123
  minRows?: number;
101
124
  maxRows?: number;
102
125
  autoFocus?: boolean;
126
+ /** Gives the host direct access to the chat textarea. */
127
+ textareaRef?: Ref<HTMLTextAreaElement>;
103
128
  /** 输入框下方一行(原文件放 token / cost)。 */
104
129
  footnote?: ReactNode;
105
130
  }
106
131
 
107
- const MAX_PENDING_ATTACHMENTS = 5;
132
+ const MAX_PENDING_ATTACHMENTS = 8;
133
+
134
+ function commandLabel(command: CloudOsComposerCommand): string {
135
+ return `${command.prefix ?? "/"}${command.name.replaceAll("-", " ")}`;
136
+ }
108
137
 
109
138
  function autoResizeTextarea(
110
139
  textarea: HTMLTextAreaElement,
@@ -123,7 +152,7 @@ function autoResizeTextarea(
123
152
  textarea.style.overflow = textarea.scrollHeight > maxH ? "auto" : "hidden";
124
153
  }
125
154
 
126
- export function CloudOsChatInput({
155
+ function CloudOsChatInputContent({
127
156
  value,
128
157
  onChange,
129
158
  onSubmit,
@@ -136,11 +165,15 @@ export function CloudOsChatInput({
136
165
  onFilesSelected,
137
166
  onAttachmentRemove,
138
167
  onAttachmentRetry,
168
+ renderFile,
139
169
  tools,
140
170
  submitControl,
141
171
  submissionBlocked = false,
142
172
  turnActive,
143
173
  commands = [],
174
+ capabilities = [],
175
+ onCommandSelect,
176
+ onCapabilityRemove,
144
177
  onAttachResource,
145
178
  attachLabel,
146
179
  showThinkingTraces,
@@ -151,8 +184,10 @@ export function CloudOsChatInput({
151
184
  minRows = 1,
152
185
  maxRows = 6,
153
186
  autoFocus = false,
187
+ textareaRef: forwardedTextareaRef,
154
188
  footnote,
155
- }: CloudOsChatInputProps) {
189
+ runtime,
190
+ }: CloudOsChatInputProps & { runtime: AssistantRuntime }) {
156
191
  const textareaRef = useRef<HTMLTextAreaElement | null>(null);
157
192
  const attachmentInputRef = useRef<HTMLInputElement>(null);
158
193
  const dragDepthRef = useRef(0);
@@ -179,16 +214,78 @@ export function CloudOsChatInput({
179
214
  (value.trim().length > 0 || hasReadyAttachment) &&
180
215
  !hasUnreadyAttachment;
181
216
 
182
- const selectedModelLabel =
183
- selectedModel === null
184
- ? "No agent"
185
- : (models.find((model) => model.id === selectedModel)?.name ?? selectedModel);
186
-
187
217
  useEffect(() => {
188
218
  const element = textareaRef.current;
189
219
  if (element) autoResizeTextarea(element, minRows, maxRows);
190
220
  }, [maxRows, minRows, value]);
191
221
 
222
+ const selectCommand = useCallback((
223
+ command: CloudOsComposerCommand,
224
+ clearTrigger: boolean,
225
+ ) => {
226
+ onCommandSelect?.(command);
227
+ if (clearTrigger) {
228
+ runtime.thread.composer.setText("");
229
+ onChange("");
230
+ }
231
+ requestAnimationFrame(() => textareaRef.current?.focus());
232
+ }, [onChange, onCommandSelect, runtime]);
233
+
234
+ const slashCommands = useMemo(
235
+ () => commands
236
+ .filter((command) => (command.prefix ?? "/") === "/")
237
+ .map((command) => ({
238
+ id: command.name,
239
+ label: commandLabel(command),
240
+ description: command.description,
241
+ execute: () => selectCommand(command, true),
242
+ })),
243
+ [commands, selectCommand],
244
+ );
245
+ const skillCommands = useMemo(
246
+ () => commands
247
+ .filter((command) => command.prefix === "$")
248
+ .map((command) => ({
249
+ id: command.name,
250
+ label: commandLabel(command),
251
+ description: command.description,
252
+ execute: () => selectCommand(command, true),
253
+ })),
254
+ [commands, selectCommand],
255
+ );
256
+ const slashAdapter = unstable_useSlashCommandAdapter({
257
+ commands: slashCommands,
258
+ removeOnExecute: true,
259
+ });
260
+ const skillAdapter = unstable_useSlashCommandAdapter({
261
+ commands: skillCommands,
262
+ removeOnExecute: true,
263
+ });
264
+ // assistant-ui only re-runs a TriggerPopover search when the adapter changes.
265
+ const slash = useMemo(() => ({
266
+ ...slashAdapter,
267
+ adapter: { ...slashAdapter.adapter },
268
+ }), [slashAdapter, slashCommands]);
269
+ const skills = useMemo(() => ({
270
+ ...skillAdapter,
271
+ adapter: { ...skillAdapter.adapter },
272
+ }), [skillAdapter, skillCommands]);
273
+ const triggers = unstable_useTriggerPopoverTriggers();
274
+ const triggerAria = unstable_useTriggerPopoverAriaProps();
275
+
276
+ const setTriggerCursor = useCallback(
277
+ (position: number) => {
278
+ for (const trigger of triggers.values()) {
279
+ trigger.resource.setCursorPosition(position);
280
+ }
281
+ },
282
+ [triggers],
283
+ );
284
+
285
+ useEffect(() => {
286
+ setTriggerCursor(textareaRef.current?.selectionStart ?? value.length);
287
+ }, [commands, setTriggerCursor, value]);
288
+
192
289
  const addFiles = useCallback(
193
290
  (files: readonly File[]) => {
194
291
  if (files.length === 0) return;
@@ -224,6 +321,20 @@ export function CloudOsChatInput({
224
321
  return (
225
322
  // isolation: isolate 把输入框内部的 z-index 关起来,免得盖到 portal 出去的模型下拉。
226
323
  <div className="cos-chat-input-root relative isolate px-4 py-4">
324
+ <ComposerTriggerPopover
325
+ char="/"
326
+ {...slash}
327
+ aria-label="Commands"
328
+ emptyItemsLabel="No matching commands"
329
+ className="!w-72 !border-kumo-line/70 !bg-kumo-base !text-kumo-default themed-floating-shadow-lg"
330
+ />
331
+ <ComposerTriggerPopover
332
+ char="$"
333
+ {...skills}
334
+ aria-label="Skills"
335
+ emptyItemsLabel="No matching skills"
336
+ className="!w-72 !border-kumo-line/70 !bg-kumo-base !text-kumo-default themed-floating-shadow-lg"
337
+ />
227
338
  <input
228
339
  ref={attachmentInputRef}
229
340
  type="file"
@@ -276,10 +387,30 @@ export function CloudOsChatInput({
276
387
 
277
388
  {banner}
278
389
 
279
- <div className="relative px-4 pb-1 pt-3">
390
+ <div className="relative flex flex-nowrap items-center gap-2 px-4 pb-1 pt-3">
391
+ {capabilities.length > 0 && (
392
+ <div className="flex max-w-[65%] shrink-0 flex-nowrap gap-1.5 overflow-x-auto">
393
+ {capabilities.map((capability) => (
394
+ <CapabilityChip
395
+ key={`${capability.kind}:${capability.name}`}
396
+ kind={capability.kind}
397
+ label={capability.label}
398
+ removable
399
+ onRemove={() =>
400
+ onCapabilityRemove?.(capability.kind, capability.name)
401
+ }
402
+ />
403
+ ))}
404
+ </div>
405
+ )}
280
406
  <textarea
281
407
  ref={(element) => {
282
408
  textareaRef.current = element;
409
+ if (typeof forwardedTextareaRef === "function") {
410
+ forwardedTextareaRef(element);
411
+ } else if (forwardedTextareaRef) {
412
+ forwardedTextareaRef.current = element;
413
+ }
283
414
  if (element) autoResizeTextarea(element, minRows, maxRows);
284
415
  }}
285
416
  value={value}
@@ -294,9 +425,17 @@ export function CloudOsChatInput({
294
425
  : (placeholder ?? "Ask a follow-up…")
295
426
  }
296
427
  onChange={(event) => {
297
- onChange(event.target.value);
428
+ const next = event.target.value;
429
+ runtime.thread.composer.setText(next);
430
+ onChange(next);
431
+ setTriggerCursor(event.target.selectionStart ?? next.length);
298
432
  autoResizeTextarea(event.target, minRows, maxRows);
299
433
  }}
434
+ onSelect={(event) =>
435
+ setTriggerCursor(
436
+ event.currentTarget.selectionStart ?? value.length,
437
+ )
438
+ }
300
439
  onPaste={(event) => {
301
440
  const files = Array.from(event.clipboardData.items)
302
441
  .filter((item) => item.kind === "file")
@@ -308,6 +447,9 @@ export function CloudOsChatInput({
308
447
  }
309
448
  }}
310
449
  onKeyDown={(event) => {
450
+ for (const trigger of triggers.values()) {
451
+ if (trigger.resource.handleKeyDown(event)) return;
452
+ }
311
453
  // Enter 发送(按住 Shift 换行); IME 组合中不发送
312
454
  if (
313
455
  event.key === "Enter" &&
@@ -318,55 +460,33 @@ export function CloudOsChatInput({
318
460
  if (canSend) onSubmit();
319
461
  }
320
462
  }}
321
- className="relative z-[1] w-full 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"
463
+ 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"
464
+ {...triggerAria}
322
465
  />
323
466
  </div>
324
467
 
325
468
  {attachments.length > 0 && (
326
469
  <div className="flex flex-wrap items-center gap-2 px-3 pb-2 pt-1">
327
470
  {attachments.map((attachment) => (
328
- <div
329
- key={attachment.id}
330
- className="relative flex h-14 w-14 items-center justify-center overflow-hidden rounded-lg border border-kumo-line/70 bg-kumo-elevated"
331
- >
332
- {attachment.previewUrl ? (
333
- <img
334
- src={attachment.previewUrl}
335
- alt={attachment.filename ?? "Attached file"}
336
- className="h-full w-full object-cover"
337
- />
338
- ) : (
339
- <FileIcon size={22} className="text-kumo-inactive" />
340
- )}
341
- {attachment.status === "uploading" && (
342
- <div className="absolute inset-0 grid place-items-center rounded-lg bg-black/35 text-[10px] text-white">
343
- Uploading
344
- </div>
345
- )}
346
- {attachment.status === "error" && (
347
- <div className="absolute inset-0 grid place-items-center rounded-lg bg-kumo-danger/80 px-1 text-center text-[9px] leading-3 text-white">
348
- Failed
349
- </div>
350
- )}
351
- {attachment.status === "error" && onAttachmentRetry && (
352
- <button
353
- type="button"
354
- aria-label="Retry upload"
355
- onClick={() => onAttachmentRetry(attachment.id)}
356
- className="absolute inset-0 cursor-pointer"
357
- />
358
- )}
359
- {onAttachmentRemove && (
360
- <button
361
- type="button"
362
- aria-label="Remove attachment"
363
- onClick={() => onAttachmentRemove(attachment.id)}
364
- className="absolute right-0.5 top-0.5 flex h-4 w-4 cursor-pointer items-center justify-center rounded-full bg-black/55 text-white hover:bg-black/75 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
365
- >
366
- <X size={10} weight="bold" />
367
- </button>
368
- )}
369
- </div>
471
+ <Fragment key={attachment.id}>
472
+ {renderFileView(renderFile, {
473
+ file: {
474
+ url: attachment.previewUrl,
475
+ filename: attachment.filename,
476
+ mediaType: attachment.mediaType,
477
+ size: attachment.size,
478
+ },
479
+ placement: "composer",
480
+ status: attachment.status,
481
+ progress: attachment.progress,
482
+ onRemove: onAttachmentRemove
483
+ ? () => onAttachmentRemove(attachment.id)
484
+ : undefined,
485
+ onRetry: onAttachmentRetry
486
+ ? () => onAttachmentRetry(attachment.id)
487
+ : undefined,
488
+ })}
489
+ </Fragment>
370
490
  ))}
371
491
  </div>
372
492
  )}
@@ -400,30 +520,6 @@ export function CloudOsChatInput({
400
520
  </span>
401
521
  </DropdownMenu.Item>
402
522
  )}
403
- {commands.length > 0 && (
404
- <>
405
- {commands.map((command) => (
406
- <DropdownMenu.Item
407
- key={command.name}
408
- onClick={() => {
409
- onChange(command.prompt);
410
- requestAnimationFrame(() =>
411
- textareaRef.current?.focus(),
412
- );
413
- }}
414
- 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"
415
- >
416
- <span className="mr-2 inline-flex h-4 w-4 items-center justify-center text-kumo-inactive">
417
- <Terminal size={14} />
418
- </span>
419
- <span className="min-w-0 flex-1 truncate">
420
- /{command.name}
421
- </span>
422
- </DropdownMenu.Item>
423
- ))}
424
- <div className="my-1 border-t border-kumo-line/70" />
425
- </>
426
- )}
427
523
  {onFilesSelected && (
428
524
  <DropdownMenu.Item
429
525
  onClick={() => attachmentInputRef.current?.click()}
@@ -454,45 +550,12 @@ export function CloudOsChatInput({
454
550
 
455
551
  <div className="ml-auto flex min-w-0 flex-shrink items-center gap-1.5">
456
552
  {onModelChange && (
457
- <DropdownMenu>
458
- <DropdownMenu.Trigger
459
- render={
460
- <button
461
- type="button"
462
- 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"
463
- aria-label="Select model"
464
- >
465
- <span className="min-w-0 truncate">{selectedModelLabel}</span>
466
- <CaretDown
467
- size={12}
468
- weight="bold"
469
- className="flex-shrink-0 text-kumo-inactive transition-transform duration-150 ease-out group-data-[state=open]:rotate-180"
470
- />
471
- </button>
472
- }
473
- />
474
- <DropdownMenu.Content
475
- align="end"
476
- className="themed-floating-shadow-lg !min-w-[190px] rounded-2xl border border-kumo-line/70 bg-kumo-base p-1"
477
- >
478
- {models.map((model) => (
479
- <DropdownMenu.Item
480
- key={model.id}
481
- onClick={() => onModelChange(model.id)}
482
- 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"
483
- >
484
- <span className="min-w-0 flex-1 truncate">{model.name}</span>
485
- {selectedModel === model.id && (
486
- <Check
487
- size={12}
488
- weight="bold"
489
- className="ml-3 flex-shrink-0 text-kumo-inactive"
490
- />
491
- )}
492
- </DropdownMenu.Item>
493
- ))}
494
- </DropdownMenu.Content>
495
- </DropdownMenu>
553
+ <CloudOsModelSelect
554
+ models={models}
555
+ selectedModel={selectedModel}
556
+ onModelChange={onModelChange}
557
+ variant="cloud-os"
558
+ />
496
559
  )}
497
560
  {!showStopButton && submitControl}
498
561
  {showStopButton ? (
@@ -507,25 +570,27 @@ export function CloudOsChatInput({
507
570
  </svg>
508
571
  </WorkshopIconButton>
509
572
  ) : (
510
- <WorkshopIconButton
573
+ <button
574
+ type="button"
511
575
  onClick={onSubmit}
512
576
  disabled={!canSend}
513
- tone="primary"
514
- className="!h-8 !w-8 disabled:cursor-not-allowed disabled:opacity-30"
577
+ className="grid size-8 shrink-0 place-items-center rounded-full bg-[var(--primary,var(--color-kumo-brand))] text-[var(--primary-foreground,var(--text-color-kumo-inverse))] shadow-sm transition-colors enabled:hover:bg-[var(--primary-hover,var(--color-kumo-brand-hover))] disabled:cursor-not-allowed disabled:opacity-20"
515
578
  aria-label="Send message"
516
579
  >
517
580
  <svg
518
- width="16"
519
- height="16"
581
+ className="size-4"
520
582
  viewBox="0 0 24 24"
521
583
  fill="none"
522
584
  stroke="currentColor"
523
- strokeWidth="2.5"
585
+ strokeWidth="1.8"
586
+ strokeLinecap="round"
587
+ strokeLinejoin="round"
588
+ aria-hidden="true"
524
589
  >
525
- <line x1="12" y1="19" x2="12" y2="5" />
526
- <polyline points="5 12 12 5 19 12" />
590
+ <path d="m5 12 7-7 7 7" />
591
+ <path d="M12 19V5" />
527
592
  </svg>
528
- </WorkshopIconButton>
593
+ </button>
529
594
  )}
530
595
  </div>
531
596
  </div>
@@ -539,3 +604,24 @@ export function CloudOsChatInput({
539
604
  </div>
540
605
  );
541
606
  }
607
+
608
+ const EMPTY_MESSAGES: readonly ThreadMessage[] = [];
609
+
610
+ export function CloudOsChatInput(props: CloudOsChatInputProps) {
611
+ const runtime = useExternalStoreRuntime<ThreadMessage>({
612
+ messages: EMPTY_MESSAGES,
613
+ onNew: async () => undefined,
614
+ });
615
+
616
+ useEffect(() => {
617
+ runtime.thread.composer.setText(props.value);
618
+ }, [props.value, runtime]);
619
+
620
+ return (
621
+ <AssistantRuntimeProvider runtime={runtime}>
622
+ <ComposerPrimitive.Unstable_TriggerPopoverRoot>
623
+ <CloudOsChatInputContent {...props} runtime={runtime} />
624
+ </ComposerPrimitive.Unstable_TriggerPopoverRoot>
625
+ </AssistantRuntimeProvider>
626
+ );
627
+ }
@@ -0,0 +1,105 @@
1
+ import { CaretDown } from "@phosphor-icons/react";
2
+ import { cn } from "../internal/cn";
3
+ import { DropdownMenu } from "../primitives/dropdown-menu";
4
+
5
+ const selectedIcon = new URL("../assets/model-selected.svg", import.meta.url).href;
6
+
7
+ export interface CloudOsModelOption {
8
+ id: string;
9
+ name: string;
10
+ disabled?: boolean;
11
+ disabledLabel?: string;
12
+ }
13
+
14
+ export interface CloudOsModelSelectProps {
15
+ models: readonly CloudOsModelOption[];
16
+ selectedModel?: string | null;
17
+ onModelChange: (modelId: string) => void;
18
+ disabled?: boolean;
19
+ variant?: "default" | "cloud-os";
20
+ }
21
+
22
+ export function CloudOsModelSelect({
23
+ models,
24
+ selectedModel = null,
25
+ onModelChange,
26
+ disabled = false,
27
+ variant = "default",
28
+ }: CloudOsModelSelectProps) {
29
+ if (models.length === 0) return null;
30
+
31
+ const selectedLabel =
32
+ models.find((model) => model.id === selectedModel)?.name ?? "Model";
33
+ const cloudOs = variant === "cloud-os";
34
+
35
+ return (
36
+ <DropdownMenu>
37
+ <DropdownMenu.Trigger
38
+ render={
39
+ <button
40
+ type="button"
41
+ disabled={disabled}
42
+ aria-label="Select model"
43
+ className={cn(
44
+ "group inline-flex h-8 min-w-0 items-center gap-1.5 rounded-lg transition-[background-color,color,transform] duration-150 ease-out focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-40",
45
+ cloudOs
46
+ ? "max-w-[180px] cursor-pointer px-2 text-[13px] leading-5 tracking-[-0.25px] text-kumo-subtle hover:bg-kumo-tint hover:text-kumo-default focus-visible:bg-kumo-tint focus-visible:text-kumo-default active:scale-[0.97] data-[state=open]:bg-kumo-tint data-[state=open]:text-kumo-default"
47
+ : "max-w-[min(11rem,38vw)] px-2.5 text-xs leading-none text-black/55 hover:bg-black/5 hover:text-black focus-visible:ring-2 focus-visible:ring-black/15",
48
+ )}
49
+ >
50
+ <span className="min-w-0 truncate">{selectedLabel}</span>
51
+ <CaretDown
52
+ size={12}
53
+ weight="bold"
54
+ aria-hidden="true"
55
+ className={cn(
56
+ "shrink-0 transition-transform duration-150 ease-out group-data-[state=open]:rotate-180",
57
+ cloudOs ? "text-kumo-inactive" : "text-black/55",
58
+ )}
59
+ />
60
+ </button>
61
+ }
62
+ />
63
+ <DropdownMenu.Content
64
+ align="end"
65
+ side="top"
66
+ sideOffset={8}
67
+ data-slot="model-select-content"
68
+ className="cos-model-select min-w-[12.5rem] rounded-lg border p-2"
69
+ >
70
+ {models.map((model) => (
71
+ <DropdownMenu.Item
72
+ key={model.id}
73
+ disabled={model.disabled}
74
+ onClick={model.disabled ? undefined : () => onModelChange(model.id)}
75
+ data-slot="model-select-item"
76
+ className="cos-model-select__item gap-2 rounded px-2 py-2.5 text-[14px] leading-[1.4] font-normal transition-colors"
77
+ >
78
+ <span
79
+ className="cos-model-select__name min-w-0 flex-1 truncate"
80
+ data-muted={selectedModel === model.id || model.disabled ? "" : undefined}
81
+ >
82
+ {model.name}
83
+ </span>
84
+ {model.disabledLabel ? (
85
+ <span
86
+ className="cos-model-select__plan min-w-0 flex-1 truncate text-right"
87
+ data-disabled={model.disabled ? "" : undefined}
88
+ >
89
+ {model.disabledLabel}
90
+ </span>
91
+ ) : selectedModel === model.id ? (
92
+ <img
93
+ src={selectedIcon}
94
+ alt=""
95
+ aria-hidden="true"
96
+ data-slot="model-selected-icon"
97
+ className="size-4 shrink-0"
98
+ />
99
+ ) : null}
100
+ </DropdownMenu.Item>
101
+ ))}
102
+ </DropdownMenu.Content>
103
+ </DropdownMenu>
104
+ );
105
+ }