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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/cloud-os/README.md +6 -6
  2. package/cloud-os/assets/followup-arrow.svg +3 -0
  3. package/cloud-os/assets/loading-corner.svg +3 -0
  4. package/cloud-os/assets/loading-mark.svg +16 -0
  5. package/cloud-os/assets/loading-spark.svg +3 -0
  6. package/cloud-os/assets/model-selected.svg +5 -0
  7. package/cloud-os/capability-chip.tsx +35 -0
  8. package/cloud-os/chat/activity-indicator.tsx +29 -0
  9. package/cloud-os/chat/cloud-os-chat-messages.tsx +123 -69
  10. package/cloud-os/chat/markdown-message.tsx +82 -40
  11. package/cloud-os/chat/rich-blocks.tsx +494 -190
  12. package/cloud-os/chat/tool-presentation.ts +67 -6
  13. package/cloud-os/chat/tool-rows.tsx +87 -43
  14. package/cloud-os/chat/transcript-model.ts +215 -23
  15. package/cloud-os/composer/cloud-os-chat-input.tsx +242 -127
  16. package/cloud-os/composer/cloud-os-model-select.tsx +105 -0
  17. package/cloud-os/file-view.tsx +266 -0
  18. package/cloud-os/index.ts +29 -2
  19. package/cloud-os/layout/cloud-os-workspace-split.tsx +107 -24
  20. package/cloud-os/primitives/workshop-controls.tsx +2 -2
  21. package/cloud-os/styles/cloud-os.css +93 -19
  22. package/demo/camel-chat-showcase.tsx +21 -13
  23. package/demo/chat-scenarios.ts +100 -40
  24. package/demo/cloud-os-chat-showcase.tsx +51 -17
  25. package/demo/fixtures.ts +4 -5
  26. package/demo/message-panel-gallery.tsx +16 -2
  27. package/package.json +3 -4
  28. package/src/camel/camel-chat-messages.tsx +82 -77
  29. package/src/camel/camel-prompt-input.tsx +2 -1
  30. package/src/camel/camel-tool-presentation.tsx +19 -15
  31. package/src/camel/camel-turn.ts +1 -17
  32. package/src/chat-summary-panel.tsx +1 -1
  33. package/src/composer/chat-composer.tsx +2 -1
  34. package/src/composer/composer-trigger-popover.tsx +1 -1
  35. package/src/composer/composer.tsx +2 -1
  36. package/src/composer/index.ts +1 -0
  37. package/src/composer/key-rules.ts +12 -0
  38. package/src/contracts.ts +0 -2
  39. package/src/message-panel.tsx +0 -23
  40. package/src/parts/index.tsx +102 -63
  41. package/src/styles/index.css +4 -1
@@ -1,14 +1,17 @@
1
1
  import {
2
+ ArrowLeft,
3
+ ArrowRight,
2
4
  Bell,
3
5
  CaretRight,
4
6
  Check,
5
7
  CircleNotch,
8
+ Clock,
9
+ Key,
6
10
  ListChecks,
7
- ShieldCheck,
8
11
  UsersThree,
9
12
  WarningCircle,
10
13
  } from "@phosphor-icons/react";
11
- import { useState } from "react";
14
+ import { useRef, useState } from "react";
12
15
  import { Tooltip } from "../primitives/tooltip";
13
16
  import { WorkshopButton } from "../primitives/workshop-controls";
14
17
  import { MarkdownMessage } from "./markdown-message";
@@ -18,7 +21,12 @@ import type {
18
21
  PlanStep,
19
22
  SubAgentView,
20
23
  } from "./transcript-model";
21
- import type { CloudOsToolCall } from "./tool-presentation";
24
+ import {
25
+ askUserOutcome,
26
+ humanizeToolName,
27
+ safeStringify,
28
+ type CloudOsToolCall,
29
+ } from "./tool-presentation";
22
30
 
23
31
  /**
24
32
  * UIMessage 协议里存在、gadgets 那套没有的几类 part —— 计划快照、原位提问、
@@ -118,129 +126,316 @@ interface AskQuestion {
118
126
  prompt: string;
119
127
  options: string[];
120
128
  multi: boolean;
129
+ allowCustom: boolean;
121
130
  }
122
131
 
123
132
  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 [];
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;
148
155
  }
149
156
 
150
157
  export function AskUserBlock({
151
158
  call,
152
- answeredText,
153
- onAnswer,
159
+ onRespond,
154
160
  }: {
155
161
  call: CloudOsToolCall;
156
- answeredText?: string;
157
- onAnswer: (payload: unknown) => void;
162
+ /** 缺省(宿主没接)时选项不可点 —— 形状对齐 ScheduleBlock 的 onOpen。 */
163
+ onRespond?: (toolCallId: string, response: unknown) => Promise<boolean>;
158
164
  }) {
159
165
  const questions = questionsOf(call.input);
160
- const [selections, setSelections] = useState<string[][]>(() =>
161
- questions.map(() => []),
162
- );
163
- const [submitted, setSubmitted] = useState(false);
164
- const external = answeredText?.trim() ?? "";
165
- const externalSelections = new Set(
166
- external.split("、").map((value) => value.trim()).filter(Boolean),
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(),
167
207
  );
168
- const resolved = submitted || Boolean(external);
169
- const valid = questions.every(
170
- (question, index) =>
171
- question.options.length === 0 || (selections[index]?.length ?? 0) > 0,
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>
172
249
  );
173
250
 
174
251
  return (
175
- <div className="max-w-[860px]">
176
- <div className="rounded-2xl border border-kumo-line bg-kumo-base px-4 py-3">
177
- {/* ApprovalBlock:窄栏下让提交按钮整体换行,别把问题正文挤没。 */}
178
- <div className="flex flex-wrap items-start gap-3">
179
- <span
180
- className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-kumo-tint text-kumo-brand"
181
- aria-hidden="true"
182
- >
183
- <Bell size={18} weight="fill" />
184
- </span>
185
- <div className="min-w-[12rem] flex-1 space-y-2.5">
186
- {questions.map((question, questionIndex) => (
187
- <div key={`${question.prompt}:${questionIndex}`} className="space-y-1.5">
188
- <p className="m-0 text-[14px] leading-5 font-medium tracking-[-0.25px] text-kumo-default">
189
- {question.prompt}
190
- </p>
191
- <div className="flex flex-wrap gap-1.5">
192
- {question.options.map((option) => {
193
- const selected = external
194
- ? externalSelections.has(option)
195
- : selections[questionIndex]?.includes(option);
196
- return (
197
- <button
198
- key={option}
199
- type="button"
200
- disabled={resolved}
201
- onClick={() =>
202
- setSelections((current) =>
203
- current.map((values, index) => {
204
- if (index !== questionIndex) return values;
205
- if (!question.multi) return [option];
206
- return values.includes(option)
207
- ? values.filter((value) => value !== option)
208
- : [...values, option];
209
- }),
210
- )
211
- }
212
- 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 ${
213
- selected
214
- ? "border-kumo-brand/45 bg-kumo-brand/10 text-kumo-default"
215
- : "border-kumo-line bg-kumo-base text-kumo-subtle hover:bg-kumo-tint hover:text-kumo-default"
216
- }`}
217
- >
218
- {option}
219
- </button>
220
- );
221
- })}
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
+ ))}
222
282
  </div>
223
283
  </div>
224
- ))}
225
- </div>
226
- <div className="ml-auto flex flex-shrink-0 items-center self-center">
227
- <WorkshopButton
228
- tone="primary"
229
- disabled={resolved || !valid}
230
- onClick={() => {
231
- onAnswer({ selections, text: "" });
232
- setSubmitted(true);
233
- }}
234
- className="!h-8 gap-1 !rounded-lg text-[12px]"
235
- >
236
- {resolved ? "Submitted" : "Submit"}
237
- </WorkshopButton>
238
- </div>
239
- </div>
240
- {external && (
241
- <p className="mt-2 mb-0 pl-12 text-[12px] leading-4 text-kumo-inactive">
242
- Answered: {external}
243
- </p>
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
+ </>
244
439
  )}
245
440
  </div>
246
441
  </div>
@@ -257,23 +452,26 @@ export function SuggestionsBlock({
257
452
  onSuggestion: (text: string) => void;
258
453
  }) {
259
454
  return (
260
- <div className="max-w-[860px] space-y-2 pt-1">
261
- <div className="flex items-center gap-2 px-1.5 text-[13px] leading-[18px] text-kumo-success">
262
- <Check size={14} weight="bold" />
263
- <span>Task complete</span>
264
- </div>
265
- <div className="flex flex-wrap gap-1.5 px-1.5">
266
- {items.map((item) => (
267
- <button
268
- key={item}
269
- type="button"
270
- onClick={() => onSuggestion(item)}
271
- 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]"
272
- >
273
- {item}
274
- </button>
275
- ))}
276
- </div>
455
+ <div className="flex max-w-[860px] flex-col items-start gap-3">
456
+ {items.map((item) => (
457
+ <button
458
+ key={item}
459
+ type="button"
460
+ onClick={() => onSuggestion(item)}
461
+ 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]"
462
+ >
463
+ <span className="grid size-5 flex-shrink-0 place-items-center" aria-hidden="true">
464
+ <span className="rotate-180 -scale-y-100">
465
+ <img
466
+ src={new URL("../assets/followup-arrow.svg", import.meta.url).href}
467
+ alt=""
468
+ className="h-[8.33333px] w-[11.6667px]"
469
+ />
470
+ </span>
471
+ </span>
472
+ <span className="whitespace-nowrap">{item}</span>
473
+ </button>
474
+ ))}
277
475
  </div>
278
476
  );
279
477
  }
@@ -318,13 +516,73 @@ function scheduleCadence(input: Record<string, unknown>): string {
318
516
  return timezone ? `${cadence} · ${timezone}` : cadence;
319
517
  }
320
518
 
321
- /** `schedule` 工具的 details 是 `{ scheduled: true, id }`,id 即任务在 SchedulerAgent 里的主键。 */
519
+ /** `schedule` 工具的 details 是 `{ scheduled: true, id }`,id Inbox Schedule Definition 主键。 */
322
520
  function scheduleIdOf(output: unknown): string {
323
521
  if (typeof output !== "object" || output === null) return "";
324
522
  const id = (output as Record<string, unknown>).id;
325
523
  return typeof id === "string" ? id : "";
326
524
  }
327
525
 
526
+ export function ScheduleConfirmation({
527
+ input,
528
+ disabled = false,
529
+ onCancel,
530
+ onConfirm,
531
+ }: {
532
+ input: Record<string, unknown>;
533
+ disabled?: boolean;
534
+ onCancel: () => void;
535
+ onConfirm: () => void;
536
+ }) {
537
+ const name = String(input.label ?? input.title ?? "Scheduled task");
538
+ const prompt = typeof input.prompt === "string" && input.prompt.trim()
539
+ ? input.prompt.trim()
540
+ : "Run the scheduled task";
541
+
542
+ return (
543
+ <section
544
+ data-slot="schedule-card"
545
+ aria-label={`Confirm scheduled task: ${name}`}
546
+ 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)]"
547
+ >
548
+ <div className="flex items-center gap-2.5">
549
+ <span
550
+ className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-kumo-fill/70 text-kumo-subtle"
551
+ aria-hidden="true"
552
+ >
553
+ <Clock size={14} />
554
+ </span>
555
+ <div className="flex min-w-0 flex-1 flex-col">
556
+ <span className="truncate text-[13.5px] leading-[18px] font-medium">
557
+ {name}
558
+ </span>
559
+ <span className="truncate font-mono text-[11px] leading-4 tracking-tight text-kumo-inactive">
560
+ {scheduleCadence(input)}
561
+ </span>
562
+ </div>
563
+ </div>
564
+
565
+ <div className="flex items-start gap-2 rounded-xl bg-kumo-fill/50 px-3 py-2">
566
+ <span className="shrink-0 font-mono text-[11px] leading-[18px] tracking-tight text-kumo-inactive">
567
+ task
568
+ </span>
569
+ <span className="min-w-0 flex-1 text-[13px] leading-[18px] text-kumo-subtle">
570
+ {prompt}
571
+ </span>
572
+ </div>
573
+
574
+ <div className="flex items-center justify-end gap-2">
575
+ <WorkshopButton disabled={disabled} onClick={onCancel}>
576
+ Cancel
577
+ </WorkshopButton>
578
+ <WorkshopButton tone="primary" disabled={disabled} onClick={onConfirm}>
579
+ {disabled ? "Confirming…" : "Confirm schedule"}
580
+ </WorkshopButton>
581
+ </div>
582
+ </section>
583
+ );
584
+ }
585
+
328
586
  export function ScheduleBlock({
329
587
  call,
330
588
  onOpen,
@@ -333,6 +591,8 @@ export function ScheduleBlock({
333
591
  /** 有 id 且宿主给了回调时,整张卡变成跳到「已安排」详情的按钮。 */
334
592
  onOpen?: (scheduleId: string) => void;
335
593
  }) {
594
+ // 待确认时由宿主把持久化审批渲染在输入框上方;这里不重复画第二张卡。
595
+ if (call.awaitingApproval) return null;
336
596
  const state = call.failed ? "failed" : call.running ? "running" : "scheduled";
337
597
  const scheduleId = state === "scheduled" ? scheduleIdOf(call.output) : "";
338
598
  const clickable = Boolean(scheduleId && onOpen);
@@ -400,10 +660,109 @@ export function ScheduleBlock({
400
660
  );
401
661
  }
402
662
 
403
- // ── 原位授权(照搬 renderActionCard 的 blocking callout)────────────────────
663
+ // ── 原位授权 ──────────────────────────────────────────────
404
664
 
405
665
  export type ApprovalDecision = "deny" | "allow_once" | "allow_level";
406
666
 
667
+ export function PermissionGrant({
668
+ capability,
669
+ requester,
670
+ reach,
671
+ disabled = false,
672
+ onGrant,
673
+ "aria-label": ariaLabel = `${capability} permission request`,
674
+ }: {
675
+ capability: string;
676
+ requester: string;
677
+ reach: readonly string[];
678
+ disabled?: boolean;
679
+ onGrant: (decision: ApprovalDecision) => void;
680
+ "aria-label"?: string;
681
+ }) {
682
+ const actionClassName =
683
+ "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";
684
+
685
+ return (
686
+ <section
687
+ aria-label={ariaLabel}
688
+ className="themed-surface-inset w-full rounded-xl border border-kumo-line bg-kumo-elevated p-4 text-kumo-default"
689
+ >
690
+ <div className="flex items-center gap-2">
691
+ <span
692
+ className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-kumo-fill text-kumo-subtle"
693
+ aria-hidden="true"
694
+ >
695
+ <Key size={14} weight="bold" />
696
+ </span>
697
+ <div className="flex min-w-0 flex-1 flex-col">
698
+ <span className="truncate text-[14px] leading-[1.4] font-medium">
699
+ {capability}
700
+ </span>
701
+ <span className="truncate text-[12px] leading-4 text-kumo-inactive">
702
+ requested by {requester}
703
+ </span>
704
+ </div>
705
+ </div>
706
+
707
+ <div className="mt-4 flex max-h-[200px] flex-col gap-2 overflow-y-auto">
708
+ <span className="text-[12px] leading-4 text-kumo-inactive">
709
+ this grants
710
+ </span>
711
+ {reach.map((item, index) => (
712
+ <span
713
+ key={`${index}:${item}`}
714
+ className="flex items-baseline gap-2 text-[14px] leading-[1.4] text-kumo-subtle"
715
+ >
716
+ <span aria-hidden="true">•</span>
717
+ <span className="min-w-0 break-words whitespace-pre-wrap">{item}</span>
718
+ </span>
719
+ ))}
720
+ </div>
721
+
722
+ <div className="mt-4 flex flex-wrap items-center gap-2">
723
+ <Tooltip
724
+ content="Allow every action at this execution level, without future prompts."
725
+ asChild
726
+ >
727
+ <button
728
+ type="button"
729
+ disabled={disabled}
730
+ onClick={() => onGrant("allow_level")}
731
+ className={`${actionClassName} border-0 bg-[linear-gradient(90deg,#ffd077_0%,#ff9938_100%)] text-kumo-inverse enabled:hover:brightness-95`}
732
+ >
733
+ Allow level
734
+ </button>
735
+ </Tooltip>
736
+ <button
737
+ type="button"
738
+ disabled={disabled}
739
+ onClick={() => onGrant("allow_once")}
740
+ className={`${actionClassName} text-kumo-subtle enabled:hover:bg-kumo-fill enabled:hover:text-kumo-default`}
741
+ >
742
+ Allow once
743
+ </button>
744
+ <button
745
+ type="button"
746
+ disabled={disabled}
747
+ onClick={() => onGrant("deny")}
748
+ className={`${actionClassName} text-kumo-subtle enabled:hover:bg-kumo-fill enabled:hover:text-kumo-default`}
749
+ >
750
+ Deny
751
+ </button>
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
+
407
766
  export function ApprovalBlock({
408
767
  call,
409
768
  approvalId,
@@ -415,70 +774,15 @@ export function ApprovalBlock({
415
774
  disabled?: boolean;
416
775
  onApprove: (approvalId: string, decision: ApprovalDecision) => void;
417
776
  }) {
418
- const title = `${call.toolName} needs approval`;
419
- const description =
420
- Object.keys(call.input).length > 0
421
- ? "```json\n" + JSON.stringify(call.input, null, 2) + "\n```"
422
- : "This action pauses the turn until you decide.";
423
-
424
777
  return (
425
778
  <div className="group/work max-w-[860px] text-[14px] leading-5 tracking-[-0.25px] text-kumo-subtle">
426
- {/* 原文件的 blocking callout 是「图标 + 正文 + 右侧动作」一行到底。聊天栏默认
427
- 只有 420px 宽,一行放不下三段,正文会被挤成 0 宽 —— 所以外层允许换行,
428
- 动作组在挤不下时整体落到下一行右对齐。 */}
429
- <div className="rounded-2xl border border-kumo-brand/40 bg-kumo-brand/10 px-4 py-3">
430
- <div className="flex flex-wrap items-start gap-3">
431
- <span
432
- className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-kumo-tint text-kumo-brand"
433
- aria-hidden="true"
434
- >
435
- <ShieldCheck size={20} weight="fill" />
436
- </span>
437
- <div className="min-w-[12rem] flex-1">
438
- <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
439
- <span className="min-w-0 truncate font-medium text-kumo-default">
440
- {title}
441
- </span>
442
- </div>
443
- <div className="cos-chat-panel cos-markdown mt-1 max-h-[200px] overflow-y-auto pr-1 text-[13px] leading-[18px] text-kumo-subtle">
444
- <MarkdownMessage message={description} />
445
- </div>
446
- </div>
447
- <div className="ml-auto flex flex-wrap items-center justify-end gap-1 self-center">
448
- <Tooltip content="Reject this action and let the agent continue without it." asChild>
449
- <WorkshopButton
450
- tone="secondary"
451
- disabled={disabled}
452
- onClick={() => onApprove(approvalId, "deny")}
453
- className="!h-8 !rounded-lg text-[12px]"
454
- >
455
- Deny
456
- </WorkshopButton>
457
- </Tooltip>
458
- <Tooltip content="Allow just this call." asChild>
459
- <WorkshopButton
460
- tone="secondary"
461
- disabled={disabled}
462
- onClick={() => onApprove(approvalId, "allow_once")}
463
- className="!h-8 !rounded-lg text-[12px]"
464
- >
465
- Allow once
466
- </WorkshopButton>
467
- </Tooltip>
468
- <Tooltip content="Allow every action at this execution level, without future prompts." asChild>
469
- <WorkshopButton
470
- tone="primary"
471
- disabled={disabled}
472
- onClick={() => onApprove(approvalId, "allow_level")}
473
- className="!h-8 gap-1 !rounded-lg text-[12px]"
474
- >
475
- <Check size={11} weight="bold" />
476
- Allow level
477
- </WorkshopButton>
478
- </Tooltip>
479
- </div>
480
- </div>
481
- </div>
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
+ />
482
786
  </div>
483
787
  );
484
788
  }