@springbrand/message-panel 0.1.3-alpha.3 → 0.1.3-alpha.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/cloud-os/README.md +6 -6
  2. package/cloud-os/assets/followup-arrow.svg +3 -0
  3. package/cloud-os/assets/loading-corner.svg +3 -0
  4. package/cloud-os/assets/loading-mark.svg +16 -0
  5. package/cloud-os/assets/loading-spark.svg +3 -0
  6. package/cloud-os/assets/model-selected.svg +5 -0
  7. package/cloud-os/assets/thinking-spark.svg +9 -0
  8. package/cloud-os/capability-chip.tsx +35 -0
  9. package/cloud-os/chat/activity-indicator.tsx +2 -2
  10. package/cloud-os/chat/cloud-os-chat-messages.tsx +374 -63
  11. package/cloud-os/chat/image-generation.tsx +76 -0
  12. package/cloud-os/chat/loading-state.tsx +25 -0
  13. package/cloud-os/chat/markdown-message.tsx +144 -41
  14. package/cloud-os/chat/rich-blocks.tsx +548 -246
  15. package/cloud-os/chat/tool-presentation.ts +35 -16
  16. package/cloud-os/chat/tool-rows.tsx +330 -85
  17. package/cloud-os/chat/transcript-model.ts +428 -56
  18. package/cloud-os/composer/cloud-os-chat-input.tsx +228 -142
  19. package/cloud-os/composer/cloud-os-model-select.tsx +105 -0
  20. package/cloud-os/file-view.tsx +441 -0
  21. package/cloud-os/index.ts +35 -3
  22. package/cloud-os/layout/cloud-os-workspace-split.tsx +58 -15
  23. package/cloud-os/primitives/workshop-controls.tsx +2 -2
  24. package/cloud-os/styles/cloud-os.css +100 -20
  25. package/demo/camel-chat-showcase.tsx +21 -13
  26. package/demo/chat-scenarios.ts +223 -43
  27. package/demo/cloud-os-chat-showcase.tsx +47 -14
  28. package/demo/fixtures.ts +4 -5
  29. package/demo/message-panel-gallery.tsx +16 -2
  30. package/package.json +3 -4
  31. package/src/camel/camel-chat-messages.tsx +78 -78
  32. package/src/camel/camel-tool-presentation.tsx +19 -15
  33. package/src/camel/camel-turn.ts +1 -17
  34. package/src/composer/composer-attachments.tsx +1 -1
  35. package/src/composer/composer-trigger-popover.tsx +1 -1
  36. package/src/composer/composer.tsx +2 -2
  37. package/src/contracts.ts +0 -2
  38. package/src/message-panel.tsx +1 -24
  39. package/src/parts/index.tsx +103 -64
@@ -1,14 +1,18 @@
1
1
  import {
2
+ ArrowLeft,
3
+ ArrowRight,
2
4
  Bell,
3
5
  CaretRight,
4
6
  Check,
7
+ CheckCircle,
5
8
  CircleNotch,
9
+ Clock,
10
+ Key,
6
11
  ListChecks,
7
- ShieldCheck,
8
12
  UsersThree,
9
13
  WarningCircle,
10
14
  } from "@phosphor-icons/react";
11
- import { useState } from "react";
15
+ import { useRef, useState } from "react";
12
16
  import { Tooltip } from "../primitives/tooltip";
13
17
  import { WorkshopButton } from "../primitives/workshop-controls";
14
18
  import { MarkdownMessage } from "./markdown-message";
@@ -18,13 +22,18 @@ import type {
18
22
  PlanStep,
19
23
  SubAgentView,
20
24
  } from "./transcript-model";
21
- import { askUserOutcome, type CloudOsToolCall } from "./tool-presentation";
25
+ import {
26
+ askUserOutcome,
27
+ humanizeToolName,
28
+ safeStringify,
29
+ type CloudOsToolCall,
30
+ } from "./tool-presentation";
22
31
 
23
32
  /**
24
33
  * UIMessage 协议里存在、gadgets 那套没有的几类 part —— 计划快照、原位提问、
25
34
  * 后续建议、定时任务、并行/子 Agent、原位授权。视觉语言全部沿用 cloud-os:
26
35
  * 折叠工作行用 `px-1.5 py-1` 的行式布局,卡片用
27
- * `themed-surface-inset rounded-2xl border-kumo-line/70 bg-kumo-elevated/45 p-3`,
36
+ * `rounded-2xl border-kumo-line/70 bg-kumo-elevated/45 p-3`,
28
37
  * 授权卡沿用 renderActionCard 的 blocking callout(brand/40 边 + brand/10 底)。
29
38
  */
30
39
 
@@ -33,13 +42,17 @@ import { askUserOutcome, type CloudOsToolCall } from "./tool-presentation";
33
42
  export function PlanBlock({
34
43
  steps,
35
44
  running,
45
+ defaultOpen = true,
46
+ showCollapsedSummary = true,
36
47
  }: {
37
48
  steps: PlanStep[];
38
49
  running: boolean;
50
+ defaultOpen?: boolean;
51
+ showCollapsedSummary?: boolean;
39
52
  }) {
40
53
  const done = steps.filter((step) => step.status === "done").length;
41
54
  const current = steps.find((step) => step.status === "in_progress");
42
- const [open, setOpen] = useState(true);
55
+ const [open, setOpen] = useState(defaultOpen);
43
56
 
44
57
  return (
45
58
  <div className="group -ml-0.5">
@@ -63,7 +76,7 @@ export function PlanBlock({
63
76
  className={`flex-shrink-0 text-kumo-inactive transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`}
64
77
  />
65
78
  </span>
66
- {!open && current && (
79
+ {!open && showCollapsedSummary && current && (
67
80
  <span className="mt-1 block truncate font-mono text-[12px] leading-4 text-kumo-inactive">
68
81
  {current.text}
69
82
  </span>
@@ -71,35 +84,34 @@ export function PlanBlock({
71
84
  </span>
72
85
  </button>
73
86
  {open && (
74
- <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">
87
+ <div className="ml-8 mt-1 flex flex-col gap-2 rounded-xl border border-kumo-line bg-[rgba(0,0,0,0.03)] px-4 py-3">
75
88
  {steps.map((step, index) => (
76
89
  <div
77
90
  key={`${step.text}-${index}`}
78
- className="flex items-start gap-2.5 text-[13px] leading-[19px] tracking-[-0.25px]"
91
+ className="flex items-center gap-2 text-[14px] leading-[1.4] font-normal tracking-normal"
79
92
  >
80
93
  <span
81
- className="mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center"
94
+ className="flex size-4 flex-shrink-0 items-center justify-center"
82
95
  aria-hidden="true"
83
96
  >
84
97
  {step.status === "done" ? (
85
- <Check size={12} weight="bold" className="text-kumo-success" />
98
+ <CheckCircle size={16} weight="fill" className="text-[#00bf75]" />
86
99
  ) : step.status === "in_progress" ? (
87
100
  <CircleNotch
88
- size={12}
89
- weight="bold"
90
- className="animate-spin text-kumo-brand motion-reduce:animate-none"
101
+ size={16}
102
+ className="animate-spin text-[#ff9938] motion-reduce:animate-none"
91
103
  />
92
104
  ) : (
93
- <span className="h-1.5 w-1.5 rounded-full bg-kumo-fill" />
105
+ <span className="h-1.5 w-1.5 rounded-full bg-[color:var(--text-color-kumo-default)] opacity-20" />
94
106
  )}
95
107
  </span>
96
108
  <span
97
109
  className={
98
110
  step.status === "done"
99
- ? "min-w-0 flex-1 text-kumo-inactive line-through"
111
+ ? "min-w-0 flex-1 text-kumo-default/30 line-through"
100
112
  : step.status === "in_progress"
101
- ? "min-w-0 flex-1 font-medium text-kumo-default"
102
- : "min-w-0 flex-1 text-kumo-subtle"
113
+ ? "min-w-0 flex-1 text-kumo-default"
114
+ : "min-w-0 flex-1 text-kumo-default/70"
103
115
  }
104
116
  >
105
117
  {step.text}
@@ -118,33 +130,32 @@ interface AskQuestion {
118
130
  prompt: string;
119
131
  options: string[];
120
132
  multi: boolean;
133
+ allowCustom: boolean;
121
134
  }
122
135
 
123
136
  function questionsOf(input: Record<string, unknown>): AskQuestion[] {
124
- if (Array.isArray(input.questions)) {
125
- return input.questions.map((item) => {
126
- const question = (item ?? {}) as Record<string, unknown>;
127
- return {
128
- prompt: String(question.prompt ?? question.question ?? "Choose an option"),
129
- options: Array.isArray(question.options)
130
- ? question.options.filter((o): o is string => typeof o === "string")
131
- : [],
132
- multi: question.kind === "multi" || question.multiSelect === true,
133
- };
134
- });
135
- }
136
- if (typeof input.question === "string") {
137
- return [
138
- {
139
- prompt: input.question,
140
- options: Array.isArray(input.options)
141
- ? input.options.filter((o): o is string => typeof o === "string")
142
- : [],
143
- multi: input.multiSelect === true,
144
- },
145
- ];
146
- }
147
- return [];
137
+ if (!Array.isArray(input.questions)) return [];
138
+ return input.questions.map((item) => {
139
+ const question = (item ?? {}) as Record<string, unknown>;
140
+ const options = Array.isArray(question.options)
141
+ ? question.options.filter((option): option is string =>
142
+ typeof option === "string"
143
+ )
144
+ : [];
145
+ return {
146
+ prompt: typeof question.question === "string"
147
+ ? question.question
148
+ : "Choose an option",
149
+ options,
150
+ multi: question.multiSelect === true,
151
+ allowCustom: question.allowCustom === true || options.length === 0,
152
+ };
153
+ });
154
+ }
155
+
156
+ interface DraftAnswer {
157
+ selections: string[];
158
+ text: string;
148
159
  }
149
160
 
150
161
  export function AskUserBlock({
@@ -156,37 +167,54 @@ export function AskUserBlock({
156
167
  onRespond?: (toolCallId: string, response: unknown) => Promise<boolean>;
157
168
  }) {
158
169
  const questions = questionsOf(call.input);
159
- // 不能用 `useState(() => questions.map(() => []))` 初始化:工具入参是**流式**到达的,
160
- // 首帧 questions 往往还是空数组,那之后每次 setSelections 都在空数组上 map,
170
+ // 不能用 `useState(() => questions.map(...))` 初始化:工具入参是**流式**到达的,
171
+ // 首帧 questions 往往还是空数组,那之后每次 setAnswers 都在空数组上 map,
161
172
  // 选择永远存不进去 → Submit 永久禁用。改成稀疏存 + 按当前 questions 补齐。
162
- const [selections, setSelections] = useState<string[][]>([]);
163
- const selectionAt = (index: number) => selections[index] ?? [];
173
+ const [answers, setAnswers] = useState<DraftAnswer[]>([]);
174
+ const [current, setCurrent] = useState(0);
175
+ const direction = useRef<"back" | "forward">("forward");
176
+ const answerAt = (index: number): DraftAnswer =>
177
+ answers[index] ?? { selections: [], text: "" };
164
178
  const toggle = (questionIndex: number, option: string, multi: boolean) =>
165
- setSelections((current) => {
166
- const next = questions.map((_, index) => current[index] ?? []);
167
- const values = next[questionIndex] ?? [];
168
- next[questionIndex] = !multi
169
- ? [option]
170
- : values.includes(option)
171
- ? values.filter((value) => value !== option)
172
- : [...values, option];
179
+ setAnswers((current) => {
180
+ const next = questions.map((_, index) =>
181
+ current[index] ?? { selections: [], text: "" }
182
+ );
183
+ const answer = next[questionIndex]!;
184
+ next[questionIndex] = {
185
+ ...answer,
186
+ selections: !multi
187
+ ? [option]
188
+ : answer.selections.includes(option)
189
+ ? answer.selections.filter((value) => value !== option)
190
+ : [...answer.selections, option],
191
+ };
192
+ return next;
193
+ });
194
+ const setText = (questionIndex: number, text: string) =>
195
+ setAnswers((current) => {
196
+ const next = questions.map((_, index) =>
197
+ current[index] ?? { selections: [], text: "" }
198
+ );
199
+ next[questionIndex] = { ...next[questionIndex]!, text };
173
200
  return next;
174
201
  });
175
202
  // 在途只用于按钮转圈;权威状态永远是 part 的 state。
176
203
  const [inFlight, setInFlight] = useState(false);
177
204
  const outcome = askUserOutcome(call);
178
- const answer = outcome.kind === "answered" ? outcome : null;
179
- const answeredSelections = new Set(answer?.selections ?? []);
180
- const answeredText = [
181
- ...(answer?.selections ?? []),
182
- ...(answer?.text.trim() ? [answer.text.trim()] : []),
183
- ].join(" / ");
205
+ const settledAnswers = outcome.kind === "answered" ? outcome.answers : [];
184
206
  // 服务端已经结算 = 这张卡永久不可操作,不管结算成什么。
185
207
  const resolved = outcome.kind !== "pending" || inFlight;
186
208
  const valid = questions.length > 0 && questions.every(
187
- (question, index) =>
188
- question.options.length === 0 || selectionAt(index).length > 0,
209
+ (_question, index) =>
210
+ answerAt(index).selections.length > 0 || answerAt(index).text.trim(),
189
211
  );
212
+ const currentIndex = Math.min(current, Math.max(questions.length - 1, 0));
213
+ const currentQuestion = questions[currentIndex];
214
+ const currentAnswer = answerAt(currentIndex);
215
+ const currentValid = currentAnswer.selections.length > 0 ||
216
+ Boolean(currentAnswer.text.trim());
217
+ const isLast = currentIndex === questions.length - 1;
190
218
  const buttonLabel = outcome.kind === "answered"
191
219
  ? "Submitted"
192
220
  : outcome.kind === "closed"
@@ -194,32 +222,30 @@ export function AskUserBlock({
194
222
  : inFlight
195
223
  ? "Submitting…"
196
224
  : "Submit";
197
- const footnote = outcome.kind === "answered"
198
- ? answeredText && `Answered: ${answeredText}`
199
- : outcome.kind === "closed"
225
+ const footnote = outcome.kind === "closed"
200
226
  ? outcome.reason === "user_replied_freeform"
201
227
  ? "Answered in the chat instead."
202
228
  : "Closed without an answer."
203
229
  : "";
204
230
 
205
- // 提交按钮固定在卡片右下角,和脚注同一行 —— 脚注不再单独占一行,多问题时
206
- // 按钮也不会卡在某一题的选项中间。选项没到(流式首帧 questions 为空)时这行
207
- // 照样渲染,否则用户看到的是一张空卡。
208
- const submit = (
231
+ const submit = () => {
232
+ if (!onRespond) return;
233
+ setInFlight(true);
234
+ // 失败就把按钮放回可点:权威状态由 part 决定,这里只管别把用户锁死。
235
+ void onRespond(call.toolCallId, {
236
+ answers: questions.map((_, index) => ({
237
+ selections: answerAt(index).selections,
238
+ text: answerAt(index).text.trim(),
239
+ })),
240
+ }).then((ok) => {
241
+ if (!ok) setInFlight(false);
242
+ }, () => setInFlight(false));
243
+ };
244
+
245
+ const settledButton = (
209
246
  <WorkshopButton
210
247
  tone="primary"
211
- disabled={resolved || !valid || !onRespond}
212
- onClick={() => {
213
- if (!onRespond) return;
214
- setInFlight(true);
215
- // 失败就把按钮放回可点:权威状态由 part 决定,这里只管别把用户锁死。
216
- void onRespond(call.toolCallId, {
217
- selections: questions.flatMap((_, index) => selectionAt(index)),
218
- text: "",
219
- }).then((ok) => {
220
- if (!ok) setInFlight(false);
221
- });
222
- }}
248
+ disabled
223
249
  className="ml-auto !h-7 flex-shrink-0 gap-1 !rounded-lg px-2.5 text-[12px]"
224
250
  >
225
251
  {buttonLabel}
@@ -228,52 +254,193 @@ export function AskUserBlock({
228
254
 
229
255
  return (
230
256
  // 收成 w-fit:短问题不该撑成一条横贯全栏的长条,长问题到 560px 再换行。
231
- <div className="w-fit min-w-[15rem] max-w-[560px]">
232
- {/* 组内(问题↔选项)10px、组间(题与题、题与按钮行)12px —— 组间必须比组内
233
- 松,否则多问题时上一题的选项会看起来像下一题的。
234
- flex+gap 而不是 space-y-*:Tailwind v4 space-y 选择器裹在
235
- `:where()` 里,特异性被抹平,会被子元素自己的 `m-0` 压掉 → 不生效。 */}
236
- <div className="flex flex-col gap-3 rounded-xl border border-kumo-line bg-kumo-base px-3 py-2.5">
237
- {questions.map((question, questionIndex) => (
238
- <div
239
- key={`${question.prompt}:${questionIndex}`}
240
- className="flex flex-col gap-2.5"
241
- >
242
- <p className="m-0 text-[13px] leading-[18px] font-medium tracking-[-0.25px] text-kumo-default">
243
- {question.prompt}
244
- </p>
245
- <div className="flex flex-wrap items-center gap-1.5">
246
- {question.options.map((option) => {
247
- const selected = answer
248
- ? answeredSelections.has(option)
249
- : selectionAt(questionIndex).includes(option);
250
- return (
251
- <button
252
- key={option}
253
- type="button"
254
- disabled={resolved || !onRespond}
255
- onClick={() => toggle(questionIndex, option, question.multi)}
256
- className={`inline-flex h-7 cursor-pointer items-center rounded-lg border px-2.5 text-[13px] leading-none tracking-[-0.25px] transition-colors duration-150 ease-out disabled:cursor-default ${
257
- selected
258
- ? "border-kumo-brand/45 bg-kumo-brand/10 text-kumo-default"
259
- : "border-kumo-line bg-kumo-base text-kumo-subtle hover:bg-kumo-tint hover:text-kumo-default"
260
- }`}
261
- >
262
- {option}
263
- </button>
264
- );
265
- })}
257
+ <div
258
+ className="w-fit min-w-[15rem] max-w-[560px]"
259
+ data-part-type="ask_user"
260
+ data-state={outcome.kind === "answered" ? "submitted" : outcome.kind}
261
+ >
262
+ <div className="flex flex-col gap-3 rounded-xl border border-kumo-line bg-operation-base px-3 py-2.5">
263
+ {outcome.kind === "pending" ? (
264
+ <>
265
+ {questions.length > 1 && (
266
+ <div className="flex flex-col gap-2">
267
+ <div className="flex items-center justify-between text-[11px] font-medium leading-4 text-kumo-inactive">
268
+ <span>Clarifying questions</span>
269
+ <span className="tabular-nums">
270
+ {currentIndex + 1}/{questions.length}
271
+ </span>
272
+ </div>
273
+ <div className="flex gap-1" aria-hidden="true">
274
+ {questions.map((question, index) => (
275
+ <span
276
+ key={`${question.prompt}:${index}`}
277
+ className={`h-1 flex-1 rounded-full ${
278
+ index < currentIndex
279
+ ? "bg-kumo-brand"
280
+ : index === currentIndex
281
+ ? "bg-kumo-brand/60"
282
+ : "bg-kumo-fill"
283
+ }`}
284
+ />
285
+ ))}
286
+ </div>
287
+ </div>
288
+ )}
289
+ {currentQuestion && (
290
+ <div className="overflow-hidden">
291
+ <div
292
+ key={currentIndex}
293
+ className="cos-ask-user-question flex flex-col gap-2.5"
294
+ data-direction={direction.current}
295
+ >
296
+ <p className="m-0 text-[13px] leading-[18px] font-medium tracking-[-0.25px] text-kumo-default">
297
+ {currentQuestion.prompt}
298
+ </p>
299
+ {currentQuestion.options.length > 0 && (
300
+ <ul className="m-0 flex list-none flex-col gap-1.5 p-0">
301
+ {currentQuestion.options.map((option) => {
302
+ const selected = currentAnswer.selections.includes(
303
+ option,
304
+ );
305
+ return (
306
+ <li key={option} className="m-0 p-0">
307
+ <label
308
+ 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 ${
309
+ resolved || !onRespond
310
+ ? "cursor-default text-kumo-inactive"
311
+ : "cursor-pointer text-kumo-subtle hover:bg-kumo-tint hover:text-kumo-default"
312
+ } ${
313
+ selected
314
+ ? "border-kumo-brand/45 bg-kumo-brand/10 text-kumo-default"
315
+ : "border-kumo-line bg-operation-base"
316
+ }`}
317
+ >
318
+ <input
319
+ type={currentQuestion.multi
320
+ ? "checkbox"
321
+ : "radio"}
322
+ name={`${call.toolCallId}-${currentIndex}`}
323
+ checked={selected}
324
+ disabled={resolved || !onRespond}
325
+ onChange={() =>
326
+ toggle(
327
+ currentIndex,
328
+ option,
329
+ currentQuestion.multi,
330
+ )}
331
+ className="h-4 w-4 shrink-0 accent-[var(--color-kumo-brand)]"
332
+ />
333
+ <span>{option}</span>
334
+ </label>
335
+ </li>
336
+ );
337
+ })}
338
+ </ul>
339
+ )}
340
+ {currentQuestion.allowCustom && (
341
+ <label className="flex flex-col gap-1 text-[12px] leading-4 text-kumo-subtle">
342
+ <span>
343
+ {currentQuestion.options.length > 0 ? "Other" : "Answer"}
344
+ </span>
345
+ <input
346
+ type="text"
347
+ value={currentAnswer.text}
348
+ maxLength={2_000}
349
+ disabled={resolved || !onRespond}
350
+ aria-label={`自定义回答:${currentQuestion.prompt}`}
351
+ placeholder={currentQuestion.options.length > 0
352
+ ? "Add a custom answer"
353
+ : "Type your answer"}
354
+ onChange={(event) =>
355
+ setText(currentIndex, event.currentTarget.value)}
356
+ 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"
357
+ />
358
+ </label>
359
+ )}
360
+ </div>
361
+ </div>
362
+ )}
363
+ <div className="flex items-center gap-2">
364
+ {questions.length > 1 && (
365
+ <WorkshopButton
366
+ disabled={currentIndex === 0 || resolved}
367
+ onClick={() => {
368
+ direction.current = "back";
369
+ setCurrent((index) => Math.max(0, index - 1));
370
+ }}
371
+ className="!h-7 gap-1 !rounded-lg px-2.5 text-[12px]"
372
+ >
373
+ <ArrowLeft size={13} weight="bold" />
374
+ Back
375
+ </WorkshopButton>
376
+ )}
377
+ <WorkshopButton
378
+ tone="primary"
379
+ disabled={
380
+ resolved || !onRespond ||
381
+ (questions.length > 1 && !isLast ? !currentValid : !valid)
382
+ }
383
+ onClick={() => {
384
+ if (questions.length > 1 && !isLast) {
385
+ direction.current = "forward";
386
+ setCurrent((index) =>
387
+ Math.min(questions.length - 1, index + 1)
388
+ );
389
+ return;
390
+ }
391
+ submit();
392
+ }}
393
+ className="ml-auto !h-7 gap-1 !rounded-lg px-2.5 text-[12px]"
394
+ >
395
+ {questions.length > 1 && !isLast ? (
396
+ <>
397
+ Next
398
+ <ArrowRight size={13} weight="bold" />
399
+ </>
400
+ ) : buttonLabel}
401
+ </WorkshopButton>
266
402
  </div>
267
- </div>
268
- ))}
269
- <div className="flex items-center gap-3">
270
- {footnote && (
271
- <p className="m-0 min-w-0 text-[12px] leading-4 text-kumo-inactive">
272
- {footnote}
273
- </p>
274
- )}
275
- {submit}
276
- </div>
403
+ </>
404
+ ) : (
405
+ <>
406
+ {outcome.kind === "answered" &&
407
+ questions.map((question, questionIndex) => (
408
+ <div
409
+ key={`${question.prompt}:${questionIndex}`}
410
+ className="flex flex-col gap-1.5"
411
+ >
412
+ <p className="m-0 text-[13px] leading-[18px] font-medium tracking-[-0.25px] text-kumo-default">
413
+ {question.prompt}
414
+ </p>
415
+ {settledAnswers[questionIndex] && (
416
+ <div className="flex flex-col gap-1 text-[12px] leading-4 text-kumo-inactive">
417
+ {settledAnswers[questionIndex]!.selections.length > 0 && (
418
+ <p className="m-0">
419
+ Answered: {settledAnswers[questionIndex]!.selections
420
+ .join(" / ")}
421
+ </p>
422
+ )}
423
+ {settledAnswers[questionIndex]!.text.trim() && (
424
+ <p className="m-0">
425
+ {settledAnswers[questionIndex]!.selections.length > 0
426
+ ? `补充:${settledAnswers[questionIndex]!.text.trim()}`
427
+ : `Answered: ${settledAnswers[questionIndex]!.text.trim()}`}
428
+ </p>
429
+ )}
430
+ </div>
431
+ )}
432
+ </div>
433
+ ))}
434
+ <div className="flex items-center gap-3">
435
+ {footnote && (
436
+ <p className="m-0 min-w-0 text-[12px] leading-4 text-kumo-inactive">
437
+ {footnote}
438
+ </p>
439
+ )}
440
+ {settledButton}
441
+ </div>
442
+ </>
443
+ )}
277
444
  </div>
278
445
  </div>
279
446
  );
@@ -289,23 +456,26 @@ export function SuggestionsBlock({
289
456
  onSuggestion: (text: string) => void;
290
457
  }) {
291
458
  return (
292
- <div className="max-w-[860px] space-y-2 pt-1">
293
- <div className="flex items-center gap-2 px-1.5 text-[13px] leading-[18px] text-kumo-success">
294
- <Check size={14} weight="bold" />
295
- <span>Task complete</span>
296
- </div>
297
- <div className="flex flex-wrap gap-1.5 px-1.5">
298
- {items.map((item) => (
299
- <button
300
- key={item}
301
- type="button"
302
- onClick={() => onSuggestion(item)}
303
- 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]"
304
- >
305
- {item}
306
- </button>
307
- ))}
308
- </div>
459
+ <div className="flex max-w-[860px] flex-col items-start gap-3">
460
+ {items.map((item) => (
461
+ <button
462
+ key={item}
463
+ type="button"
464
+ onClick={() => onSuggestion(item)}
465
+ className="inline-flex cursor-pointer items-center gap-2 text-[14px] leading-[1.4] text-kumo-inactive transition-colors duration-150 ease-out hover:text-kumo-brand focus-visible:text-kumo-brand focus-visible:outline-none active:scale-[0.98]"
466
+ >
467
+ <span className="grid size-5 flex-shrink-0 place-items-center" aria-hidden="true">
468
+ <span className="rotate-180 -scale-y-100">
469
+ <img
470
+ src={new URL("../assets/followup-arrow.svg", import.meta.url).href}
471
+ alt=""
472
+ className="h-[8.33333px] w-[11.6667px]"
473
+ />
474
+ </span>
475
+ </span>
476
+ <span className="whitespace-nowrap">{item}</span>
477
+ </button>
478
+ ))}
309
479
  </div>
310
480
  );
311
481
  }
@@ -350,13 +520,73 @@ function scheduleCadence(input: Record<string, unknown>): string {
350
520
  return timezone ? `${cadence} · ${timezone}` : cadence;
351
521
  }
352
522
 
353
- /** `schedule` 工具的 details 是 `{ scheduled: true, id }`,id 即任务在 SchedulerAgent 里的主键。 */
523
+ /** `schedule` 工具的 details 是 `{ scheduled: true, id }`,id Inbox Schedule Definition 主键。 */
354
524
  function scheduleIdOf(output: unknown): string {
355
525
  if (typeof output !== "object" || output === null) return "";
356
526
  const id = (output as Record<string, unknown>).id;
357
527
  return typeof id === "string" ? id : "";
358
528
  }
359
529
 
530
+ export function ScheduleConfirmation({
531
+ input,
532
+ disabled = false,
533
+ onCancel,
534
+ onConfirm,
535
+ }: {
536
+ input: Record<string, unknown>;
537
+ disabled?: boolean;
538
+ onCancel: () => void;
539
+ onConfirm: () => void;
540
+ }) {
541
+ const name = String(input.label ?? input.title ?? "Scheduled task");
542
+ const prompt = typeof input.prompt === "string" && input.prompt.trim()
543
+ ? input.prompt.trim()
544
+ : "Run the scheduled task";
545
+
546
+ return (
547
+ <section
548
+ data-slot="schedule-card"
549
+ aria-label={`Confirm scheduled task: ${name}`}
550
+ className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-kumo-line/70 bg-operation-base 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)]"
551
+ >
552
+ <div className="flex items-center gap-2.5">
553
+ <span
554
+ className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-kumo-fill/70 text-kumo-subtle"
555
+ aria-hidden="true"
556
+ >
557
+ <Clock size={14} />
558
+ </span>
559
+ <div className="flex min-w-0 flex-1 flex-col">
560
+ <span className="truncate text-[13.5px] leading-[18px] font-medium">
561
+ {name}
562
+ </span>
563
+ <span className="truncate font-mono text-[11px] leading-4 tracking-tight text-kumo-inactive">
564
+ {scheduleCadence(input)}
565
+ </span>
566
+ </div>
567
+ </div>
568
+
569
+ <div className="flex items-start gap-2 rounded-xl bg-kumo-fill/50 px-3 py-2">
570
+ <span className="shrink-0 font-mono text-[11px] leading-[18px] tracking-tight text-kumo-inactive">
571
+ task
572
+ </span>
573
+ <span className="min-w-0 flex-1 text-[13px] leading-[18px] text-kumo-subtle">
574
+ {prompt}
575
+ </span>
576
+ </div>
577
+
578
+ <div className="flex items-center justify-end gap-2">
579
+ <WorkshopButton disabled={disabled} onClick={onCancel}>
580
+ Cancel
581
+ </WorkshopButton>
582
+ <WorkshopButton tone="primary" disabled={disabled} onClick={onConfirm}>
583
+ {disabled ? "Confirming…" : "Confirm schedule"}
584
+ </WorkshopButton>
585
+ </div>
586
+ </section>
587
+ );
588
+ }
589
+
360
590
  export function ScheduleBlock({
361
591
  call,
362
592
  onOpen,
@@ -365,6 +595,8 @@ export function ScheduleBlock({
365
595
  /** 有 id 且宿主给了回调时,整张卡变成跳到「已安排」详情的按钮。 */
366
596
  onOpen?: (scheduleId: string) => void;
367
597
  }) {
598
+ // 待确认时由宿主把持久化审批渲染在输入框上方;这里不重复画第二张卡。
599
+ if (call.awaitingApproval) return null;
368
600
  const state = call.failed ? "failed" : call.running ? "running" : "scheduled";
369
601
  const scheduleId = state === "scheduled" ? scheduleIdOf(call.output) : "";
370
602
  const clickable = Boolean(scheduleId && onOpen);
@@ -379,7 +611,7 @@ export function ScheduleBlock({
379
611
  "aria-label": `Open scheduled task: ${String(call.input.label ?? call.input.title ?? "Scheduled task")}`,
380
612
  }
381
613
  : {})}
382
- className={`w-full rounded-2xl border border-kumo-line bg-kumo-base px-4 py-3 text-left${
614
+ className={`w-full rounded-2xl border border-kumo-line bg-operation-base px-4 py-3 text-left${
383
615
  clickable
384
616
  ? " 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"
385
617
  : ""
@@ -432,10 +664,109 @@ export function ScheduleBlock({
432
664
  );
433
665
  }
434
666
 
435
- // ── 原位授权(照搬 renderActionCard 的 blocking callout)────────────────────
667
+ // ── 原位授权 ──────────────────────────────────────────────
436
668
 
437
669
  export type ApprovalDecision = "deny" | "allow_once" | "allow_level";
438
670
 
671
+ export function PermissionGrant({
672
+ capability,
673
+ requester,
674
+ reach,
675
+ disabled = false,
676
+ onGrant,
677
+ "aria-label": ariaLabel = `${capability} permission request`,
678
+ }: {
679
+ capability: string;
680
+ requester: string;
681
+ reach: readonly string[];
682
+ disabled?: boolean;
683
+ onGrant: (decision: ApprovalDecision) => void;
684
+ "aria-label"?: string;
685
+ }) {
686
+ const actionClassName =
687
+ "inline-flex h-10 w-[120px] items-center justify-center rounded-lg border border-kumo-line px-4 text-[14px] leading-[1.4] font-medium 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";
688
+
689
+ return (
690
+ <section
691
+ aria-label={ariaLabel}
692
+ className="w-full rounded-xl border border-kumo-line bg-operation-base p-4 text-kumo-default"
693
+ >
694
+ <div className="flex items-center gap-2">
695
+ <span
696
+ className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-kumo-fill text-kumo-subtle"
697
+ aria-hidden="true"
698
+ >
699
+ <Key size={14} weight="bold" />
700
+ </span>
701
+ <div className="flex min-w-0 flex-1 flex-col">
702
+ <span className="truncate text-[14px] leading-[1.4] font-medium">
703
+ {capability}
704
+ </span>
705
+ <span className="truncate text-[12px] leading-4 text-kumo-inactive">
706
+ requested by {requester}
707
+ </span>
708
+ </div>
709
+ </div>
710
+
711
+ <div className="mt-4 flex max-h-[200px] flex-col gap-2 overflow-y-auto">
712
+ <span className="text-[12px] leading-4 text-kumo-inactive">
713
+ this grants
714
+ </span>
715
+ {reach.map((item, index) => (
716
+ <span
717
+ key={`${index}:${item}`}
718
+ className="flex items-baseline gap-2 text-[14px] leading-[1.4] text-kumo-subtle"
719
+ >
720
+ <span aria-hidden="true">•</span>
721
+ <span className="min-w-0 break-words whitespace-pre-wrap">{item}</span>
722
+ </span>
723
+ ))}
724
+ </div>
725
+
726
+ <div className="mt-4 flex flex-wrap items-center gap-2">
727
+ <Tooltip
728
+ content="Allow every action at this execution level, without future prompts."
729
+ asChild
730
+ >
731
+ <button
732
+ type="button"
733
+ disabled={disabled}
734
+ onClick={() => onGrant("allow_level")}
735
+ className={`${actionClassName} border-0 bg-[linear-gradient(90deg,#ffd077_0%,#ff9938_100%)] text-kumo-inverse enabled:hover:brightness-95`}
736
+ >
737
+ Allow level
738
+ </button>
739
+ </Tooltip>
740
+ <button
741
+ type="button"
742
+ disabled={disabled}
743
+ onClick={() => onGrant("allow_once")}
744
+ className={`${actionClassName} text-kumo-subtle enabled:hover:bg-kumo-fill enabled:hover:text-kumo-default`}
745
+ >
746
+ Allow once
747
+ </button>
748
+ <button
749
+ type="button"
750
+ disabled={disabled}
751
+ onClick={() => onGrant("deny")}
752
+ className={`${actionClassName} text-kumo-subtle enabled:hover:bg-kumo-fill enabled:hover:text-kumo-default`}
753
+ >
754
+ Deny
755
+ </button>
756
+ </div>
757
+ </section>
758
+ );
759
+ }
760
+
761
+ function approvalReach(call: CloudOsToolCall): string[] {
762
+ const entries = Object.entries(call.input);
763
+ return entries.length > 0
764
+ ? entries.map(
765
+ ([name, value]) => `${humanizeToolName(name)}: ${safeStringify(value)}`,
766
+ )
767
+ : ["Run this tool without arguments"];
768
+ }
769
+
439
770
  export function ApprovalBlock({
440
771
  call,
441
772
  approvalId,
@@ -447,70 +778,15 @@ export function ApprovalBlock({
447
778
  disabled?: boolean;
448
779
  onApprove: (approvalId: string, decision: ApprovalDecision) => void;
449
780
  }) {
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
781
  return (
457
782
  <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>
783
+ <PermissionGrant
784
+ capability={humanizeToolName(call.toolName)}
785
+ requester="the agent"
786
+ reach={approvalReach(call)}
787
+ disabled={disabled}
788
+ onGrant={(decision) => onApprove(approvalId, decision)}
789
+ />
514
790
  </div>
515
791
  );
516
792
  }
@@ -542,67 +818,93 @@ export function ParallelBlock({
542
818
  tools: ParallelTool[];
543
819
  }) {
544
820
  const done = tools.filter((tool) => tool.status === "done").length;
821
+ const [open, setOpen] = useState(false);
545
822
  return (
546
823
  <div className="max-w-[860px]">
547
- <div className="flex items-center gap-3 px-1.5 py-1 text-[14px] leading-5 tracking-[-0.25px] text-kumo-subtle">
824
+ <button
825
+ type="button"
826
+ onClick={() => setOpen((value) => !value)}
827
+ className="flex cursor-pointer items-center gap-3 px-1.5 py-1 text-[14px] leading-5 tracking-[-0.25px] text-kumo-subtle transition-colors duration-150 ease-out hover:text-kumo-default focus-visible:text-kumo-default focus-visible:outline-none"
828
+ aria-expanded={open}
829
+ >
548
830
  <span className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
549
831
  <WorkIcon Icon={ListChecks} />
550
832
  </span>
551
833
  <span className="min-w-0 truncate">
552
834
  {label} · {done}/{tools.length}
553
835
  </span>
554
- </div>
555
- <div className="ml-8 mt-1 space-y-1">
556
- {tools.map((tool, index) => (
557
- <div
558
- key={`${tool.label}-${index}`}
559
- className="flex items-center gap-2 px-1.5 text-[13px] leading-[19px] tracking-[-0.25px] text-kumo-subtle"
560
- >
561
- <StatusDot status={tool.status} />
562
- <span className="min-w-0 flex-1 truncate">{tool.label}</span>
563
- {tool.meta && (
564
- <span className="flex-shrink-0 font-mono text-[11px] leading-4 text-kumo-inactive">
565
- {tool.meta}
566
- </span>
567
- )}
568
- </div>
569
- ))}
570
- </div>
836
+ <CaretRight
837
+ size={13}
838
+ weight="bold"
839
+ className={`flex-shrink-0 text-kumo-inactive transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`}
840
+ />
841
+ </button>
842
+ {open && (
843
+ <div className="ml-8 mt-1 space-y-1">
844
+ {tools.map((tool, index) => (
845
+ <div
846
+ key={`${tool.label}-${index}`}
847
+ className="flex items-center gap-2 px-1.5 text-[13px] leading-[19px] tracking-[-0.25px] text-kumo-subtle"
848
+ >
849
+ <StatusDot status={tool.status} />
850
+ <span className="min-w-0 flex-1 truncate">{tool.label}</span>
851
+ {tool.meta && (
852
+ <span className="flex-shrink-0 font-mono text-[11px] leading-4 text-kumo-inactive">
853
+ {tool.meta}
854
+ </span>
855
+ )}
856
+ </div>
857
+ ))}
858
+ </div>
859
+ )}
571
860
  </div>
572
861
  );
573
862
  }
574
863
 
575
864
  export function SubAgentsBlock({ agents }: { agents: SubAgentView[] }) {
865
+ const [open, setOpen] = useState(false);
576
866
  return (
577
867
  <div className="max-w-[860px]">
578
- <div className="flex items-center gap-3 px-1.5 py-1 text-[14px] leading-5 tracking-[-0.25px] text-kumo-subtle">
868
+ <button
869
+ type="button"
870
+ onClick={() => setOpen((value) => !value)}
871
+ className="flex cursor-pointer items-center gap-3 px-1.5 py-1 text-[14px] leading-5 tracking-[-0.25px] text-kumo-subtle transition-colors duration-150 ease-out hover:text-kumo-default focus-visible:text-kumo-default focus-visible:outline-none"
872
+ aria-expanded={open}
873
+ >
579
874
  <span className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
580
875
  <WorkIcon Icon={UsersThree} />
581
876
  </span>
582
877
  <span className="min-w-0 truncate">
583
878
  {agents.length === 1 ? "Sub-agent" : `${agents.length} sub-agents`}
584
879
  </span>
585
- </div>
586
- <div className="ml-8 mt-1 space-y-1">
587
- {agents.map((agent, index) => (
588
- <div
589
- key={`${agent.name}-${index}`}
590
- className="flex items-start gap-2 px-1.5 text-[13px] leading-[19px] tracking-[-0.25px]"
591
- >
592
- <span className="mt-1.5 flex">
593
- <StatusDot status={agent.status} />
594
- </span>
595
- <span className="min-w-0 flex-1">
596
- <span className="block truncate font-medium text-kumo-default">
597
- {agent.name}
880
+ <CaretRight
881
+ size={13}
882
+ weight="bold"
883
+ className={`flex-shrink-0 text-kumo-inactive transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`}
884
+ />
885
+ </button>
886
+ {open && (
887
+ <div className="ml-8 mt-1 space-y-1">
888
+ {agents.map((agent, index) => (
889
+ <div
890
+ key={`${agent.name}-${index}`}
891
+ className="flex items-start gap-2 px-1.5 text-[13px] leading-[19px] tracking-[-0.25px]"
892
+ >
893
+ <span className="mt-1.5 flex">
894
+ <StatusDot status={agent.status} />
598
895
  </span>
599
- {agent.summary && (
600
- <span className="block text-kumo-subtle">{agent.summary}</span>
601
- )}
602
- </span>
603
- </div>
604
- ))}
605
- </div>
896
+ <span className="min-w-0 flex-1">
897
+ <span className="block truncate font-medium text-kumo-default">
898
+ {agent.name}
899
+ </span>
900
+ {agent.summary && (
901
+ <span className="block text-kumo-subtle">{agent.summary}</span>
902
+ )}
903
+ </span>
904
+ </div>
905
+ ))}
906
+ </div>
907
+ )}
606
908
  </div>
607
909
  );
608
910
  }