@springbrand/message-panel 0.1.3-alpha.1 → 0.1.3-alpha.11

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 (43) hide show
  1. package/cloud-os/README.md +71 -0
  2. package/cloud-os/capability-chip.tsx +35 -0
  3. package/cloud-os/chat/activity-indicator.tsx +29 -0
  4. package/cloud-os/chat/cloud-os-chat-messages.tsx +663 -0
  5. package/cloud-os/chat/markdown-message.tsx +78 -0
  6. package/cloud-os/chat/rich-blocks.tsx +944 -0
  7. package/cloud-os/chat/tool-presentation.ts +602 -0
  8. package/cloud-os/chat/tool-rows.tsx +216 -0
  9. package/cloud-os/chat/transcript-model.ts +780 -0
  10. package/cloud-os/composer/cloud-os-chat-input.tsx +686 -0
  11. package/cloud-os/file-view.tsx +258 -0
  12. package/cloud-os/index.ts +123 -0
  13. package/cloud-os/internal/cn.ts +6 -0
  14. package/cloud-os/internal/theme-context.tsx +16 -0
  15. package/cloud-os/layout/cloud-os-root.tsx +34 -0
  16. package/cloud-os/layout/cloud-os-workspace-split.tsx +228 -0
  17. package/cloud-os/primitives/dropdown-menu.tsx +82 -0
  18. package/cloud-os/primitives/tooltip.tsx +68 -0
  19. package/cloud-os/primitives/workshop-controls.tsx +114 -0
  20. package/cloud-os/styles/cloud-os.css +582 -0
  21. package/cloud-os/workspace/cloud-os-workspace-panel.tsx +272 -0
  22. package/demo/camel-chat-showcase.tsx +25 -930
  23. package/demo/chat-scenarios.ts +943 -0
  24. package/demo/cloud-os-chat-showcase.tsx +483 -0
  25. package/demo/fixtures.ts +4 -5
  26. package/demo/index.ts +2 -0
  27. package/demo/message-panel-gallery.tsx +16 -2
  28. package/package.json +17 -5
  29. package/src/camel/camel-chat-messages.tsx +118 -95
  30. package/src/camel/camel-prompt-input.tsx +2 -1
  31. package/src/camel/camel-tool-presentation.tsx +17 -13
  32. package/src/camel/camel-turn.ts +1 -17
  33. package/src/chat-summary-panel.tsx +1 -1
  34. package/src/composer/chat-composer.tsx +2 -1
  35. package/src/composer/composer-trigger-popover.tsx +1 -1
  36. package/src/composer/composer.tsx +2 -1
  37. package/src/composer/index.ts +1 -0
  38. package/src/composer/key-rules.ts +12 -0
  39. package/src/contracts.ts +0 -2
  40. package/src/message-panel.tsx +0 -23
  41. package/src/message.tsx +0 -8
  42. package/src/parts/index.tsx +102 -63
  43. package/src/styles/index.css +4 -1
@@ -0,0 +1,944 @@
1
+ import {
2
+ ArrowLeft,
3
+ ArrowRight,
4
+ Bell,
5
+ CaretRight,
6
+ Check,
7
+ CircleNotch,
8
+ Clock,
9
+ Key,
10
+ ListChecks,
11
+ UsersThree,
12
+ WarningCircle,
13
+ } from "@phosphor-icons/react";
14
+ import { useRef, useState } from "react";
15
+ import { Tooltip } from "../primitives/tooltip";
16
+ import { WorkshopButton } from "../primitives/workshop-controls";
17
+ import { MarkdownMessage } from "./markdown-message";
18
+ import { WorkIcon } from "./tool-rows";
19
+ import type {
20
+ ParallelTool,
21
+ PlanStep,
22
+ SubAgentView,
23
+ } from "./transcript-model";
24
+ import {
25
+ askUserOutcome,
26
+ humanizeToolName,
27
+ safeStringify,
28
+ type CloudOsToolCall,
29
+ } from "./tool-presentation";
30
+
31
+ /**
32
+ * UIMessage 协议里存在、gadgets 那套没有的几类 part —— 计划快照、原位提问、
33
+ * 后续建议、定时任务、并行/子 Agent、原位授权。视觉语言全部沿用 cloud-os:
34
+ * 折叠工作行用 `px-1.5 py-1` 的行式布局,卡片用
35
+ * `themed-surface-inset rounded-2xl border-kumo-line/70 bg-kumo-elevated/45 p-3`,
36
+ * 授权卡沿用 renderActionCard 的 blocking callout(brand/40 边 + brand/10 底)。
37
+ */
38
+
39
+ // ── 计划快照 ────────────────────────────────────────────────────────────────
40
+
41
+ export function PlanBlock({
42
+ steps,
43
+ running,
44
+ }: {
45
+ steps: PlanStep[];
46
+ running: boolean;
47
+ }) {
48
+ const done = steps.filter((step) => step.status === "done").length;
49
+ const current = steps.find((step) => step.status === "in_progress");
50
+ const [open, setOpen] = useState(true);
51
+
52
+ return (
53
+ <div className="group -ml-0.5">
54
+ <button
55
+ type="button"
56
+ onClick={() => setOpen((value) => !value)}
57
+ className="flex w-full cursor-pointer items-center gap-3 rounded-xl px-1.5 py-1 text-left text-kumo-subtle transition-colors duration-150 ease-out hover:text-kumo-default focus-visible:text-kumo-default focus-visible:outline-none active:scale-[0.995]"
58
+ aria-expanded={open}
59
+ >
60
+ <span className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
61
+ <WorkIcon Icon={ListChecks} />
62
+ </span>
63
+ <span className="min-w-0 flex-1">
64
+ <span className="flex min-w-0 items-center gap-2 text-[14px] leading-5 tracking-[-0.25px]">
65
+ <span className={`min-w-0 truncate ${running ? "cos-thinking-shimmer" : ""}`}>
66
+ Plan · {done}/{steps.length}
67
+ </span>
68
+ <CaretRight
69
+ size={13}
70
+ weight="bold"
71
+ className={`flex-shrink-0 text-kumo-inactive transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`}
72
+ />
73
+ </span>
74
+ {!open && current && (
75
+ <span className="mt-1 block truncate font-mono text-[12px] leading-4 text-kumo-inactive">
76
+ {current.text}
77
+ </span>
78
+ )}
79
+ </span>
80
+ </button>
81
+ {open && (
82
+ <div className="themed-surface-inset ml-8 mt-1 space-y-1.5 rounded-2xl border border-kumo-line/70 bg-kumo-elevated/45 p-3">
83
+ {steps.map((step, index) => (
84
+ <div
85
+ key={`${step.text}-${index}`}
86
+ className="flex items-start gap-2.5 text-[13px] leading-[19px] tracking-[-0.25px]"
87
+ >
88
+ <span
89
+ className="mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center"
90
+ aria-hidden="true"
91
+ >
92
+ {step.status === "done" ? (
93
+ <Check size={12} weight="bold" className="text-kumo-success" />
94
+ ) : step.status === "in_progress" ? (
95
+ <CircleNotch
96
+ size={12}
97
+ weight="bold"
98
+ className="animate-spin text-kumo-brand motion-reduce:animate-none"
99
+ />
100
+ ) : (
101
+ <span className="h-1.5 w-1.5 rounded-full bg-kumo-fill" />
102
+ )}
103
+ </span>
104
+ <span
105
+ className={
106
+ step.status === "done"
107
+ ? "min-w-0 flex-1 text-kumo-inactive line-through"
108
+ : step.status === "in_progress"
109
+ ? "min-w-0 flex-1 font-medium text-kumo-default"
110
+ : "min-w-0 flex-1 text-kumo-subtle"
111
+ }
112
+ >
113
+ {step.text}
114
+ </span>
115
+ </div>
116
+ ))}
117
+ </div>
118
+ )}
119
+ </div>
120
+ );
121
+ }
122
+
123
+ // ── 原位提问 ────────────────────────────────────────────────────────────────
124
+
125
+ interface AskQuestion {
126
+ prompt: string;
127
+ options: string[];
128
+ multi: boolean;
129
+ allowCustom: boolean;
130
+ }
131
+
132
+ function questionsOf(input: Record<string, unknown>): AskQuestion[] {
133
+ if (!Array.isArray(input.questions)) return [];
134
+ return input.questions.map((item) => {
135
+ const question = (item ?? {}) as Record<string, unknown>;
136
+ const options = Array.isArray(question.options)
137
+ ? question.options.filter((option): option is string =>
138
+ typeof option === "string"
139
+ )
140
+ : [];
141
+ return {
142
+ prompt: typeof question.question === "string"
143
+ ? question.question
144
+ : "Choose an option",
145
+ options,
146
+ multi: question.multiSelect === true,
147
+ allowCustom: question.allowCustom === true || options.length === 0,
148
+ };
149
+ });
150
+ }
151
+
152
+ interface DraftAnswer {
153
+ selections: string[];
154
+ text: string;
155
+ }
156
+
157
+ export function AskUserBlock({
158
+ call,
159
+ onRespond,
160
+ }: {
161
+ call: CloudOsToolCall;
162
+ /** 缺省(宿主没接)时选项不可点 —— 形状对齐 ScheduleBlock 的 onOpen。 */
163
+ onRespond?: (toolCallId: string, response: unknown) => Promise<boolean>;
164
+ }) {
165
+ const questions = questionsOf(call.input);
166
+ // 不能用 `useState(() => questions.map(...))` 初始化:工具入参是**流式**到达的,
167
+ // 首帧 questions 往往还是空数组,那之后每次 setAnswers 都在空数组上 map,
168
+ // 选择永远存不进去 → Submit 永久禁用。改成稀疏存 + 按当前 questions 补齐。
169
+ const [answers, setAnswers] = useState<DraftAnswer[]>([]);
170
+ const [current, setCurrent] = useState(0);
171
+ const direction = useRef<"back" | "forward">("forward");
172
+ const answerAt = (index: number): DraftAnswer =>
173
+ answers[index] ?? { selections: [], text: "" };
174
+ const toggle = (questionIndex: number, option: string, multi: boolean) =>
175
+ setAnswers((current) => {
176
+ const next = questions.map((_, index) =>
177
+ current[index] ?? { selections: [], text: "" }
178
+ );
179
+ const answer = next[questionIndex]!;
180
+ next[questionIndex] = {
181
+ ...answer,
182
+ selections: !multi
183
+ ? [option]
184
+ : answer.selections.includes(option)
185
+ ? answer.selections.filter((value) => value !== option)
186
+ : [...answer.selections, option],
187
+ };
188
+ return next;
189
+ });
190
+ const setText = (questionIndex: number, text: string) =>
191
+ setAnswers((current) => {
192
+ const next = questions.map((_, index) =>
193
+ current[index] ?? { selections: [], text: "" }
194
+ );
195
+ next[questionIndex] = { ...next[questionIndex]!, text };
196
+ return next;
197
+ });
198
+ // 在途只用于按钮转圈;权威状态永远是 part 的 state。
199
+ const [inFlight, setInFlight] = useState(false);
200
+ const outcome = askUserOutcome(call);
201
+ const settledAnswers = outcome.kind === "answered" ? outcome.answers : [];
202
+ // 服务端已经结算 = 这张卡永久不可操作,不管结算成什么。
203
+ const resolved = outcome.kind !== "pending" || inFlight;
204
+ const valid = questions.length > 0 && questions.every(
205
+ (_question, index) =>
206
+ answerAt(index).selections.length > 0 || answerAt(index).text.trim(),
207
+ );
208
+ const currentIndex = Math.min(current, Math.max(questions.length - 1, 0));
209
+ const currentQuestion = questions[currentIndex];
210
+ const currentAnswer = answerAt(currentIndex);
211
+ const currentValid = currentAnswer.selections.length > 0 ||
212
+ Boolean(currentAnswer.text.trim());
213
+ const isLast = currentIndex === questions.length - 1;
214
+ const buttonLabel = outcome.kind === "answered"
215
+ ? "Submitted"
216
+ : outcome.kind === "closed"
217
+ ? "Closed"
218
+ : inFlight
219
+ ? "Submitting…"
220
+ : "Submit";
221
+ const footnote = outcome.kind === "closed"
222
+ ? outcome.reason === "user_replied_freeform"
223
+ ? "Answered in the chat instead."
224
+ : "Closed without an answer."
225
+ : "";
226
+
227
+ const submit = () => {
228
+ if (!onRespond) return;
229
+ setInFlight(true);
230
+ // 失败就把按钮放回可点:权威状态由 part 决定,这里只管别把用户锁死。
231
+ void onRespond(call.toolCallId, {
232
+ answers: questions.map((_, index) => ({
233
+ selections: answerAt(index).selections,
234
+ text: answerAt(index).text.trim(),
235
+ })),
236
+ }).then((ok) => {
237
+ if (!ok) setInFlight(false);
238
+ }, () => setInFlight(false));
239
+ };
240
+
241
+ const settledButton = (
242
+ <WorkshopButton
243
+ tone="primary"
244
+ disabled
245
+ className="ml-auto !h-7 flex-shrink-0 gap-1 !rounded-lg px-2.5 text-[12px]"
246
+ >
247
+ {buttonLabel}
248
+ </WorkshopButton>
249
+ );
250
+
251
+ return (
252
+ // 收成 w-fit:短问题不该撑成一条横贯全栏的长条,长问题到 560px 再换行。
253
+ <div
254
+ className="w-fit min-w-[15rem] max-w-[560px]"
255
+ data-part-type="ask_user"
256
+ data-state={outcome.kind === "answered" ? "submitted" : outcome.kind}
257
+ >
258
+ <div className="flex flex-col gap-3 rounded-xl border border-kumo-line bg-kumo-base px-3 py-2.5">
259
+ {outcome.kind === "pending" ? (
260
+ <>
261
+ {questions.length > 1 && (
262
+ <div className="flex flex-col gap-2">
263
+ <div className="flex items-center justify-between text-[11px] font-medium leading-4 text-kumo-inactive">
264
+ <span>Clarifying questions</span>
265
+ <span className="tabular-nums">
266
+ {currentIndex + 1}/{questions.length}
267
+ </span>
268
+ </div>
269
+ <div className="flex gap-1" aria-hidden="true">
270
+ {questions.map((question, index) => (
271
+ <span
272
+ key={`${question.prompt}:${index}`}
273
+ className={`h-1 flex-1 rounded-full ${
274
+ index < currentIndex
275
+ ? "bg-kumo-brand"
276
+ : index === currentIndex
277
+ ? "bg-kumo-brand/60"
278
+ : "bg-kumo-fill"
279
+ }`}
280
+ />
281
+ ))}
282
+ </div>
283
+ </div>
284
+ )}
285
+ {currentQuestion && (
286
+ <div className="overflow-hidden">
287
+ <div
288
+ key={currentIndex}
289
+ className="cos-ask-user-question flex flex-col gap-2.5"
290
+ data-direction={direction.current}
291
+ >
292
+ <p className="m-0 text-[13px] leading-[18px] font-medium tracking-[-0.25px] text-kumo-default">
293
+ {currentQuestion.prompt}
294
+ </p>
295
+ {currentQuestion.options.length > 0 && (
296
+ <ul className="m-0 flex list-none flex-col gap-1.5 p-0">
297
+ {currentQuestion.options.map((option) => {
298
+ const selected = currentAnswer.selections.includes(
299
+ option,
300
+ );
301
+ return (
302
+ <li key={option} className="m-0 p-0">
303
+ <label
304
+ className={`flex min-h-8 items-center gap-2 rounded-lg border px-2.5 py-1.5 text-[13px] leading-[18px] tracking-[-0.25px] transition-colors duration-150 ease-out ${
305
+ resolved || !onRespond
306
+ ? "cursor-default text-kumo-inactive"
307
+ : "cursor-pointer text-kumo-subtle hover:bg-kumo-tint hover:text-kumo-default"
308
+ } ${
309
+ selected
310
+ ? "border-kumo-brand/45 bg-kumo-brand/10 text-kumo-default"
311
+ : "border-kumo-line bg-kumo-base"
312
+ }`}
313
+ >
314
+ <input
315
+ type={currentQuestion.multi
316
+ ? "checkbox"
317
+ : "radio"}
318
+ name={`${call.toolCallId}-${currentIndex}`}
319
+ checked={selected}
320
+ disabled={resolved || !onRespond}
321
+ onChange={() =>
322
+ toggle(
323
+ currentIndex,
324
+ option,
325
+ currentQuestion.multi,
326
+ )}
327
+ className="h-4 w-4 shrink-0 accent-[var(--color-kumo-brand)]"
328
+ />
329
+ <span>{option}</span>
330
+ </label>
331
+ </li>
332
+ );
333
+ })}
334
+ </ul>
335
+ )}
336
+ {currentQuestion.allowCustom && (
337
+ <label className="flex flex-col gap-1 text-[12px] leading-4 text-kumo-subtle">
338
+ <span>
339
+ {currentQuestion.options.length > 0 ? "Other" : "Answer"}
340
+ </span>
341
+ <input
342
+ type="text"
343
+ value={currentAnswer.text}
344
+ maxLength={2_000}
345
+ disabled={resolved || !onRespond}
346
+ aria-label={`自定义回答:${currentQuestion.prompt}`}
347
+ placeholder={currentQuestion.options.length > 0
348
+ ? "Add a custom answer"
349
+ : "Type your answer"}
350
+ onChange={(event) =>
351
+ setText(currentIndex, event.currentTarget.value)}
352
+ className="h-8 rounded-lg border border-kumo-line bg-kumo-base px-2.5 text-[13px] text-kumo-default outline-none placeholder:text-kumo-inactive focus:border-kumo-brand focus:ring-2 focus:ring-kumo-brand/20 disabled:cursor-default"
353
+ />
354
+ </label>
355
+ )}
356
+ </div>
357
+ </div>
358
+ )}
359
+ <div className="flex items-center gap-2">
360
+ {questions.length > 1 && (
361
+ <WorkshopButton
362
+ disabled={currentIndex === 0 || resolved}
363
+ onClick={() => {
364
+ direction.current = "back";
365
+ setCurrent((index) => Math.max(0, index - 1));
366
+ }}
367
+ className="!h-7 gap-1 !rounded-lg px-2.5 text-[12px]"
368
+ >
369
+ <ArrowLeft size={13} weight="bold" />
370
+ Back
371
+ </WorkshopButton>
372
+ )}
373
+ <WorkshopButton
374
+ tone="primary"
375
+ disabled={
376
+ resolved || !onRespond ||
377
+ (questions.length > 1 && !isLast ? !currentValid : !valid)
378
+ }
379
+ onClick={() => {
380
+ if (questions.length > 1 && !isLast) {
381
+ direction.current = "forward";
382
+ setCurrent((index) =>
383
+ Math.min(questions.length - 1, index + 1)
384
+ );
385
+ return;
386
+ }
387
+ submit();
388
+ }}
389
+ className="ml-auto !h-7 gap-1 !rounded-lg px-2.5 text-[12px]"
390
+ >
391
+ {questions.length > 1 && !isLast ? (
392
+ <>
393
+ Next
394
+ <ArrowRight size={13} weight="bold" />
395
+ </>
396
+ ) : buttonLabel}
397
+ </WorkshopButton>
398
+ </div>
399
+ </>
400
+ ) : (
401
+ <>
402
+ {outcome.kind === "answered" &&
403
+ questions.map((question, questionIndex) => (
404
+ <div
405
+ key={`${question.prompt}:${questionIndex}`}
406
+ className="flex flex-col gap-1.5"
407
+ >
408
+ <p className="m-0 text-[13px] leading-[18px] font-medium tracking-[-0.25px] text-kumo-default">
409
+ {question.prompt}
410
+ </p>
411
+ {settledAnswers[questionIndex] && (
412
+ <div className="flex flex-col gap-1 text-[12px] leading-4 text-kumo-inactive">
413
+ {settledAnswers[questionIndex]!.selections.length > 0 && (
414
+ <p className="m-0">
415
+ Answered: {settledAnswers[questionIndex]!.selections
416
+ .join(" / ")}
417
+ </p>
418
+ )}
419
+ {settledAnswers[questionIndex]!.text.trim() && (
420
+ <p className="m-0">
421
+ {settledAnswers[questionIndex]!.selections.length > 0
422
+ ? `补充:${settledAnswers[questionIndex]!.text.trim()}`
423
+ : `Answered: ${settledAnswers[questionIndex]!.text.trim()}`}
424
+ </p>
425
+ )}
426
+ </div>
427
+ )}
428
+ </div>
429
+ ))}
430
+ <div className="flex items-center gap-3">
431
+ {footnote && (
432
+ <p className="m-0 min-w-0 text-[12px] leading-4 text-kumo-inactive">
433
+ {footnote}
434
+ </p>
435
+ )}
436
+ {settledButton}
437
+ </div>
438
+ </>
439
+ )}
440
+ </div>
441
+ </div>
442
+ );
443
+ }
444
+
445
+ // ── 后续建议 ────────────────────────────────────────────────────────────────
446
+
447
+ export function SuggestionsBlock({
448
+ items,
449
+ onSuggestion,
450
+ }: {
451
+ items: string[];
452
+ onSuggestion: (text: string) => void;
453
+ }) {
454
+ return (
455
+ <div className="max-w-[860px] space-y-2 pt-1">
456
+ <div className="flex items-center gap-2 px-1.5 text-[13px] leading-[18px] text-kumo-success">
457
+ <Check size={14} weight="bold" />
458
+ <span>Task complete</span>
459
+ </div>
460
+ <div className="flex flex-wrap gap-1.5 px-1.5">
461
+ {items.map((item) => (
462
+ <button
463
+ key={item}
464
+ type="button"
465
+ onClick={() => onSuggestion(item)}
466
+ className="inline-flex h-7 cursor-pointer items-center rounded-full border border-kumo-line bg-kumo-base px-3 text-[13px] leading-none tracking-[-0.25px] text-kumo-subtle transition-colors duration-150 ease-out hover:bg-kumo-tint hover:text-kumo-default focus-visible:outline-none active:scale-[0.98]"
467
+ >
468
+ {item}
469
+ </button>
470
+ ))}
471
+ </div>
472
+ </div>
473
+ );
474
+ }
475
+
476
+ // ── 定时任务 ────────────────────────────────────────────────────────────────
477
+
478
+ function scheduleDuration(seconds: unknown): string {
479
+ if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) {
480
+ return "";
481
+ }
482
+ for (const [size, unit] of [
483
+ [86_400, "day"],
484
+ [3_600, "hour"],
485
+ [60, "minute"],
486
+ ] as const) {
487
+ if (seconds % size === 0) {
488
+ const value = seconds / size;
489
+ return `${value} ${unit}${value === 1 ? "" : "s"}`;
490
+ }
491
+ }
492
+ return `${seconds} second${seconds === 1 ? "" : "s"}`;
493
+ }
494
+
495
+ function scheduleCadence(input: Record<string, unknown>): string {
496
+ const trigger = (input.trigger ?? {}) as Record<string, unknown>;
497
+ let cadence = "";
498
+ if (trigger.kind === "once-in") cadence = `In ${scheduleDuration(trigger.seconds)}`;
499
+ else if (trigger.kind === "once-at" && typeof trigger.at === "string") {
500
+ cadence = `Once at ${trigger.at}`;
501
+ } else if (trigger.kind === "interval") {
502
+ cadence = `Every ${scheduleDuration(trigger.seconds)}`;
503
+ } else if (trigger.kind === "cron" && typeof trigger.cron === "string") {
504
+ cadence = `Cron ${trigger.cron}`;
505
+ }
506
+ if (!cadence) cadence = String(input.cadence ?? input.schedule ?? "Scheduled");
507
+ const timezone =
508
+ typeof input.timezone === "string"
509
+ ? input.timezone
510
+ : typeof input.tz === "string"
511
+ ? input.tz
512
+ : "";
513
+ return timezone ? `${cadence} · ${timezone}` : cadence;
514
+ }
515
+
516
+ /** `schedule` 工具的 details 是 `{ scheduled: true, id }`,id 即任务在 SchedulerAgent 里的主键。 */
517
+ function scheduleIdOf(output: unknown): string {
518
+ if (typeof output !== "object" || output === null) return "";
519
+ const id = (output as Record<string, unknown>).id;
520
+ return typeof id === "string" ? id : "";
521
+ }
522
+
523
+ export function ScheduleConfirmation({
524
+ input,
525
+ disabled = false,
526
+ onCancel,
527
+ onConfirm,
528
+ }: {
529
+ input: Record<string, unknown>;
530
+ disabled?: boolean;
531
+ onCancel: () => void;
532
+ onConfirm: () => void;
533
+ }) {
534
+ const name = String(input.label ?? input.title ?? "Scheduled task");
535
+ const prompt = typeof input.prompt === "string" && input.prompt.trim()
536
+ ? input.prompt.trim()
537
+ : "Run the scheduled task";
538
+
539
+ return (
540
+ <section
541
+ data-slot="schedule-card"
542
+ aria-label={`确认定时任务:${name}`}
543
+ 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)]"
544
+ >
545
+ <div className="flex items-center gap-2.5">
546
+ <span
547
+ className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-kumo-fill/70 text-kumo-subtle"
548
+ aria-hidden="true"
549
+ >
550
+ <Clock size={14} />
551
+ </span>
552
+ <div className="flex min-w-0 flex-1 flex-col">
553
+ <span className="truncate text-[13.5px] leading-[18px] font-medium">
554
+ {name}
555
+ </span>
556
+ <span className="truncate font-mono text-[11px] leading-4 tracking-tight text-kumo-inactive">
557
+ {scheduleCadence(input)}
558
+ </span>
559
+ </div>
560
+ </div>
561
+
562
+ <div className="flex items-start gap-2 rounded-xl bg-kumo-fill/50 px-3 py-2">
563
+ <span className="shrink-0 font-mono text-[11px] leading-[18px] tracking-tight text-kumo-inactive">
564
+ task
565
+ </span>
566
+ <span className="min-w-0 flex-1 text-[13px] leading-[18px] text-kumo-subtle">
567
+ {prompt}
568
+ </span>
569
+ </div>
570
+
571
+ <div className="flex items-center justify-end gap-2">
572
+ <WorkshopButton disabled={disabled} onClick={onCancel}>
573
+ 取消
574
+ </WorkshopButton>
575
+ <WorkshopButton tone="primary" disabled={disabled} onClick={onConfirm}>
576
+ {disabled ? "正在确认…" : "确认安排"}
577
+ </WorkshopButton>
578
+ </div>
579
+ </section>
580
+ );
581
+ }
582
+
583
+ export function ScheduleBlock({
584
+ call,
585
+ onOpen,
586
+ }: {
587
+ call: CloudOsToolCall;
588
+ /** 有 id 且宿主给了回调时,整张卡变成跳到「已安排」详情的按钮。 */
589
+ onOpen?: (scheduleId: string) => void;
590
+ }) {
591
+ // 待确认时由宿主把持久化审批渲染在输入框上方;这里不重复画第二张卡。
592
+ if (call.awaitingApproval) return null;
593
+ const state = call.failed ? "failed" : call.running ? "running" : "scheduled";
594
+ const scheduleId = state === "scheduled" ? scheduleIdOf(call.output) : "";
595
+ const clickable = Boolean(scheduleId && onOpen);
596
+ const Card = clickable ? "button" : "div";
597
+ return (
598
+ <div className="max-w-[860px]">
599
+ <Card
600
+ {...(clickable
601
+ ? {
602
+ type: "button" as const,
603
+ onClick: () => onOpen?.(scheduleId),
604
+ "aria-label": `Open scheduled task: ${String(call.input.label ?? call.input.title ?? "Scheduled task")}`,
605
+ }
606
+ : {})}
607
+ className={`w-full rounded-2xl border border-kumo-line bg-kumo-base px-4 py-3 text-left${
608
+ clickable
609
+ ? " cursor-pointer transition-colors duration-150 ease-out hover:bg-kumo-tint focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/50"
610
+ : ""
611
+ }`}
612
+ >
613
+ <div className="flex flex-wrap items-start gap-3">
614
+ <span
615
+ className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-kumo-tint text-kumo-subtle"
616
+ aria-hidden="true"
617
+ >
618
+ <Bell size={18} />
619
+ </span>
620
+ <div className="min-w-[10rem] flex-1">
621
+ <span className="block truncate text-[14px] leading-5 font-medium tracking-[-0.25px] text-kumo-default">
622
+ {String(call.input.label ?? call.input.title ?? "Scheduled task")}
623
+ </span>
624
+ <p className="mt-1 mb-0 text-[12px] leading-4 text-kumo-inactive">
625
+ {scheduleCadence(call.input)}
626
+ </p>
627
+ </div>
628
+ <span
629
+ className={`ml-auto flex flex-shrink-0 items-center gap-1 self-center text-[12px] leading-4 font-medium ${
630
+ state === "failed"
631
+ ? "text-kumo-danger"
632
+ : state === "running"
633
+ ? "text-kumo-brand"
634
+ : "text-kumo-success"
635
+ }`}
636
+ >
637
+ {state === "failed" ? (
638
+ <WarningCircle size={13} weight="fill" />
639
+ ) : state === "running" ? (
640
+ <CircleNotch
641
+ size={13}
642
+ weight="bold"
643
+ className="animate-spin motion-reduce:animate-none"
644
+ />
645
+ ) : (
646
+ <Check size={13} weight="bold" />
647
+ )}
648
+ {state === "failed"
649
+ ? "Failed"
650
+ : state === "running"
651
+ ? "Scheduling…"
652
+ : "Scheduled"}
653
+ </span>
654
+ </div>
655
+ </Card>
656
+ </div>
657
+ );
658
+ }
659
+
660
+ // ── 原位授权 ──────────────────────────────────────────────
661
+
662
+ export type ApprovalDecision = "deny" | "allow_once" | "allow_level";
663
+
664
+ export function PermissionGrant({
665
+ capability,
666
+ requester,
667
+ reach,
668
+ disabled = false,
669
+ onGrant,
670
+ "aria-label": ariaLabel = `${capability} permission request`,
671
+ }: {
672
+ capability: string;
673
+ requester: string;
674
+ reach: readonly string[];
675
+ disabled?: boolean;
676
+ onGrant: (decision: ApprovalDecision) => void;
677
+ "aria-label"?: string;
678
+ }) {
679
+ const actionClassName =
680
+ "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";
681
+
682
+ return (
683
+ <section
684
+ aria-label={ariaLabel}
685
+ 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"
686
+ >
687
+ <div className="flex items-center gap-2.5">
688
+ <span
689
+ className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-kumo-fill/70 text-kumo-subtle"
690
+ aria-hidden="true"
691
+ >
692
+ <Key size={14} weight="bold" />
693
+ </span>
694
+ <div className="flex min-w-0 flex-1 flex-col">
695
+ <span className="truncate text-[13.5px] leading-[18px] font-medium">
696
+ {capability}
697
+ </span>
698
+ <span className="truncate text-[12px] leading-4 text-kumo-inactive">
699
+ requested by {requester}
700
+ </span>
701
+ </div>
702
+ </div>
703
+
704
+ <div className="mt-3.5 flex max-h-[200px] flex-col gap-1 overflow-y-auto pr-1">
705
+ <span className="font-mono text-[10px] leading-4 text-kumo-inactive">
706
+ this grants
707
+ </span>
708
+ {reach.map((item, index) => (
709
+ <span
710
+ key={`${index}:${item}`}
711
+ className="flex items-baseline gap-2 text-[12px] leading-[17px] text-kumo-subtle"
712
+ >
713
+ <span
714
+ className="size-1 shrink-0 rounded-full bg-kumo-interact"
715
+ aria-hidden="true"
716
+ />
717
+ <span className="min-w-0 break-words whitespace-pre-wrap">{item}</span>
718
+ </span>
719
+ ))}
720
+ </div>
721
+
722
+ <div className="mt-3.5 flex min-h-8 flex-wrap items-center justify-end gap-1">
723
+ <button
724
+ type="button"
725
+ disabled={disabled}
726
+ onClick={() => onGrant("deny")}
727
+ className={`${actionClassName} text-kumo-subtle enabled:hover:bg-kumo-fill enabled:hover:text-kumo-default`}
728
+ >
729
+ Deny
730
+ </button>
731
+ <button
732
+ type="button"
733
+ disabled={disabled}
734
+ onClick={() => onGrant("allow_once")}
735
+ className={`${actionClassName} text-kumo-subtle enabled:hover:bg-kumo-fill enabled:hover:text-kumo-default`}
736
+ >
737
+ Allow once
738
+ </button>
739
+ <Tooltip
740
+ content="Allow every action at this execution level, without future prompts."
741
+ asChild
742
+ >
743
+ <button
744
+ type="button"
745
+ disabled={disabled}
746
+ onClick={() => onGrant("allow_level")}
747
+ className={`${actionClassName} bg-kumo-contrast text-kumo-inverse enabled:hover:bg-kumo-strong`}
748
+ >
749
+ Allow level
750
+ </button>
751
+ </Tooltip>
752
+ </div>
753
+ </section>
754
+ );
755
+ }
756
+
757
+ function approvalReach(call: CloudOsToolCall): string[] {
758
+ const entries = Object.entries(call.input);
759
+ return entries.length > 0
760
+ ? entries.map(
761
+ ([name, value]) => `${humanizeToolName(name)}: ${safeStringify(value)}`,
762
+ )
763
+ : ["Run this tool without arguments"];
764
+ }
765
+
766
+ export function ApprovalBlock({
767
+ call,
768
+ approvalId,
769
+ disabled = false,
770
+ onApprove,
771
+ }: {
772
+ call: CloudOsToolCall;
773
+ approvalId: string;
774
+ disabled?: boolean;
775
+ onApprove: (approvalId: string, decision: ApprovalDecision) => void;
776
+ }) {
777
+ return (
778
+ <div className="group/work max-w-[860px] text-[14px] leading-5 tracking-[-0.25px] text-kumo-subtle">
779
+ <PermissionGrant
780
+ capability={humanizeToolName(call.toolName)}
781
+ requester="the agent"
782
+ reach={approvalReach(call)}
783
+ disabled={disabled}
784
+ onGrant={(decision) => onApprove(approvalId, decision)}
785
+ />
786
+ </div>
787
+ );
788
+ }
789
+
790
+ // ── 并行 / 子 Agent ─────────────────────────────────────────────────────────
791
+
792
+ function StatusDot({ status }: { status: ParallelTool["status"] }) {
793
+ return (
794
+ <span
795
+ className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${
796
+ status === "failed"
797
+ ? "bg-kumo-danger"
798
+ : status === "running"
799
+ ? "animate-pulse bg-kumo-brand motion-reduce:animate-none"
800
+ : status === "done"
801
+ ? "bg-kumo-success"
802
+ : "bg-kumo-fill"
803
+ }`}
804
+ aria-hidden="true"
805
+ />
806
+ );
807
+ }
808
+
809
+ export function ParallelBlock({
810
+ label,
811
+ tools,
812
+ }: {
813
+ label: string;
814
+ tools: ParallelTool[];
815
+ }) {
816
+ const done = tools.filter((tool) => tool.status === "done").length;
817
+ return (
818
+ <div className="max-w-[860px]">
819
+ <div className="flex items-center gap-3 px-1.5 py-1 text-[14px] leading-5 tracking-[-0.25px] text-kumo-subtle">
820
+ <span className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
821
+ <WorkIcon Icon={ListChecks} />
822
+ </span>
823
+ <span className="min-w-0 truncate">
824
+ {label} · {done}/{tools.length}
825
+ </span>
826
+ </div>
827
+ <div className="ml-8 mt-1 space-y-1">
828
+ {tools.map((tool, index) => (
829
+ <div
830
+ key={`${tool.label}-${index}`}
831
+ className="flex items-center gap-2 px-1.5 text-[13px] leading-[19px] tracking-[-0.25px] text-kumo-subtle"
832
+ >
833
+ <StatusDot status={tool.status} />
834
+ <span className="min-w-0 flex-1 truncate">{tool.label}</span>
835
+ {tool.meta && (
836
+ <span className="flex-shrink-0 font-mono text-[11px] leading-4 text-kumo-inactive">
837
+ {tool.meta}
838
+ </span>
839
+ )}
840
+ </div>
841
+ ))}
842
+ </div>
843
+ </div>
844
+ );
845
+ }
846
+
847
+ export function SubAgentsBlock({ agents }: { agents: SubAgentView[] }) {
848
+ return (
849
+ <div className="max-w-[860px]">
850
+ <div className="flex items-center gap-3 px-1.5 py-1 text-[14px] leading-5 tracking-[-0.25px] text-kumo-subtle">
851
+ <span className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
852
+ <WorkIcon Icon={UsersThree} />
853
+ </span>
854
+ <span className="min-w-0 truncate">
855
+ {agents.length === 1 ? "Sub-agent" : `${agents.length} sub-agents`}
856
+ </span>
857
+ </div>
858
+ <div className="ml-8 mt-1 space-y-1">
859
+ {agents.map((agent, index) => (
860
+ <div
861
+ key={`${agent.name}-${index}`}
862
+ className="flex items-start gap-2 px-1.5 text-[13px] leading-[19px] tracking-[-0.25px]"
863
+ >
864
+ <span className="mt-1.5 flex">
865
+ <StatusDot status={agent.status} />
866
+ </span>
867
+ <span className="min-w-0 flex-1">
868
+ <span className="block truncate font-medium text-kumo-default">
869
+ {agent.name}
870
+ </span>
871
+ {agent.summary && (
872
+ <span className="block text-kumo-subtle">{agent.summary}</span>
873
+ )}
874
+ </span>
875
+ </div>
876
+ ))}
877
+ </div>
878
+ </div>
879
+ );
880
+ }
881
+
882
+ // ── 错误行(照搬 msg.type === "error" 分支)─────────────────────────────────
883
+
884
+ export function ErrorBlock({
885
+ title,
886
+ body,
887
+ onRetry,
888
+ }: {
889
+ title: string;
890
+ body?: string;
891
+ onRetry?: () => void;
892
+ }) {
893
+ const [expanded, setExpanded] = useState(false);
894
+ return (
895
+ <div className="group/work max-w-[860px] text-[14px] leading-5 tracking-[-0.25px] text-kumo-subtle">
896
+ <div className="flex w-full items-center gap-2 px-1.5 py-1">
897
+ <button
898
+ type="button"
899
+ onClick={() => setExpanded((value) => !value)}
900
+ className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 rounded-md text-left transition-colors duration-150 ease-out hover:text-kumo-default focus-visible:text-kumo-default focus-visible:outline-none active:scale-[0.995]"
901
+ aria-expanded={expanded}
902
+ >
903
+ <span
904
+ className="flex h-5 w-5 flex-shrink-0 items-center justify-center text-kumo-danger"
905
+ aria-hidden="true"
906
+ >
907
+ <WarningCircle size={16} weight="fill" />
908
+ </span>
909
+ <span className="flex min-w-0 flex-1 items-center gap-1">
910
+ <span className="min-w-0 truncate">
911
+ <span className="font-medium text-kumo-danger">{title}: </span>
912
+ <span className="text-kumo-subtle">{body}</span>
913
+ </span>
914
+ {body && (
915
+ <CaretRight
916
+ size={13}
917
+ weight="bold"
918
+ className={`flex-shrink-0 text-kumo-inactive transition-transform duration-150 ease-out ${expanded ? "rotate-90" : ""}`}
919
+ />
920
+ )}
921
+ </span>
922
+ </button>
923
+ {onRetry && (
924
+ <Tooltip content="Retry the last action." asChild>
925
+ <button
926
+ type="button"
927
+ onClick={onRetry}
928
+ className="flex flex-shrink-0 cursor-pointer items-center gap-1 rounded-md px-1 py-0.5 text-[13px] leading-4 font-medium text-kumo-default transition-[color,opacity,transform] duration-150 ease-out hover:text-kumo-default-hover focus-visible:text-kumo-default-hover focus-visible:outline-none active:scale-[0.98]"
929
+ >
930
+ Retry
931
+ </button>
932
+ </Tooltip>
933
+ )}
934
+ </div>
935
+ {expanded && body && (
936
+ <div className="ml-8 mt-1">
937
+ <pre className="max-h-48 overflow-auto rounded-xl border border-kumo-line/70 bg-kumo-base p-3 font-mono text-[12px] leading-[18px] text-kumo-subtle whitespace-pre-wrap">
938
+ {body}
939
+ </pre>
940
+ </div>
941
+ )}
942
+ </div>
943
+ );
944
+ }