@springbrand/message-panel 0.1.3-alpha.5 → 0.1.3-alpha.6

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.
@@ -3,10 +3,10 @@ import { useCloudOsMode } from "../internal/theme-context";
3
3
  import type { CloudOsActivity } from "./transcript-model";
4
4
 
5
5
  /**
6
- * 回合还在跑、但屏幕上没有任何东西在动时的活动指示。
6
+ * 回合存活期间持续显示的全局活动指示。
7
7
  *
8
8
  * 用 thinking-orbs 的 canvas 球而不是一行 shimmer 文字:动画本身就是「还活着」
9
- * 的信号,文案只负责说清在干什么(`deriveTurnActivity` 给出的四种之一)。
9
+ * 的信号,文案只负责说清在干什么(`deriveTurnActivity` 给出的九种之一)。
10
10
  *
11
11
  * 和 `src/status.tsx` 的 `ThinkingIndicator` 是同一个上游球、同一套 role/aria
12
12
  * 形状,但**不共用实现** —— cloud-os 不 import `../src`(见 cloud-os/README.md,
@@ -5,7 +5,6 @@ import {
5
5
  CaretRight,
6
6
  Check,
7
7
  Copy,
8
- File as FileIcon,
9
8
  Terminal,
10
9
  } from "@phosphor-icons/react";
11
10
  import {
@@ -37,6 +36,10 @@ import {
37
36
  import { ThinkingTraceRow, ToolGroupRow } from "./tool-rows";
38
37
  import { CloudOsActivityIndicator } from "./activity-indicator";
39
38
  import { CapabilityChip } from "../capability-chip";
39
+ import {
40
+ renderFileView,
41
+ type FileRenderer,
42
+ } from "../file-view";
40
43
  import {
41
44
  buildCloudOsEntries,
42
45
  deriveTurnActivity,
@@ -51,9 +54,12 @@ export type CloudOsChatStatus = "ready" | "submitted" | "streaming" | "error";
51
54
  export interface CloudOsChatMessagesProps {
52
55
  messages: readonly UIMessage[];
53
56
  status: CloudOsChatStatus;
57
+ /** 权威 Turn 生命周期;提供时替代本地 status 推断。 */
58
+ turnActive?: boolean;
54
59
  isHydrating?: boolean;
55
60
  isRecovering?: boolean;
56
61
  recoveryStatusLabel?: string;
62
+ awaitingApproval?: boolean;
57
63
  /** 有 pending steer 时,活动占位说明「正在等当前步骤收尾」。 */
58
64
  hasPendingSteer?: boolean;
59
65
  /** 首帧就带着待发消息:此时不显示空会话欢迎语。 */
@@ -75,6 +81,8 @@ export interface CloudOsChatMessagesProps {
75
81
  input: Record<string, unknown>;
76
82
  disabled: boolean;
77
83
  }) => ReactNode;
84
+ /** 接管消息里的文件视图;未提供时使用包内 FileView。 */
85
+ renderFile?: FileRenderer;
78
86
  /** 把消息里的 `sandbox:/workspace/...` 之类地址翻译成可访问 URL。 */
79
87
  resolveUrl?: CloudOsUrlResolver;
80
88
  /** 消息区宽度是否收敛到 920px 居中(GadgetEditor 里叫 constrainChatWidth)。 */
@@ -119,11 +127,13 @@ function UserBubble({
119
127
  entry,
120
128
  onCopy,
121
129
  copied,
130
+ renderFile,
122
131
  resolveUrl,
123
132
  }: {
124
133
  entry: Extract<CloudOsEntry, { type: "user" }>;
125
134
  onCopy: (text: string) => void;
126
135
  copied: boolean;
136
+ renderFile?: FileRenderer;
127
137
  resolveUrl?: CloudOsUrlResolver;
128
138
  }) {
129
139
  return (
@@ -141,24 +151,14 @@ function UserBubble({
141
151
  )}
142
152
  {entry.attachments.length > 0 && (
143
153
  <div className="mb-1.5 flex max-w-[min(680px,78%)] flex-wrap justify-end gap-2">
144
- {entry.attachments.map((attachment, index) =>
145
- attachment.mediaType?.startsWith("image/") ? (
146
- <img
147
- key={`${attachment.url}:${index}`}
148
- src={attachment.url}
149
- alt={attachment.filename ?? "Attachment"}
150
- className="themed-thumbnail-shadow max-h-52 max-w-64 rounded-xl border border-kumo-line object-cover"
151
- />
152
- ) : (
153
- <span
154
- key={`${attachment.url}:${index}`}
155
- 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"
156
- >
157
- <FileIcon size={13} className="text-kumo-inactive" />
158
- {attachment.filename ?? "Attachment"}
159
- </span>
160
- ),
161
- )}
154
+ {entry.attachments.map((attachment, index) => (
155
+ <Fragment key={`${attachment.url}:${index}`}>
156
+ {renderFileView(renderFile, {
157
+ file: attachment,
158
+ placement: "user-message",
159
+ })}
160
+ </Fragment>
161
+ ))}
162
162
  </div>
163
163
  )}
164
164
  {entry.text && (
@@ -196,9 +196,11 @@ function UserBubble({
196
196
  export function CloudOsChatMessages({
197
197
  messages,
198
198
  status,
199
+ turnActive,
199
200
  isHydrating = false,
200
201
  isRecovering = false,
201
202
  recoveryStatusLabel,
203
+ awaitingApproval = false,
202
204
  hasPendingSteer = false,
203
205
  startedWithPending = false,
204
206
  error,
@@ -206,6 +208,7 @@ export function CloudOsChatMessages({
206
208
  approvalIdOf,
207
209
  approvalsDisabled = false,
208
210
  renderApproval,
211
+ renderFile,
209
212
  resolveUrl,
210
213
  constrainWidth = true,
211
214
  emptyTitle = "What are we working on?",
@@ -226,7 +229,8 @@ export function CloudOsChatMessages({
226
229
  ReadonlySet<string>
227
230
  >(() => new Set());
228
231
  const { copied, copy } = useCopyAction();
229
- const isActive = status === "submitted" || status === "streaming";
232
+ const isActive =
233
+ turnActive ?? (status === "submitted" || status === "streaming");
230
234
 
231
235
  const entries = useMemo(
232
236
  () =>
@@ -279,10 +283,13 @@ export function CloudOsChatMessages({
279
283
  element.scrollTo({ top: element.scrollHeight });
280
284
  }, [isRecovering, messages, status, showScrollButton]);
281
285
 
282
- // 判据是「尾巴有没有活口」,不是原文件的「本回合有没有出过内容」——
283
- // 后者会让「答完 ask_user 之后到模型下一个 chunk」这段完全没有指示,
284
- // 而卡片此时写着 Submitted,读起来就是卡死了。见 deriveTurnActivity。
285
- const activity = deriveTurnActivity(entries, { isActive, hasPendingSteer });
286
+ const activity = deriveTurnActivity(entries, {
287
+ isActive,
288
+ isRecovering,
289
+ recoveryStatusLabel,
290
+ awaitingApproval,
291
+ hasPendingSteer,
292
+ });
286
293
  const copyText = (text: string) => {
287
294
  copy(text);
288
295
  onCopy?.(text);
@@ -293,7 +300,7 @@ export function CloudOsChatMessages({
293
300
  className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-kumo-base"
294
301
  data-cloud-os-transcript=""
295
302
  data-status={status}
296
- aria-busy={isActive || isHydrating}
303
+ aria-busy={isActive || isHydrating || isRecovering}
297
304
  >
298
305
  <div
299
306
  ref={scrollRef}
@@ -324,6 +331,7 @@ export function CloudOsChatMessages({
324
331
  entry={entry}
325
332
  onCopy={copyText}
326
333
  copied={copied === entry.text}
334
+ renderFile={renderFile}
327
335
  resolveUrl={resolveUrl}
328
336
  />
329
337
  </div>
@@ -516,13 +524,29 @@ export function CloudOsChatMessages({
516
524
  );
517
525
  case "image":
518
526
  return (
519
- <figure key={block.key} className="my-1">
520
- <img
521
- src={resolveUrl?.(block.src) ?? block.src}
522
- alt={block.alt}
523
- className="max-h-[28rem] max-w-full rounded-xl border border-kumo-line object-contain"
524
- />
525
- </figure>
527
+ <Fragment key={block.key}>
528
+ {renderFileView(renderFile, {
529
+ file: {
530
+ url: resolveUrl?.(block.src) ?? block.src,
531
+ filename: block.alt,
532
+ mediaType: block.mediaType ?? "image/*",
533
+ },
534
+ placement: "assistant-message",
535
+ })}
536
+ </Fragment>
537
+ );
538
+ case "file":
539
+ return (
540
+ <Fragment key={block.key}>
541
+ {renderFileView(renderFile, {
542
+ file: {
543
+ ...block.file,
544
+ url:
545
+ resolveUrl?.(block.file.url) ?? block.file.url,
546
+ },
547
+ placement: "assistant-message",
548
+ })}
549
+ </Fragment>
526
550
  );
527
551
  case "error":
528
552
  return (
@@ -583,14 +607,6 @@ export function CloudOsChatMessages({
583
607
  );
584
608
  })}
585
609
 
586
- {isRecovering && (
587
- <div className="mt-4 flex max-w-[860px] items-center gap-3 px-1.5 py-1 text-[14px] leading-5 tracking-[-0.25px]">
588
- <span className="cos-thinking-shimmer">
589
- {recoveryStatusLabel ?? "Reconnecting…"}
590
- </span>
591
- </div>
592
- )}
593
-
594
610
  {activity && (
595
611
  <div className="mt-5 inline-flex px-1.5 py-1 text-[14px] leading-5 tracking-[-0.25px]">
596
612
  <CloudOsActivityIndicator {...activity} />
@@ -3,8 +3,9 @@ import {
3
3
  CaretRight,
4
4
  Check,
5
5
  CircleNotch,
6
+ Clock,
7
+ Key,
6
8
  ListChecks,
7
- ShieldCheck,
8
9
  UsersThree,
9
10
  WarningCircle,
10
11
  } from "@phosphor-icons/react";
@@ -18,7 +19,12 @@ import type {
18
19
  PlanStep,
19
20
  SubAgentView,
20
21
  } from "./transcript-model";
21
- import { askUserOutcome, type CloudOsToolCall } from "./tool-presentation";
22
+ import {
23
+ askUserOutcome,
24
+ humanizeToolName,
25
+ safeStringify,
26
+ type CloudOsToolCall,
27
+ } from "./tool-presentation";
22
28
 
23
29
  /**
24
30
  * UIMessage 协议里存在、gadgets 那套没有的几类 part —— 计划快照、原位提问、
@@ -357,6 +363,66 @@ function scheduleIdOf(output: unknown): string {
357
363
  return typeof id === "string" ? id : "";
358
364
  }
359
365
 
366
+ export function ScheduleConfirmation({
367
+ input,
368
+ disabled = false,
369
+ onCancel,
370
+ onConfirm,
371
+ }: {
372
+ input: Record<string, unknown>;
373
+ disabled?: boolean;
374
+ onCancel: () => void;
375
+ onConfirm: () => void;
376
+ }) {
377
+ const name = String(input.label ?? input.title ?? "Scheduled task");
378
+ const prompt = typeof input.prompt === "string" && input.prompt.trim()
379
+ ? input.prompt.trim()
380
+ : "Run the scheduled task";
381
+
382
+ return (
383
+ <section
384
+ data-slot="schedule-card"
385
+ aria-label={`确认定时任务:${name}`}
386
+ className="themed-surface-inset flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-kumo-line/70 bg-kumo-elevated p-4 text-kumo-default shadow-[0_1px_2px_rgba(0,0,0,0.04),0_12px_32px_-16px_rgba(0,0,0,0.12)]"
387
+ >
388
+ <div className="flex items-center gap-2.5">
389
+ <span
390
+ className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-kumo-fill/70 text-kumo-subtle"
391
+ aria-hidden="true"
392
+ >
393
+ <Clock size={14} />
394
+ </span>
395
+ <div className="flex min-w-0 flex-1 flex-col">
396
+ <span className="truncate text-[13.5px] leading-[18px] font-medium">
397
+ {name}
398
+ </span>
399
+ <span className="truncate font-mono text-[11px] leading-4 tracking-tight text-kumo-inactive">
400
+ {scheduleCadence(input)}
401
+ </span>
402
+ </div>
403
+ </div>
404
+
405
+ <div className="flex items-start gap-2 rounded-xl bg-kumo-fill/50 px-3 py-2">
406
+ <span className="shrink-0 font-mono text-[11px] leading-[18px] tracking-tight text-kumo-inactive">
407
+ task
408
+ </span>
409
+ <span className="min-w-0 flex-1 text-[13px] leading-[18px] text-kumo-subtle">
410
+ {prompt}
411
+ </span>
412
+ </div>
413
+
414
+ <div className="flex items-center justify-end gap-2">
415
+ <WorkshopButton disabled={disabled} onClick={onCancel}>
416
+ 取消
417
+ </WorkshopButton>
418
+ <WorkshopButton tone="primary" disabled={disabled} onClick={onConfirm}>
419
+ {disabled ? "正在确认…" : "确认安排"}
420
+ </WorkshopButton>
421
+ </div>
422
+ </section>
423
+ );
424
+ }
425
+
360
426
  export function ScheduleBlock({
361
427
  call,
362
428
  onOpen,
@@ -365,6 +431,8 @@ export function ScheduleBlock({
365
431
  /** 有 id 且宿主给了回调时,整张卡变成跳到「已安排」详情的按钮。 */
366
432
  onOpen?: (scheduleId: string) => void;
367
433
  }) {
434
+ // 待确认时由宿主把持久化审批渲染在输入框上方;这里不重复画第二张卡。
435
+ if (call.awaitingApproval) return null;
368
436
  const state = call.failed ? "failed" : call.running ? "running" : "scheduled";
369
437
  const scheduleId = state === "scheduled" ? scheduleIdOf(call.output) : "";
370
438
  const clickable = Boolean(scheduleId && onOpen);
@@ -432,10 +500,112 @@ export function ScheduleBlock({
432
500
  );
433
501
  }
434
502
 
435
- // ── 原位授权(照搬 renderActionCard 的 blocking callout)────────────────────
503
+ // ── 原位授权 ──────────────────────────────────────────────
436
504
 
437
505
  export type ApprovalDecision = "deny" | "allow_once" | "allow_level";
438
506
 
507
+ export function PermissionGrant({
508
+ capability,
509
+ requester,
510
+ reach,
511
+ disabled = false,
512
+ onGrant,
513
+ "aria-label": ariaLabel = `${capability} permission request`,
514
+ }: {
515
+ capability: string;
516
+ requester: string;
517
+ reach: readonly string[];
518
+ disabled?: boolean;
519
+ onGrant: (decision: ApprovalDecision) => void;
520
+ "aria-label"?: string;
521
+ }) {
522
+ const actionClassName =
523
+ "inline-flex h-8 items-center rounded-full px-3 text-[12px] leading-none font-medium tracking-[-0.25px] transition-[background-color,color,opacity,transform] duration-150 ease-out active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-ring/40 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100";
524
+
525
+ return (
526
+ <section
527
+ aria-label={ariaLabel}
528
+ className="themed-surface-inset w-full max-w-sm rounded-[20px] border border-kumo-line bg-kumo-elevated p-4 text-kumo-default shadow-sm"
529
+ >
530
+ <div className="flex items-center gap-2.5">
531
+ <span
532
+ className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-kumo-fill/70 text-kumo-subtle"
533
+ aria-hidden="true"
534
+ >
535
+ <Key size={14} weight="bold" />
536
+ </span>
537
+ <div className="flex min-w-0 flex-1 flex-col">
538
+ <span className="truncate text-[13.5px] leading-[18px] font-medium">
539
+ {capability}
540
+ </span>
541
+ <span className="truncate text-[12px] leading-4 text-kumo-inactive">
542
+ requested by {requester}
543
+ </span>
544
+ </div>
545
+ </div>
546
+
547
+ <div className="mt-3.5 flex max-h-[200px] flex-col gap-1 overflow-y-auto pr-1">
548
+ <span className="font-mono text-[10px] leading-4 text-kumo-inactive">
549
+ this grants
550
+ </span>
551
+ {reach.map((item, index) => (
552
+ <span
553
+ key={`${index}:${item}`}
554
+ className="flex items-baseline gap-2 text-[12px] leading-[17px] text-kumo-subtle"
555
+ >
556
+ <span
557
+ className="size-1 shrink-0 rounded-full bg-kumo-interact"
558
+ aria-hidden="true"
559
+ />
560
+ <span className="min-w-0 break-words whitespace-pre-wrap">{item}</span>
561
+ </span>
562
+ ))}
563
+ </div>
564
+
565
+ <div className="mt-3.5 flex min-h-8 flex-wrap items-center justify-end gap-1">
566
+ <button
567
+ type="button"
568
+ disabled={disabled}
569
+ onClick={() => onGrant("deny")}
570
+ className={`${actionClassName} text-kumo-subtle enabled:hover:bg-kumo-fill enabled:hover:text-kumo-default`}
571
+ >
572
+ Deny
573
+ </button>
574
+ <button
575
+ type="button"
576
+ disabled={disabled}
577
+ onClick={() => onGrant("allow_once")}
578
+ className={`${actionClassName} text-kumo-subtle enabled:hover:bg-kumo-fill enabled:hover:text-kumo-default`}
579
+ >
580
+ Allow once
581
+ </button>
582
+ <Tooltip
583
+ content="Allow every action at this execution level, without future prompts."
584
+ asChild
585
+ >
586
+ <button
587
+ type="button"
588
+ disabled={disabled}
589
+ onClick={() => onGrant("allow_level")}
590
+ className={`${actionClassName} bg-kumo-contrast text-kumo-inverse enabled:hover:bg-kumo-strong`}
591
+ >
592
+ Allow level
593
+ </button>
594
+ </Tooltip>
595
+ </div>
596
+ </section>
597
+ );
598
+ }
599
+
600
+ function approvalReach(call: CloudOsToolCall): string[] {
601
+ const entries = Object.entries(call.input);
602
+ return entries.length > 0
603
+ ? entries.map(
604
+ ([name, value]) => `${humanizeToolName(name)}: ${safeStringify(value)}`,
605
+ )
606
+ : ["Run this tool without arguments"];
607
+ }
608
+
439
609
  export function ApprovalBlock({
440
610
  call,
441
611
  approvalId,
@@ -447,70 +617,15 @@ export function ApprovalBlock({
447
617
  disabled?: boolean;
448
618
  onApprove: (approvalId: string, decision: ApprovalDecision) => void;
449
619
  }) {
450
- const title = `${call.toolName} needs approval`;
451
- const description =
452
- Object.keys(call.input).length > 0
453
- ? "```json\n" + JSON.stringify(call.input, null, 2) + "\n```"
454
- : "This action pauses the turn until you decide.";
455
-
456
620
  return (
457
621
  <div className="group/work max-w-[860px] text-[14px] leading-5 tracking-[-0.25px] text-kumo-subtle">
458
- {/* 原文件的 blocking callout 是「图标 + 正文 + 右侧动作」一行到底。聊天栏默认
459
- 只有 420px 宽,一行放不下三段,正文会被挤成 0 宽 —— 所以外层允许换行,
460
- 动作组在挤不下时整体落到下一行右对齐。 */}
461
- <div className="rounded-2xl border border-kumo-brand/40 bg-kumo-brand/10 px-4 py-3">
462
- <div className="flex flex-wrap items-start gap-3">
463
- <span
464
- className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-kumo-tint text-kumo-brand"
465
- aria-hidden="true"
466
- >
467
- <ShieldCheck size={20} weight="fill" />
468
- </span>
469
- <div className="min-w-[12rem] flex-1">
470
- <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
471
- <span className="min-w-0 truncate font-medium text-kumo-default">
472
- {title}
473
- </span>
474
- </div>
475
- <div className="cos-chat-panel cos-markdown mt-1 max-h-[200px] overflow-y-auto pr-1 text-[13px] leading-[18px] text-kumo-subtle">
476
- <MarkdownMessage message={description} />
477
- </div>
478
- </div>
479
- <div className="ml-auto flex flex-wrap items-center justify-end gap-1 self-center">
480
- <Tooltip content="Reject this action and let the agent continue without it." asChild>
481
- <WorkshopButton
482
- tone="secondary"
483
- disabled={disabled}
484
- onClick={() => onApprove(approvalId, "deny")}
485
- className="!h-8 !rounded-lg text-[12px]"
486
- >
487
- Deny
488
- </WorkshopButton>
489
- </Tooltip>
490
- <Tooltip content="Allow just this call." asChild>
491
- <WorkshopButton
492
- tone="secondary"
493
- disabled={disabled}
494
- onClick={() => onApprove(approvalId, "allow_once")}
495
- className="!h-8 !rounded-lg text-[12px]"
496
- >
497
- Allow once
498
- </WorkshopButton>
499
- </Tooltip>
500
- <Tooltip content="Allow every action at this execution level, without future prompts." asChild>
501
- <WorkshopButton
502
- tone="primary"
503
- disabled={disabled}
504
- onClick={() => onApprove(approvalId, "allow_level")}
505
- className="!h-8 gap-1 !rounded-lg text-[12px]"
506
- >
507
- <Check size={11} weight="bold" />
508
- Allow level
509
- </WorkshopButton>
510
- </Tooltip>
511
- </div>
512
- </div>
513
- </div>
622
+ <PermissionGrant
623
+ capability={humanizeToolName(call.toolName)}
624
+ requester="the agent"
625
+ reach={approvalReach(call)}
626
+ disabled={disabled}
627
+ onGrant={(decision) => onApprove(approvalId, decision)}
628
+ />
514
629
  </div>
515
630
  );
516
631
  }
@@ -145,7 +145,7 @@ function truncate(value: string, maxLength: number): string {
145
145
  return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}…`;
146
146
  }
147
147
 
148
- function humanize(name: string): string {
148
+ export function humanizeToolName(name: string): string {
149
149
  const spaced = name
150
150
  .replace(/^mcp__/, "")
151
151
  .replace(/__/g, " ")
@@ -343,7 +343,7 @@ export function getToolCallSummary(call: CloudOsToolCall): {
343
343
  case "agent": {
344
344
  const name =
345
345
  stringField(input, "subagentName", "agent", "name", "agentType") ||
346
- humanize(toolName);
346
+ humanizeToolName(toolName);
347
347
  return { verb: running ? "Running" : "Ran", target: name };
348
348
  }
349
349
  case "skill": {
@@ -378,13 +378,15 @@ export function getToolCallSummary(call: CloudOsToolCall): {
378
378
  const [, server = "", ...rest] = toolName.split("__");
379
379
  return {
380
380
  verb: running ? "Calling" : "Called",
381
- target: rest.length ? `${humanize(rest.join(" "))} · ${humanize(server)}` : undefined,
381
+ target: rest.length
382
+ ? `${humanizeToolName(rest.join(" "))} · ${humanizeToolName(server)}`
383
+ : undefined,
382
384
  };
383
385
  }
384
386
  case "generic":
385
387
  break;
386
388
  }
387
- const displayName = humanize(toolName);
389
+ const displayName = humanizeToolName(toolName);
388
390
  return { verb: running ? `Using ${displayName}` : `Used ${displayName}` };
389
391
  }
390
392
 
@@ -435,7 +437,7 @@ export function describeToolCallCount(
435
437
  case "generic":
436
438
  break;
437
439
  }
438
- const displayName = humanize(toolName);
440
+ const displayName = humanizeToolName(toolName);
439
441
  return count === 1 ? `Used ${displayName}` : `Used ${displayName} ${formatTimes(count)}`;
440
442
  }
441
443
 
@@ -5,6 +5,7 @@ import {
5
5
  buildToolCallGroups,
6
6
  toCloudOsToolCall,
7
7
  type CloudOsToolCall,
8
+ type CloudOsToolKind,
8
9
  type ToolCallGroup,
9
10
  } from "./tool-presentation";
10
11
 
@@ -65,7 +66,14 @@ export type AssistantBlock =
65
66
  | { kind: "approval"; key: string; call: CloudOsToolCall; approvalId: string }
66
67
  | { kind: "parallel"; key: string; label: string; tools: ParallelTool[] }
67
68
  | { kind: "subagents"; key: string; agents: SubAgentView[] }
68
- | { kind: "image"; key: string; src: string; alt: string }
69
+ | {
70
+ kind: "image";
71
+ key: string;
72
+ src: string;
73
+ alt: string;
74
+ mediaType?: string;
75
+ }
76
+ | { kind: "file"; key: string; file: CloudOsAttachment }
69
77
  | { kind: "error"; key: string; title: string; body?: string };
70
78
 
71
79
  export interface ParallelTool {
@@ -376,15 +384,32 @@ function assistantBlocks(
376
384
  const data = dataOf(part);
377
385
  const src = String(data.src ?? data.url ?? record.url ?? "");
378
386
  const mediaType = String(record.mediaType ?? data.mediaType ?? "");
379
- if (src && (dataKind === "image" || mediaType.startsWith("image/"))) {
380
- flush();
387
+ if (!src) return;
388
+
389
+ flush();
390
+ const filename = String(
391
+ data.alt ?? data.filename ?? record.filename ?? "Attachment",
392
+ );
393
+ if (dataKind === "image" || mediaType.startsWith("image/")) {
381
394
  blocks.push({
382
395
  kind: "image",
383
396
  key,
384
397
  src,
385
- alt: String(data.alt ?? data.filename ?? record.filename ?? "Image"),
398
+ alt: filename,
399
+ mediaType: mediaType || undefined,
386
400
  });
401
+ return;
387
402
  }
403
+
404
+ blocks.push({
405
+ kind: "file",
406
+ key,
407
+ file: {
408
+ url: src,
409
+ filename,
410
+ mediaType: mediaType || undefined,
411
+ },
412
+ });
388
413
  return;
389
414
  }
390
415
 
@@ -596,75 +621,124 @@ export interface CloudOsActivity {
596
621
  label: string;
597
622
  }
598
623
 
599
- /**
600
- * 尾巴是否**自己会说话** —— 有它在动,就不该再挂一个活动指示器。
601
- *
602
- * 正在流的正文/思考、还在跑的工具组或计划,以及一张**还等着你作答**的
603
- * `ask_user` 卡都算:前三者自身就有动效,最后一个等的是人不是模型,这时候
604
- * 显示「Thinking」是在撒谎。
605
- */
606
- function isLiveBlock(block: AssistantBlock): boolean {
624
+ const SEARCHING_TOOL_KINDS: ReadonlySet<CloudOsToolKind> = new Set([
625
+ "read",
626
+ "glob",
627
+ "grep",
628
+ "list",
629
+ "web-search",
630
+ "web-fetch",
631
+ ]);
632
+ const SHAPING_TOOL_KINDS: ReadonlySet<CloudOsToolKind> = new Set([
633
+ "write",
634
+ "edit",
635
+ "tasks",
636
+ "create-agent",
637
+ "publish-extension",
638
+ ]);
639
+
640
+ function activityForTool(call: CloudOsToolCall | undefined): CloudOsActivity {
641
+ if (!call) return { state: "working", label: "Working…" };
642
+ const name = call.toolName.replaceAll("-", "_").toLowerCase();
643
+ if (call.kind === "agent") {
644
+ return {
645
+ state: "weaving",
646
+ label: call.running ? "Coordinating agents…" : "Combining results…",
647
+ };
648
+ }
649
+ if (SEARCHING_TOOL_KINDS.has(call.kind)) {
650
+ return {
651
+ state: "searching",
652
+ label: call.running ? "Searching…" : "Reading results…",
653
+ };
654
+ }
655
+ if (SHAPING_TOOL_KINDS.has(call.kind)) {
656
+ return { state: "shaping", label: "Shaping result…" };
657
+ }
658
+ if (call.kind === "skill" || name === "activate_skill") {
659
+ return { state: "working", label: "Loading skill…" };
660
+ }
661
+ if (name === "run_skill_script") {
662
+ return { state: "working", label: "Running skill…" };
663
+ }
664
+ return { state: "working", label: "Working…" };
665
+ }
666
+
667
+ function activityForTail(block: AssistantBlock | undefined): CloudOsActivity {
668
+ if (!block) return { state: "solving", label: "Thinking…" };
607
669
  switch (block.kind) {
608
- case "text":
609
670
  case "reasoning":
610
- return true;
671
+ return { state: "solving", label: "Reasoning…" };
672
+ case "text":
673
+ case "suggestions":
674
+ return { state: "composing", label: "Composing answer…" };
611
675
  case "toolGroup":
612
- return block.group.hasRunning;
676
+ return activityForTool(block.group.calls.at(-1));
613
677
  case "plan":
614
- return block.running;
678
+ case "image":
679
+ case "file":
680
+ return { state: "shaping", label: "Shaping result…" };
615
681
  case "askUser":
616
- return block.pending;
617
- // approval 块只在 awaitingApproval 时才建出来,所以它在场就是在等人。
682
+ return block.pending
683
+ ? { state: "listening", label: "Waiting for your answer…" }
684
+ : { state: "solving", label: "Picking up your answer…" };
618
685
  case "approval":
619
- return true;
686
+ return { state: "breathing", label: "Waiting for approval…" };
687
+ case "schedule":
688
+ return block.call.awaitingApproval
689
+ ? { state: "breathing", label: "Waiting for approval…" }
690
+ : { state: "working", label: "Working…" };
620
691
  case "parallel":
621
- return block.tools.some((tool) => tool.status === "running");
692
+ return {
693
+ state: "weaving",
694
+ label: block.tools.some((tool) => tool.status === "running")
695
+ ? "Coordinating tasks…"
696
+ : "Combining results…",
697
+ };
622
698
  case "subagents":
623
- return block.agents.some((agent) => agent.status === "running");
699
+ return {
700
+ state: "weaving",
701
+ label: block.agents.some((agent) => agent.status === "running")
702
+ ? "Coordinating agents…"
703
+ : "Combining results…",
704
+ };
624
705
  default:
625
- return false;
626
- }
627
- }
628
-
629
- // 已结算的尾巴 → 此刻真正在发生的事。措辞刻意只说得出口的那部分:
630
- // 工具跑完了,模型在读它的输出;卡答完了,模型在接你的答案。
631
- function activityForTail(block: AssistantBlock | undefined): CloudOsActivity {
632
- if (!block) return { state: "solving", label: "Thinking…" };
633
- if (block.kind === "askUser") {
634
- return { state: "solving", label: "Picking up your answer…" };
635
- }
636
- if (block.kind === "toolGroup") {
637
- const kind = block.group.calls.at(-1)?.kind;
638
- return kind !== undefined &&
639
- ["web-search", "web-fetch", "grep", "glob", "list", "read"].includes(
640
- kind,
641
- )
642
- ? { state: "searching", label: "Reading results…" }
643
- : { state: "working", label: "Working…" };
706
+ return { state: "working", label: "Working…" };
644
707
  }
645
- return { state: "working", label: "Working…" };
646
708
  }
647
709
 
648
710
  /**
649
- * 本回合此刻该显示什么活动;`null` = 尾巴自己会说话,别再叠一个。
650
- *
651
- * @remarks
652
- * 判据是「**尾巴有没有活口**」,不是「本回合有没有出过内容」。后者曾经是这里的
653
- * 写法,于是答完 `ask_user`(卡片变成 Submitted,读起来就是"结束了")到模型下一
654
- * 个 chunk 之间整屏没有任何在跑的迹象 —— 而那段恰好是最长的一次 TTFT,看起来
655
- * 就是卡死。凡是尾巴为**已结算卡片**的断档都是同一个洞,这里一并补上。
711
+ * 回合活着时始终返回一个 Orb 状态;只有回合终止时才返回 `null`。
656
712
  */
657
713
  export function deriveTurnActivity(
658
714
  entries: readonly CloudOsEntry[],
659
- options: { isActive: boolean; hasPendingSteer?: boolean },
715
+ options: {
716
+ isActive: boolean;
717
+ isRecovering?: boolean;
718
+ recoveryStatusLabel?: string;
719
+ awaitingApproval?: boolean;
720
+ hasPendingSteer?: boolean;
721
+ },
660
722
  ): CloudOsActivity | null {
723
+ if (options.isRecovering) {
724
+ return {
725
+ state: "connecting",
726
+ label: options.recoveryStatusLabel ?? "Reconnecting…",
727
+ };
728
+ }
661
729
  if (!options.isActive) return null;
730
+ if (options.awaitingApproval) {
731
+ return { state: "breathing", label: "Waiting for approval…" };
732
+ }
733
+ if (options.hasPendingSteer) {
734
+ return {
735
+ state: "breathing",
736
+ label: "Waiting for the current step to finish…",
737
+ };
738
+ }
662
739
  const tail = entries.at(-1) ?? null;
663
740
  const block = tail?.type === "assistant" ? tail.blocks.at(-1) : undefined;
664
- if (block && isLiveBlock(block)) return null;
665
- return options.hasPendingSteer
666
- ? { state: "solving", label: "Waiting for the current step to finish…" }
667
- : activityForTail(block);
741
+ return activityForTail(block);
668
742
  }
669
743
 
670
744
  // ── 行间距节奏(照搬原 rhythmTopClass)──────────────────────────────────────
@@ -6,7 +6,6 @@ import {
6
6
  Plug,
7
7
  Plus,
8
8
  Terminal,
9
- X,
10
9
  } from "@phosphor-icons/react";
11
10
  import {
12
11
  AssistantRuntimeProvider,
@@ -20,6 +19,7 @@ import {
20
19
  } from "@assistant-ui/react";
21
20
  import { ComposerTriggerPopover } from "@springbrand/message-panel/composer";
22
21
  import {
22
+ Fragment,
23
23
  useCallback,
24
24
  useEffect,
25
25
  useMemo,
@@ -32,6 +32,10 @@ import { DropdownMenu } from "../primitives/dropdown-menu";
32
32
  import { Tooltip } from "../primitives/tooltip";
33
33
  import { WorkshopIconButton } from "../primitives/workshop-controls";
34
34
  import { CapabilityChip } from "../capability-chip";
35
+ import {
36
+ renderFileView,
37
+ type FileRenderer,
38
+ } from "../file-view";
35
39
 
36
40
  const isComposingKeyEvent = (event: {
37
41
  isComposing?: boolean;
@@ -74,6 +78,7 @@ export interface CloudOsAttachmentView {
74
78
  mediaType?: string;
75
79
  size?: number;
76
80
  previewUrl?: string;
81
+ progress?: number;
77
82
  status: "uploading" | "ready" | "error";
78
83
  }
79
84
 
@@ -90,6 +95,8 @@ export interface CloudOsChatInputProps {
90
95
  onFilesSelected?: (files: readonly File[]) => void;
91
96
  onAttachmentRemove?: (id: string) => void;
92
97
  onAttachmentRetry?: (id: string) => void;
98
+ /** 接管输入框附件视图;未提供时使用包内 FileView。 */
99
+ renderFile?: FileRenderer;
93
100
  /** 底部左侧的额外控件(宿主的 Agent 档位设置等)。 */
94
101
  tools?: ReactNode;
95
102
  /** 发送键左边的额外控件(宿主的「引导 / 排队」投递方式)。 */
@@ -123,7 +130,7 @@ export interface CloudOsChatInputProps {
123
130
  footnote?: ReactNode;
124
131
  }
125
132
 
126
- const MAX_PENDING_ATTACHMENTS = 5;
133
+ const MAX_PENDING_ATTACHMENTS = 8;
127
134
 
128
135
  function commandLabel(command: CloudOsComposerCommand): string {
129
136
  return `${command.prefix ?? "/"}${command.name.replaceAll("-", " ")}`;
@@ -159,6 +166,7 @@ function CloudOsChatInputContent({
159
166
  onFilesSelected,
160
167
  onAttachmentRemove,
161
168
  onAttachmentRetry,
169
+ renderFile,
162
170
  tools,
163
171
  submitControl,
164
172
  submissionBlocked = false,
@@ -460,48 +468,25 @@ function CloudOsChatInputContent({
460
468
  {attachments.length > 0 && (
461
469
  <div className="flex flex-wrap items-center gap-2 px-3 pb-2 pt-1">
462
470
  {attachments.map((attachment) => (
463
- <div
464
- key={attachment.id}
465
- className="relative flex h-14 w-14 items-center justify-center overflow-hidden rounded-lg border border-kumo-line/70 bg-kumo-elevated"
466
- >
467
- {attachment.previewUrl ? (
468
- <img
469
- src={attachment.previewUrl}
470
- alt={attachment.filename ?? "Attached file"}
471
- className="h-full w-full object-cover"
472
- />
473
- ) : (
474
- <FileIcon size={22} className="text-kumo-inactive" />
475
- )}
476
- {attachment.status === "uploading" && (
477
- <div className="absolute inset-0 grid place-items-center rounded-lg bg-black/35 text-[10px] text-white">
478
- Uploading
479
- </div>
480
- )}
481
- {attachment.status === "error" && (
482
- <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">
483
- Failed
484
- </div>
485
- )}
486
- {attachment.status === "error" && onAttachmentRetry && (
487
- <button
488
- type="button"
489
- aria-label="Retry upload"
490
- onClick={() => onAttachmentRetry(attachment.id)}
491
- className="absolute inset-0 cursor-pointer"
492
- />
493
- )}
494
- {onAttachmentRemove && (
495
- <button
496
- type="button"
497
- aria-label="Remove attachment"
498
- onClick={() => onAttachmentRemove(attachment.id)}
499
- 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"
500
- >
501
- <X size={10} weight="bold" />
502
- </button>
503
- )}
504
- </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>
505
490
  ))}
506
491
  </div>
507
492
  )}
@@ -0,0 +1,258 @@
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
+ <AttachmentIcon className="size-4" aria-hidden="true" />
169
+ </span>
170
+ <span className="flex min-w-0 flex-col">
171
+ <span className="max-w-36 truncate text-xs font-medium text-kumo-default">
172
+ {label}
173
+ </span>
174
+ <span
175
+ className={
176
+ status === "error"
177
+ ? "text-[11px] text-kumo-danger"
178
+ : "text-[11px] text-kumo-inactive"
179
+ }
180
+ >
181
+ {meta}
182
+ </span>
183
+ </span>
184
+ <span className="ms-1 flex w-5 items-center justify-end">
185
+ {status === "uploading" ? (
186
+ <Loader2Icon
187
+ className="size-3.5 animate-spin text-kumo-inactive motion-reduce:animate-none"
188
+ aria-label="Uploading"
189
+ />
190
+ ) : status === "error" && onRetry ? (
191
+ <button
192
+ type="button"
193
+ aria-label={`Retry ${label}`}
194
+ onClick={onRetry}
195
+ 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"
196
+ >
197
+ <RotateCcwIcon className="size-3" aria-hidden="true" />
198
+ </button>
199
+ ) : status === "ready" && onRemove ? (
200
+ <button
201
+ type="button"
202
+ aria-label={`Remove ${label}`}
203
+ onClick={onRemove}
204
+ 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"
205
+ >
206
+ <XIcon className="size-3" aria-hidden="true" />
207
+ </button>
208
+ ) : status === "ready" ? (
209
+ <CheckIcon className="size-3.5 text-kumo-success" aria-label="Ready" />
210
+ ) : null}
211
+ </span>
212
+ {status !== "ready" && onRemove && (
213
+ <button
214
+ type="button"
215
+ aria-label={`Remove ${label}`}
216
+ onClick={onRemove}
217
+ 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"
218
+ >
219
+ <XIcon className="size-3" aria-hidden="true" />
220
+ </button>
221
+ )}
222
+ {status === "uploading" && (
223
+ <span
224
+ aria-hidden="true"
225
+ className="absolute inset-x-0 bottom-0 h-0.5 bg-kumo-brand/70 transition-[width] duration-300"
226
+ style={{ width: `${Math.min(100, Math.max(0, progress * 100))}%` }}
227
+ />
228
+ )}
229
+ </div>
230
+ );
231
+ }
232
+
233
+ if (isImage) {
234
+ return (
235
+ <ImageFileView src={file.url!} label={label} placement={placement} />
236
+ );
237
+ }
238
+
239
+ return (
240
+ <span
241
+ 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"
242
+ data-file-view=""
243
+ data-placement={placement}
244
+ >
245
+ <FileIcon size={13} className="text-kumo-inactive" />
246
+ {label}
247
+ </span>
248
+ );
249
+ }
250
+
251
+ /** 宿主没有接管时回退到包内默认视图。 */
252
+ export function renderFileView(
253
+ renderFile: FileRenderer | undefined,
254
+ props: FileViewProps,
255
+ ) {
256
+ const Renderer = renderFile ?? FileView;
257
+ return <Renderer {...props} />;
258
+ }
package/cloud-os/index.ts CHANGED
@@ -13,6 +13,14 @@
13
13
  export { CloudOsRoot } from "./layout/cloud-os-root";
14
14
  export { CapabilityChip } from "./capability-chip";
15
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";
16
24
  export { CloudOsWorkspaceSplit } from "./layout/cloud-os-workspace-split";
17
25
  export type { CloudOsWorkspaceSplitProps } from "./layout/cloud-os-workspace-split";
18
26
 
@@ -58,7 +66,9 @@ export {
58
66
  ErrorBlock,
59
67
  ParallelBlock,
60
68
  PlanBlock,
69
+ PermissionGrant,
61
70
  ScheduleBlock,
71
+ ScheduleConfirmation,
62
72
  SubAgentsBlock,
63
73
  SuggestionsBlock,
64
74
  } from "./chat/rich-blocks";
@@ -92,6 +102,7 @@ export {
92
102
  canonicalToolKind,
93
103
  getToolCallSummary,
94
104
  getToolIcon,
105
+ humanizeToolName,
95
106
  toCloudOsToolCall,
96
107
  toolNameOfPart,
97
108
  } from "./chat/tool-presentation";
@@ -347,9 +347,20 @@ export function CloudOsChatShowcase() {
347
347
  <CloudOsChatMessages
348
348
  messages={messages}
349
349
  status={status}
350
+ turnActive={
351
+ status === "submitted" ||
352
+ status === "streaming" ||
353
+ scenario === "ask" ||
354
+ ((scenario === "approval" || scenario === "schedule_cancel") &&
355
+ approval === undefined)
356
+ }
350
357
  isHydrating={scenario === "hydrating"}
351
358
  isRecovering={scenario === "recovering"}
352
359
  recoveryStatusLabel="模型响应暂时中断,正在重试…"
360
+ awaitingApproval={
361
+ (scenario === "approval" || scenario === "schedule_cancel") &&
362
+ approval === undefined
363
+ }
353
364
  showThinkingTraces={showThinkingTraces}
354
365
  approvalIdOf={approvalIdOf}
355
366
  error={statusError}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/message-panel",
3
- "version": "0.1.3-alpha.5",
3
+ "version": "0.1.3-alpha.6",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -35,7 +35,7 @@
35
35
  "react-markdown": "^10.1.0",
36
36
  "remark-gfm": "^4.0.1",
37
37
  "streamdown": "^2.5.0",
38
- "thinking-orbs": "0.1.1",
38
+ "thinking-orbs": "0.2.0",
39
39
  "use-stick-to-bottom": "^1.1.6"
40
40
  },
41
41
  "devDependencies": {