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

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