@springbrand/message-panel 0.1.3-alpha.31 → 0.1.3-alpha.32

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.
@@ -57,6 +57,9 @@ import {
57
57
  type CloudOsEntry,
58
58
  type CloudOsActionPresentation,
59
59
  } from "./transcript-model";
60
+ import { useDisclosureExpandedKeys } from "./use-lifecycle-disclosure";
61
+
62
+ const NO_RUNNING_DISCLOSURES: ReadonlySet<string> = new Set();
60
63
 
61
64
  export type CloudOsChatStatus = "ready" | "submitted" | "streaming" | "error";
62
65
 
@@ -113,18 +116,6 @@ export interface CloudOsChatMessagesProps {
113
116
  onCopy?: (text: string) => void;
114
117
  }
115
118
 
116
- export function collapseEntriesWithNewNarrative(
117
- expanded: ReadonlySet<string>,
118
- previousNarrativeKeys: ReadonlySet<string>,
119
- narrativeKeys: ReadonlySet<string>,
120
- ): ReadonlySet<string> {
121
- const next = new Set(expanded);
122
- for (const key of narrativeKeys) {
123
- if (!previousNarrativeKeys.has(key)) next.delete(key);
124
- }
125
- return next.size === expanded.size ? expanded : next;
126
- }
127
-
128
119
  function useCopyAction(): {
129
120
  copied: string | null;
130
121
  copy: (text: string) => void;
@@ -362,15 +353,6 @@ export function CloudOsChatMessages({
362
353
  const scrollRef = useRef<HTMLDivElement>(null);
363
354
  const endRef = useRef<HTMLDivElement>(null);
364
355
  const [showScrollButton, setShowScrollButton] = useState(false);
365
- const [expandedToolCalls, setExpandedToolCalls] = useState<ReadonlySet<string>>(
366
- () => new Set(),
367
- );
368
- const [expandedWorkEntries, setExpandedWorkEntries] = useState<
369
- ReadonlySet<string>
370
- >(() => new Set());
371
- const [expandedCompactions, setExpandedCompactions] = useState<
372
- ReadonlySet<string>
373
- >(() => new Set());
374
356
  const { copied, copy } = useCopyAction();
375
357
  const isActive =
376
358
  turnActive ?? (status === "submitted" || status === "streaming");
@@ -385,31 +367,74 @@ export function CloudOsChatMessages({
385
367
  }),
386
368
  [approvalIdOf, isActive, messages, showThinkingTraces],
387
369
  );
388
- const narrativeEntryKeys = useMemo(
370
+ const workEntryKeys = useMemo(
389
371
  () =>
390
372
  new Set(
391
373
  entries.flatMap((entry) =>
392
374
  entry.type === "assistant" &&
393
- partitionAssistantBlocks(entry.blocks).content.some(
394
- (block) => block.kind === "text",
395
- )
375
+ partitionAssistantBlocks(entry.blocks).work.length > 0
396
376
  ? [entry.key]
397
377
  : [],
398
378
  ),
399
379
  ),
400
380
  [entries],
401
381
  );
402
- const previousNarrativeEntryKeys = useRef<ReadonlySet<string>>(
403
- narrativeEntryKeys,
404
- );
405
-
406
- useEffect(() => {
407
- const previous = previousNarrativeEntryKeys.current;
408
- previousNarrativeEntryKeys.current = narrativeEntryKeys;
409
- setExpandedWorkEntries((expanded) =>
410
- collapseEntriesWithNewNarrative(expanded, previous, narrativeEntryKeys),
382
+ const runningWorkEntryKeys = useMemo(() => {
383
+ const keys = new Set<string>();
384
+ if (!isActive) return keys;
385
+ const entry = entries.findLast(
386
+ (candidate) => candidate.type === "assistant",
411
387
  );
412
- }, [narrativeEntryKeys]);
388
+ if (entry?.type !== "assistant") return keys;
389
+ const { work, content } = partitionAssistantBlocks(entry.blocks);
390
+ if (work.length > 0 && !content.some((block) => block.kind === "text")) {
391
+ keys.add(entry.key);
392
+ }
393
+ return keys;
394
+ }, [entries, isActive]);
395
+ const toolCallKeys = useMemo(
396
+ () =>
397
+ new Set(
398
+ entries.flatMap((entry) =>
399
+ entry.type === "assistant"
400
+ ? entry.blocks.flatMap((block) =>
401
+ block.kind === "toolGroup" ? [block.group.key] : [],
402
+ )
403
+ : [],
404
+ ),
405
+ ),
406
+ [entries],
407
+ );
408
+ const runningToolCallKeys = useMemo(
409
+ () =>
410
+ new Set(
411
+ entries.flatMap((entry) =>
412
+ entry.type === "assistant" && runningWorkEntryKeys.has(entry.key)
413
+ ? entry.blocks.flatMap((block) =>
414
+ block.kind === "toolGroup" && block.group.hasRunning
415
+ ? [block.group.key]
416
+ : [],
417
+ )
418
+ : [],
419
+ ),
420
+ ),
421
+ [entries, runningWorkEntryKeys],
422
+ );
423
+ const compactionKeys = useMemo(
424
+ () =>
425
+ new Set(
426
+ entries.flatMap((entry) =>
427
+ entry.type === "compactionBoundary" ? [entry.key] : [],
428
+ ),
429
+ ),
430
+ [entries],
431
+ );
432
+ const [expandedToolCalls, toggleToolCall] =
433
+ useDisclosureExpandedKeys(toolCallKeys, runningToolCallKeys);
434
+ const [expandedWorkEntries, toggleWorkEntry] =
435
+ useDisclosureExpandedKeys(workEntryKeys, runningWorkEntryKeys);
436
+ const [expandedCompactions, toggleCompaction] =
437
+ useDisclosureExpandedKeys(compactionKeys, NO_RUNNING_DISCLOSURES);
413
438
 
414
439
  const entryTopClasses = useMemo(
415
440
  () =>
@@ -419,33 +444,6 @@ export function CloudOsChatMessages({
419
444
  [entries],
420
445
  );
421
446
 
422
- const toggleToolCall = useCallback((key: string) => {
423
- setExpandedToolCalls((current) => {
424
- const next = new Set(current);
425
- if (next.has(key)) next.delete(key);
426
- else next.add(key);
427
- return next;
428
- });
429
- }, []);
430
-
431
- const toggleWorkEntry = useCallback((key: string) => {
432
- setExpandedWorkEntries((current) => {
433
- const next = new Set(current);
434
- if (next.has(key)) next.delete(key);
435
- else next.add(key);
436
- return next;
437
- });
438
- }, []);
439
-
440
- const toggleCompaction = useCallback((key: string) => {
441
- setExpandedCompactions((current) => {
442
- const next = new Set(current);
443
- if (next.has(key)) next.delete(key);
444
- else next.add(key);
445
- return next;
446
- });
447
- }, []);
448
-
449
447
  const syncScrollButton = useCallback(() => {
450
448
  const element = scrollRef.current;
451
449
  if (!element) return;
@@ -628,7 +626,7 @@ export function CloudOsChatMessages({
628
626
  <WorkTraceDisclosure
629
627
  durationMs={entry.durationMs}
630
628
  groups={workGroups}
631
- completed={!isActive}
629
+ completed={!runningWorkEntryKeys.has(entry.key)}
632
630
  open={expandedWorkEntries.has(entry.key)}
633
631
  onToggle={() => toggleWorkEntry(entry.key)}
634
632
  >
@@ -640,6 +638,7 @@ export function CloudOsChatMessages({
640
638
  <WorkDescriptionRow
641
639
  key={block.key}
642
640
  text={block.text}
641
+ running={runningWorkEntryKeys.has(entry.key)}
643
642
  resolveUrl={resolveUrl}
644
643
  />
645
644
  );
@@ -658,8 +657,9 @@ export function CloudOsChatMessages({
658
657
  <PlanBlock
659
658
  key={block.key}
660
659
  steps={block.steps}
661
- running={block.running}
662
- defaultOpen={false}
660
+ running={
661
+ runningWorkEntryKeys.has(entry.key) && block.running
662
+ }
663
663
  showCollapsedSummary={false}
664
664
  />
665
665
  );
@@ -669,6 +669,7 @@ export function CloudOsChatMessages({
669
669
  key={block.key}
670
670
  label={block.label}
671
671
  tools={block.tools}
672
+ running={runningWorkEntryKeys.has(entry.key)}
672
673
  />
673
674
  );
674
675
  case "subagents":
@@ -676,6 +677,7 @@ export function CloudOsChatMessages({
676
677
  <SubAgentsBlock
677
678
  key={block.key}
678
679
  agents={block.agents}
680
+ running={runningWorkEntryKeys.has(entry.key)}
679
681
  />
680
682
  );
681
683
  default:
@@ -692,6 +694,7 @@ export function CloudOsChatMessages({
692
694
  <WorkDescriptionRow
693
695
  key={block.key}
694
696
  text={block.text}
697
+ running={block.running}
695
698
  resolveUrl={resolveUrl}
696
699
  />
697
700
  );
@@ -730,7 +733,9 @@ export function CloudOsChatMessages({
730
733
  <PlanBlock
731
734
  key={block.key}
732
735
  steps={block.steps}
733
- running={block.running}
736
+ running={
737
+ runningWorkEntryKeys.has(entry.key) && block.running
738
+ }
734
739
  />
735
740
  );
736
741
  case "askUser":
@@ -742,13 +747,7 @@ export function CloudOsChatMessages({
742
747
  />
743
748
  );
744
749
  case "suggestions":
745
- return (
746
- <SuggestionsBlock
747
- key={block.key}
748
- items={block.items}
749
- onSuggestion={(text) => onSuggestion?.(text)}
750
- />
751
- );
750
+ return null;
752
751
  case "schedule":
753
752
  return (
754
753
  <ScheduleBlock
@@ -785,11 +784,16 @@ export function CloudOsChatMessages({
785
784
  key={block.key}
786
785
  label={block.label}
787
786
  tools={block.tools}
787
+ running={runningWorkEntryKeys.has(entry.key)}
788
788
  />
789
789
  );
790
790
  case "subagents":
791
791
  return (
792
- <SubAgentsBlock key={block.key} agents={block.agents} />
792
+ <SubAgentsBlock
793
+ key={block.key}
794
+ agents={block.agents}
795
+ running={runningWorkEntryKeys.has(entry.key)}
796
+ />
793
797
  );
794
798
  case "image":
795
799
  {
@@ -870,7 +874,7 @@ export function CloudOsChatMessages({
870
874
  )}
871
875
 
872
876
  {hasNarrative && entry.timestamp !== undefined && (
873
- <div className="mt-0.5 -ml-1 flex items-center gap-1 opacity-0 transition-opacity duration-150 ease-out group-hover/agentMessage:opacity-100 group-focus-within/agentMessage:opacity-100">
877
+ <div className="mt-0.5 -ml-1 flex items-center gap-1">
874
878
  <Tooltip
875
879
  content={copied === entry.copyText ? "Copied!" : "Copy message"}
876
880
  asChild
@@ -895,6 +899,16 @@ export function CloudOsChatMessages({
895
899
  </Tooltip>
896
900
  </div>
897
901
  )}
902
+
903
+ {contentBlocks.map((block) =>
904
+ block.kind === "suggestions" ? (
905
+ <SuggestionsBlock
906
+ key={block.key}
907
+ items={block.items}
908
+ onSuggestion={(text) => onSuggestion?.(text)}
909
+ />
910
+ ) : null
911
+ )}
898
912
  </div>
899
913
  </div>
900
914
  );
@@ -1,6 +1,4 @@
1
1
  import {
2
- ArrowLeft,
3
- ArrowRight,
4
2
  Bell,
5
3
  CaretRight,
6
4
  Check,
@@ -12,11 +10,16 @@ import {
12
10
  UsersThree,
13
11
  WarningCircle,
14
12
  } from "@phosphor-icons/react";
15
- import { useRef, useState } from "react";
13
+ import { useId, useState } from "react";
16
14
  import { Tooltip } from "../primitives/tooltip";
17
15
  import { WorkshopButton } from "../primitives/workshop-controls";
18
16
  import { MarkdownMessage } from "./markdown-message";
19
- import { WorkIcon } from "./tool-rows";
17
+ import {
18
+ SECONDARY_DISCLOSURE_ICON,
19
+ SECONDARY_DISCLOSURE_TITLE_WITH_ICON,
20
+ SECONDARY_DISCLOSURE_TITLE_WITHOUT_ICON,
21
+ WorkIcon,
22
+ } from "./tool-rows";
20
23
  import type {
21
24
  ParallelTool,
22
25
  PlanStep,
@@ -28,6 +31,7 @@ import {
28
31
  safeStringify,
29
32
  type CloudOsToolCall,
30
33
  } from "./tool-presentation";
34
+ import { useLifecycleDisclosure } from "./use-lifecycle-disclosure";
31
35
 
32
36
  /**
33
37
  * UIMessage 协议里存在、gadgets 那套没有的几类 part —— 计划快照、原位提问、
@@ -42,37 +46,34 @@ import {
42
46
  export function PlanBlock({
43
47
  steps,
44
48
  running,
45
- defaultOpen = true,
46
49
  showCollapsedSummary = true,
47
50
  }: {
48
51
  steps: PlanStep[];
49
52
  running: boolean;
50
- defaultOpen?: boolean;
51
53
  showCollapsedSummary?: boolean;
52
54
  }) {
53
55
  const done = steps.filter((step) => step.status === "done").length;
54
56
  const current = steps.find((step) => step.status === "in_progress");
55
- const [open, setOpen] = useState(defaultOpen);
57
+ const [open, toggle] = useLifecycleDisclosure(running);
56
58
 
57
59
  return (
58
- <div className="group -ml-0.5">
60
+ <div className="group">
59
61
  <button
60
62
  type="button"
61
- onClick={() => setOpen((value) => !value)}
62
- 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]"
63
+ onClick={toggle}
64
+ className={`${SECONDARY_DISCLOSURE_TITLE_WITH_ICON} w-full`}
63
65
  aria-expanded={open}
64
66
  >
65
- <span className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
67
+ <span className={SECONDARY_DISCLOSURE_ICON}>
66
68
  <WorkIcon Icon={ListChecks} />
67
69
  </span>
68
70
  <span className="min-w-0 flex-1">
69
- <span className="flex min-w-0 items-center gap-2 text-[14px] leading-5 tracking-[-0.25px]">
71
+ <span className="flex min-w-0 items-center gap-2">
70
72
  <span className={`min-w-0 truncate ${running ? "cos-thinking-shimmer" : ""}`}>
71
73
  Plan · {done}/{steps.length}
72
74
  </span>
73
75
  <CaretRight
74
- size={13}
75
- weight="bold"
76
+ size={16}
76
77
  className={`flex-shrink-0 text-kumo-inactive transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`}
77
78
  />
78
79
  </span>
@@ -84,7 +85,7 @@ export function PlanBlock({
84
85
  </span>
85
86
  </button>
86
87
  {open && (
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">
88
+ <div className="mt-1 flex flex-col gap-2 rounded-xl border border-kumo-line bg-[rgba(0,0,0,0.03)] px-4 py-3">
88
89
  {steps.map((step, index) => (
89
90
  <div
90
91
  key={`${step.text}-${index}`}
@@ -171,8 +172,7 @@ export function AskUserBlock({
171
172
  // 首帧 questions 往往还是空数组,那之后每次 setAnswers 都在空数组上 map,
172
173
  // 选择永远存不进去 → Submit 永久禁用。改成稀疏存 + 按当前 questions 补齐。
173
174
  const [answers, setAnswers] = useState<DraftAnswer[]>([]);
174
- const [current, setCurrent] = useState(0);
175
- const direction = useRef<"back" | "forward">("forward");
175
+ const contentId = useId();
176
176
  const answerAt = (index: number): DraftAnswer =>
177
177
  answers[index] ?? { selections: [], text: "" };
178
178
  const toggle = (questionIndex: number, option: string, multi: boolean) =>
@@ -202,31 +202,43 @@ export function AskUserBlock({
202
202
  // 在途只用于按钮转圈;权威状态永远是 part 的 state。
203
203
  const [inFlight, setInFlight] = useState(false);
204
204
  const outcome = askUserOutcome(call);
205
- const settledAnswers = outcome.kind === "answered" ? outcome.answers : [];
205
+ const [open, toggleDisclosure] = useLifecycleDisclosure(
206
+ outcome.kind === "pending",
207
+ );
208
+ const displayAnswerAt = (index: number): DraftAnswer => {
209
+ if (outcome.kind !== "answered") return answerAt(index);
210
+ const answer = outcome.answers[index];
211
+ return {
212
+ selections: answer?.selections ?? [],
213
+ text: answer?.text ?? "",
214
+ };
215
+ };
206
216
  // 服务端已经结算 = 这张卡永久不可操作,不管结算成什么。
207
217
  const resolved = outcome.kind !== "pending" || inFlight;
208
218
  const valid = questions.length > 0 && questions.every(
209
219
  (_question, index) =>
210
220
  answerAt(index).selections.length > 0 || answerAt(index).text.trim(),
211
221
  );
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;
218
- const buttonLabel = outcome.kind === "answered"
219
- ? "Submitted"
220
- : outcome.kind === "closed"
221
- ? "Closed"
222
- : inFlight
223
- ? "Submitting…"
224
- : "Submit";
222
+ const titleBase = questions.length === 1
223
+ ? "Clarifying question"
224
+ : "Clarifying questions";
225
+ const disclosureTitle = outcome.kind === "pending"
226
+ ? questions.length === 1 && questions[0]
227
+ ? `${titleBase} ${questions[0].prompt}`
228
+ : questions.length > 1
229
+ ? `${titleBase} · ${questions.length} questions`
230
+ : titleBase
231
+ : outcome.kind === "answered"
232
+ ? questions.length > 1
233
+ ? `${titleBase} · ${questions.length} answered`
234
+ : `${titleBase} · Answered`
235
+ : `${titleBase} · Closed`;
236
+ const buttonLabel = inFlight ? "Submitting…" : "Submit";
225
237
  const footnote = outcome.kind === "closed"
226
- ? outcome.reason === "user_replied_freeform"
227
- ? "Answered in the chat instead."
228
- : "Closed without an answer."
229
- : "";
238
+ ? outcome.reason === "user_replied_freeform"
239
+ ? "Answered in the chat instead."
240
+ : "Closed without an answer."
241
+ : "";
230
242
 
231
243
  const submit = () => {
232
244
  if (!onRespond) return;
@@ -242,205 +254,129 @@ export function AskUserBlock({
242
254
  }, () => setInFlight(false));
243
255
  };
244
256
 
245
- const settledButton = (
257
+ const closedButton = (
246
258
  <WorkshopButton
247
259
  tone="primary"
248
260
  disabled
249
261
  className="ml-auto !h-7 flex-shrink-0 gap-1 !rounded-lg px-2.5 text-[12px]"
250
262
  >
251
- {buttonLabel}
263
+ Closed
252
264
  </WorkshopButton>
253
265
  );
254
266
 
255
267
  return (
256
- // 收成 w-fit:短问题不该撑成一条横贯全栏的长条,长问题到 560px 再换行。
257
268
  <div
258
- className="w-fit min-w-[15rem] max-w-[560px]"
269
+ className="w-full"
259
270
  data-part-type="ask_user"
260
271
  data-state={outcome.kind === "answered" ? "submitted" : outcome.kind}
261
272
  >
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}
273
+ <div className="flex flex-col gap-3 py-2.5">
274
+ <button
275
+ type="button"
276
+ onClick={toggleDisclosure}
277
+ aria-expanded={open}
278
+ aria-controls={contentId}
279
+ title={disclosureTitle}
280
+ className={`${SECONDARY_DISCLOSURE_TITLE_WITHOUT_ICON} max-w-full`}
281
+ >
282
+ <span className="min-w-0 truncate">{disclosureTitle}</span>
283
+ <CaretRight
284
+ size={16}
285
+ className={`shrink-0 transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`}
286
+ />
287
+ </button>
288
+ <div
289
+ id={contentId}
290
+ data-ask-user-content=""
291
+ className={open ? "flex flex-col gap-3" : "hidden"}
292
+ >
293
+ {outcome.kind !== "closed" ? (
294
+ <>
295
+ <div className="flex flex-col gap-4">
296
+ {questions.map((question, questionIndex) => {
297
+ const answer = displayAnswerAt(questionIndex);
298
+ return (
299
+ <div
300
+ key={`${question.prompt}:${questionIndex}`}
301
+ className="flex flex-col items-start gap-2"
302
+ >
303
+ <p className="m-0 text-[14px] leading-[1.4] font-normal text-black">
304
+ {question.prompt}
305
+ </p>
306
+ {question.options.length > 0 && (
307
+ <div className="flex flex-wrap items-center gap-2">
308
+ {question.options.map((option) => {
309
+ const selected = answer.selections.includes(option);
310
+ return (
311
+ <button
312
+ key={option}
313
+ type="button"
314
+ aria-pressed={selected}
324
315
  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>
402
- </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>
316
+ onClick={() =>
317
+ toggle(questionIndex, option, question.multi)}
318
+ className={`inline-flex w-fit items-center justify-center rounded-lg border px-4 py-2 text-left text-[14px] leading-[1.4] font-normal text-[rgba(0,0,0,0.7)] transition-[background,border-color] duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#ff9938]/30 ${
319
+ selected
320
+ ? `${resolved || !onRespond ? "cursor-default" : "cursor-pointer"} border-[#ff9938] bg-[#fff]`
321
+ : resolved
322
+ ? "cursor-default border-[rgba(0,0,0,0.07)] bg-[#fff]"
323
+ : !onRespond
324
+ ? "cursor-default border-[rgba(0,0,0,0.07)] bg-[#fff] opacity-60"
325
+ : "cursor-pointer border-[rgba(0,0,0,0.07)] bg-[#fff] hover:border-transparent hover:bg-[linear-gradient(#fff,#fff)_padding-box,linear-gradient(90deg,#ff9938,#ffe46c)_border-box]"
326
+ }`}
327
+ >
328
+ {option}
329
+ </button>
330
+ );
331
+ })}
332
+ </div>
422
333
  )}
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>
334
+ {question.allowCustom && (
335
+ <label className="flex w-full flex-col gap-2">
336
+ <span className="m-0 text-[14px] leading-[1.4] font-normal text-black">
337
+ {question.options.length > 0 ? "Other" : "Answer"}
338
+ </span>
339
+ <input
340
+ type="text"
341
+ value={answer.text}
342
+ maxLength={2_000}
343
+ disabled={resolved || !onRespond}
344
+ aria-label={`自定义回答:${question.prompt}`}
345
+ placeholder={question.options.length > 0
346
+ ? "Add a custom answer"
347
+ : "Type your answer"}
348
+ onChange={(event) =>
349
+ setText(questionIndex, event.currentTarget.value)}
350
+ className="rounded-lg border border-[rgba(0,0,0,0.07)] bg-[#fff] px-4 py-2 text-[14px] leading-[1.4] font-normal text-[rgba(0,0,0,0.7)] outline-none placeholder:text-kumo-inactive focus:border-[#ff9938] focus:ring-2 focus:ring-[#ff9938]/30 disabled:cursor-default"
351
+ />
352
+ </label>
429
353
  )}
430
354
  </div>
431
- )}
432
- </div>
433
- ))}
355
+ );
356
+ })}
357
+ </div>
358
+ {outcome.kind === "pending" && (
359
+ <button
360
+ type="button"
361
+ disabled={resolved || !onRespond || !valid}
362
+ onClick={submit}
363
+ className="inline-flex h-10 min-w-[120px] self-start items-center justify-center rounded-lg bg-gradient-to-r from-[#ff9938] to-[#ffe46c] px-5 py-2.5 text-[14px] leading-5 font-medium text-black transition-opacity duration-150 ease-out hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#ff9938]/30 disabled:cursor-default disabled:opacity-50"
364
+ >
365
+ {buttonLabel}
366
+ </button>
367
+ )}
368
+ </>
369
+ ) : (
434
370
  <div className="flex items-center gap-3">
435
371
  {footnote && (
436
372
  <p className="m-0 min-w-0 text-[12px] leading-4 text-kumo-inactive">
437
373
  {footnote}
438
374
  </p>
439
375
  )}
440
- {settledButton}
376
+ {closedButton}
441
377
  </div>
442
- </>
443
- )}
378
+ )}
379
+ </div>
444
380
  </div>
445
381
  </div>
446
382
  );
@@ -813,34 +749,35 @@ function StatusDot({ status }: { status: ParallelTool["status"] }) {
813
749
  export function ParallelBlock({
814
750
  label,
815
751
  tools,
752
+ running = tools.some((tool) => tool.status === "running"),
816
753
  }: {
817
754
  label: string;
818
755
  tools: ParallelTool[];
756
+ running?: boolean;
819
757
  }) {
820
758
  const done = tools.filter((tool) => tool.status === "done").length;
821
- const [open, setOpen] = useState(false);
759
+ const [open, toggle] = useLifecycleDisclosure(running);
822
760
  return (
823
761
  <div className="max-w-[860px]">
824
762
  <button
825
763
  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"
764
+ onClick={toggle}
765
+ className={SECONDARY_DISCLOSURE_TITLE_WITH_ICON}
828
766
  aria-expanded={open}
829
767
  >
830
- <span className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
768
+ <span className={SECONDARY_DISCLOSURE_ICON}>
831
769
  <WorkIcon Icon={ListChecks} />
832
770
  </span>
833
771
  <span className="min-w-0 truncate">
834
772
  {label} · {done}/{tools.length}
835
773
  </span>
836
774
  <CaretRight
837
- size={13}
838
- weight="bold"
775
+ size={16}
839
776
  className={`flex-shrink-0 text-kumo-inactive transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`}
840
777
  />
841
778
  </button>
842
779
  {open && (
843
- <div className="ml-8 mt-1 space-y-1">
780
+ <div className="mt-1 space-y-1">
844
781
  {tools.map((tool, index) => (
845
782
  <div
846
783
  key={`${tool.label}-${index}`}
@@ -861,30 +798,35 @@ export function ParallelBlock({
861
798
  );
862
799
  }
863
800
 
864
- export function SubAgentsBlock({ agents }: { agents: SubAgentView[] }) {
865
- const [open, setOpen] = useState(false);
801
+ export function SubAgentsBlock({
802
+ agents,
803
+ running = agents.some((agent) => agent.status === "running"),
804
+ }: {
805
+ agents: SubAgentView[];
806
+ running?: boolean;
807
+ }) {
808
+ const [open, toggle] = useLifecycleDisclosure(running);
866
809
  return (
867
810
  <div className="max-w-[860px]">
868
811
  <button
869
812
  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"
813
+ onClick={toggle}
814
+ className={SECONDARY_DISCLOSURE_TITLE_WITH_ICON}
872
815
  aria-expanded={open}
873
816
  >
874
- <span className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
817
+ <span className={SECONDARY_DISCLOSURE_ICON}>
875
818
  <WorkIcon Icon={UsersThree} />
876
819
  </span>
877
820
  <span className="min-w-0 truncate">
878
821
  {agents.length === 1 ? "Sub-agent" : `${agents.length} sub-agents`}
879
822
  </span>
880
823
  <CaretRight
881
- size={13}
882
- weight="bold"
824
+ size={16}
883
825
  className={`flex-shrink-0 text-kumo-inactive transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`}
884
826
  />
885
827
  </button>
886
828
  {open && (
887
- <div className="ml-8 mt-1 space-y-1">
829
+ <div className="mt-1 space-y-1">
888
830
  {agents.map((agent, index) => (
889
831
  <div
890
832
  key={`${agent.name}-${index}`}
@@ -920,31 +862,30 @@ export function ErrorBlock({
920
862
  body?: string;
921
863
  onRetry?: () => void;
922
864
  }) {
923
- const [expanded, setExpanded] = useState(false);
865
+ const [expanded, toggle] = useLifecycleDisclosure(false);
924
866
  return (
925
867
  <div className="group/work max-w-[860px] text-[14px] leading-5 tracking-[-0.25px] text-kumo-subtle">
926
- <div className="flex w-full items-center gap-2 px-1.5 py-1">
868
+ <div className="flex w-full items-center gap-2">
927
869
  <button
928
870
  type="button"
929
- onClick={() => setExpanded((value) => !value)}
930
- 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]"
871
+ onClick={toggle}
872
+ className={`${SECONDARY_DISCLOSURE_TITLE_WITH_ICON} flex-1`}
931
873
  aria-expanded={expanded}
932
874
  >
933
875
  <span
934
- className="flex h-5 w-5 flex-shrink-0 items-center justify-center text-kumo-danger"
876
+ className={`${SECONDARY_DISCLOSURE_ICON} text-kumo-danger`}
935
877
  aria-hidden="true"
936
878
  >
937
- <WarningCircle size={16} weight="fill" />
879
+ <WarningCircle size={15} weight="fill" />
938
880
  </span>
939
881
  <span className="flex min-w-0 flex-1 items-center gap-1">
940
882
  <span className="min-w-0 truncate">
941
- <span className="font-medium text-kumo-danger">{title}: </span>
942
- <span className="text-kumo-subtle">{body}</span>
883
+ <span>{title}: </span>
884
+ <span>{body}</span>
943
885
  </span>
944
886
  {body && (
945
887
  <CaretRight
946
- size={13}
947
- weight="bold"
888
+ size={16}
948
889
  className={`flex-shrink-0 text-kumo-inactive transition-transform duration-150 ease-out ${expanded ? "rotate-90" : ""}`}
949
890
  />
950
891
  )}
@@ -963,7 +904,7 @@ export function ErrorBlock({
963
904
  )}
964
905
  </div>
965
906
  {expanded && body && (
966
- <div className="ml-8 mt-1">
907
+ <div className="mt-1">
967
908
  <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">
968
909
  {body}
969
910
  </pre>
@@ -1,6 +1,6 @@
1
1
  import { Atom, CaretDown, CaretRight, Copy } from "@phosphor-icons/react";
2
2
  import { motion, useReducedMotion } from "motion/react";
3
- import { memo, useId, useState, type ReactNode } from "react";
3
+ import { memo, useId, type ReactNode } from "react";
4
4
  import {
5
5
  MarkdownMessage,
6
6
  type CloudOsUrlResolver,
@@ -13,12 +13,25 @@ import {
13
13
  type PhosphorIcon,
14
14
  type ToolCallGroup,
15
15
  } from "./tool-presentation";
16
+ import { useLifecycleDisclosure } from "./use-lifecycle-disclosure";
16
17
 
17
18
  const thinkingSpark = new URL(
18
19
  "../assets/thinking-spark.svg",
19
20
  import.meta.url,
20
21
  ).href;
21
22
 
23
+ const SECONDARY_DISCLOSURE_TITLE_BASE =
24
+ "flex min-w-0 cursor-pointer gap-2 rounded-xl text-left text-[14px] leading-[1.4] text-cos-work-description transition-colors duration-150 ease-out hover:text-cos-work-description-subtle focus-visible:text-cos-work-description-subtle focus-visible:outline-none active:scale-[0.995]";
25
+
26
+ export const SECONDARY_DISCLOSURE_TITLE_WITH_ICON =
27
+ `${SECONDARY_DISCLOSURE_TITLE_BASE} items-start px-1.5 py-1`;
28
+
29
+ export const SECONDARY_DISCLOSURE_TITLE_WITHOUT_ICON =
30
+ `${SECONDARY_DISCLOSURE_TITLE_BASE} items-center py-1 pr-1.5`;
31
+
32
+ export const SECONDARY_DISCLOSURE_ICON =
33
+ "mt-0.5 flex size-4 shrink-0 items-center justify-center";
34
+
22
35
  /**
23
36
  * 工作行(work rows): WorkIcon / ToolCallDetails / NestedToolCallRow /
24
37
  * ToolGroupRow / ThinkingTraceRow。渲染 UIMessage 归一化后的 CloudOsToolCall。
@@ -150,13 +163,13 @@ export const NestedToolCallRow = memo(function NestedToolCallRow({
150
163
  <button
151
164
  type="button"
152
165
  onClick={() => onToggle(key)}
153
- 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]"
166
+ className={`${SECONDARY_DISCLOSURE_TITLE_WITH_ICON} w-full`}
154
167
  aria-expanded={open}
155
168
  >
156
- <span className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
169
+ <span className={SECONDARY_DISCLOSURE_ICON}>
157
170
  <WorkIcon Icon={Icon} />
158
171
  </span>
159
- <span className="flex min-w-0 flex-1 items-center gap-2 text-[14px] leading-5 tracking-[-0.25px]">
172
+ <span className="flex min-w-0 flex-1 items-center gap-2">
160
173
  <span className="min-w-0 truncate">{label}</span>
161
174
  {tc.failed && (
162
175
  <span className="flex-shrink-0 rounded-full bg-kumo-danger-tint px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.04em] text-kumo-danger">
@@ -164,14 +177,13 @@ export const NestedToolCallRow = memo(function NestedToolCallRow({
164
177
  </span>
165
178
  )}
166
179
  <CaretRight
167
- size={13}
168
- weight="bold"
180
+ size={16}
169
181
  className={`flex-shrink-0 text-kumo-inactive transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`}
170
182
  />
171
183
  </span>
172
184
  </button>
173
185
  {open && (
174
- <div className="ml-8 mt-1 space-y-3">
186
+ <div className="mt-1 space-y-3">
175
187
  <ToolCallDetails toolCall={tc} />
176
188
  </div>
177
189
  )}
@@ -179,6 +191,27 @@ export const NestedToolCallRow = memo(function NestedToolCallRow({
179
191
  );
180
192
  });
181
193
 
194
+ function ThinkingContent({
195
+ id,
196
+ text,
197
+ resolveUrl,
198
+ className = "",
199
+ }: {
200
+ id?: string;
201
+ text: string;
202
+ resolveUrl?: CloudOsUrlResolver;
203
+ className?: string;
204
+ }) {
205
+ return (
206
+ <div
207
+ id={id}
208
+ className={`cos-markdown max-h-[240px] overflow-y-auto rounded-xl border border-[rgba(0,0,0,0.07)] bg-[#F2F2F2] px-4 py-3 text-[14px] leading-[1.4] text-[rgba(0,0,0,0.7)] ${className}`}
209
+ >
210
+ <MarkdownMessage message={text} resolveUrl={resolveUrl} />
211
+ </div>
212
+ );
213
+ }
214
+
182
215
  export const ThinkingTraceRow = memo(function ThinkingTraceRow({
183
216
  reasoning,
184
217
  running,
@@ -186,7 +219,7 @@ export const ThinkingTraceRow = memo(function ThinkingTraceRow({
186
219
  reasoning: string;
187
220
  running: boolean;
188
221
  }) {
189
- const [open, setOpen] = useState(false);
222
+ const [open, toggle] = useLifecycleDisclosure(running);
190
223
  const contentId = useId();
191
224
  const summary = reasoning.replace(/\s+/g, " ").trim();
192
225
 
@@ -198,22 +231,22 @@ export const ThinkingTraceRow = memo(function ThinkingTraceRow({
198
231
  >
199
232
  <button
200
233
  type="button"
201
- onClick={() => setOpen((current) => !current)}
202
- className="group -ml-0.5 flex w-fit max-w-[80%] min-w-0 items-center gap-1.5 rounded-xl px-1.5 py-1 text-left text-[14px] leading-5 text-kumo-inactive transition-colors hover:text-kumo-default focus-visible:text-kumo-default focus-visible:outline-none"
234
+ onClick={toggle}
235
+ className={`${SECONDARY_DISCLOSURE_TITLE_WITH_ICON} group w-fit max-w-[80%]`}
203
236
  aria-expanded={open}
204
237
  aria-controls={contentId}
205
238
  >
206
- <span className="relative grid size-4 flex-shrink-0 place-items-center" aria-hidden="true">
239
+ <span className={`${SECONDARY_DISCLOSURE_ICON} relative`} aria-hidden="true">
207
240
  <Atom
208
- size={14}
241
+ size={15}
209
242
  className="transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0"
210
243
  />
211
244
  <CaretDown
212
- size={14}
245
+ size={15}
213
246
  className={`absolute opacity-0 transition-[opacity,transform] group-hover:opacity-100 group-focus-visible:opacity-100 ${open ? "" : "-rotate-90"}`}
214
247
  />
215
248
  </span>
216
- <span className="flex-shrink-0 text-kumo-subtle">Think</span>
249
+ <span className="flex-shrink-0">Think</span>
217
250
  <span aria-hidden="true">·</span>
218
251
  <span
219
252
  className="min-w-0 flex-1 truncate"
@@ -223,12 +256,11 @@ export const ThinkingTraceRow = memo(function ThinkingTraceRow({
223
256
  </span>
224
257
  </button>
225
258
  {open && (
226
- <div
259
+ <ThinkingContent
227
260
  id={contentId}
228
- className="cos-markdown w-full rounded-xl border border-kumo-line bg-kumo-elevated/45 px-4 py-3 text-[14px] leading-[1.4] text-kumo-default/70"
229
- >
230
- <MarkdownMessage message={reasoning} />
231
- </div>
261
+ text={reasoning}
262
+ className="w-full"
263
+ />
232
264
  )}
233
265
  </div>
234
266
  );
@@ -327,7 +359,7 @@ export const WorkTraceDisclosure = memo(function WorkTraceDisclosure({
327
359
 
328
360
  return (
329
361
  <div data-agent-work="" data-state={open ? "open" : "closed"}>
330
- <div className="w-full border-b border-kumo-line pb-2">
362
+ <div className="w-full pb-2">
331
363
  <button
332
364
  type="button"
333
365
  onClick={onToggle}
@@ -336,33 +368,35 @@ export const WorkTraceDisclosure = memo(function WorkTraceDisclosure({
336
368
  aria-label={label}
337
369
  className="flex min-w-0 items-center gap-2 text-left text-[14px] leading-[1.4] text-cos-work-description transition-colors duration-150 ease-out hover:text-cos-work-description-subtle focus-visible:text-cos-work-description-subtle focus-visible:outline-none"
338
370
  >
339
- <span className="grid size-4 shrink-0 place-items-center" aria-hidden="true">
340
- <motion.img
341
- src={thinkingSpark}
342
- alt=""
343
- className="size-[12.24px]"
344
- initial={{ rotate: 0 }}
345
- animate={
346
- reduceMotion ? { rotate: 0 } : { rotate: [0, 360, -360, -360] }
347
- }
348
- transition={
349
- reduceMotion
350
- ? { duration: 0 }
351
- : {
352
- rotate: {
353
- duration: 1.68,
354
- times: [0, 0.8368, 0.8373, 1],
355
- ease: [
356
- [0.5, 0, 0.5, 1],
357
- [0.5, 0, 0.5, 1],
358
- "linear",
359
- ],
360
- repeat: Infinity,
361
- },
362
- }
363
- }
364
- />
365
- </span>
371
+ {!completed && (
372
+ <span className="grid size-4 shrink-0 place-items-center" aria-hidden="true">
373
+ <motion.img
374
+ src={thinkingSpark}
375
+ alt=""
376
+ className="size-[12.24px]"
377
+ initial={{ rotate: 0 }}
378
+ animate={
379
+ reduceMotion ? { rotate: 0 } : { rotate: [0, 360, -360, -360] }
380
+ }
381
+ transition={
382
+ reduceMotion
383
+ ? { duration: 0 }
384
+ : {
385
+ rotate: {
386
+ duration: 1.68,
387
+ times: [0, 0.8368, 0.8373, 1],
388
+ ease: [
389
+ [0.5, 0, 0.5, 1],
390
+ [0.5, 0, 0.5, 1],
391
+ "linear",
392
+ ],
393
+ repeat: Infinity,
394
+ },
395
+ }
396
+ }
397
+ />
398
+ </span>
399
+ )}
366
400
  <span className="min-w-0 truncate">
367
401
  <span>{`${verb}${duration}${activity ? `, ${activity.leading}` : ""}`}</span>
368
402
  {activity?.emphasis && (
@@ -391,12 +425,14 @@ export const WorkDescriptionRow = memo(function WorkDescriptionRow({
391
425
  resolveUrl,
392
426
  }: {
393
427
  text: string;
428
+ running?: boolean;
394
429
  resolveUrl?: CloudOsUrlResolver;
395
430
  }) {
396
431
  return (
397
- <div className="cos-markdown text-[14px] leading-[1.4] text-cos-work-description">
398
- <MarkdownMessage message={text} resolveUrl={resolveUrl} />
399
- </div>
432
+ <ThinkingContent
433
+ text={text}
434
+ resolveUrl={resolveUrl}
435
+ />
400
436
  );
401
437
  });
402
438
 
@@ -417,10 +453,10 @@ export const ToolGroupRow = memo(function ToolGroupRow({
417
453
  <button
418
454
  type="button"
419
455
  onClick={() => onToggle(group.key)}
420
- className="flex min-w-0 items-start gap-2 text-left text-[14px] leading-[1.4] text-cos-tool-group transition-colors duration-150 ease-out hover:text-kumo-subtle focus-visible:text-kumo-subtle focus-visible:outline-none"
456
+ className={SECONDARY_DISCLOSURE_TITLE_WITH_ICON}
421
457
  aria-expanded={open}
422
458
  >
423
- <span className="mt-0.5 flex size-4 shrink-0 items-center justify-center">
459
+ <span className={SECONDARY_DISCLOSURE_ICON}>
424
460
  <WorkIcon Icon={group.Icon} />
425
461
  </span>
426
462
  <span className="min-w-0 flex-1">
@@ -448,7 +484,7 @@ export const ToolGroupRow = memo(function ToolGroupRow({
448
484
  </span>
449
485
  </button>
450
486
  {open && (
451
- <div className="mt-3 pl-6">
487
+ <div className="mt-3">
452
488
  <ToolGroupDetails calls={group.calls} />
453
489
  </div>
454
490
  )}
@@ -636,6 +636,8 @@ function assistantBlocks(
636
636
  }
637
637
 
638
638
  const USER_STOP_REASON = "Stopped by user";
639
+ const REQUEST_UNAVAILABLE_ERROR =
640
+ "We’re unable to complete your request right now. Please try again in a few moments.";
639
641
 
640
642
  function terminalOf(
641
643
  message: UIMessage,
@@ -650,7 +652,9 @@ function terminalOf(
650
652
  if (metadata.turnStatus === "error" || metadata.error) {
651
653
  return {
652
654
  kind: "error",
653
- title: "Error",
655
+ title: metadata.error === REQUEST_UNAVAILABLE_ERROR
656
+ ? "Unable to complete your request"
657
+ : "Error",
654
658
  body: metadata.error ?? "The response failed.",
655
659
  };
656
660
  }
@@ -0,0 +1,65 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ export type DisclosurePolicy = "always-open" | "lifecycle";
4
+
5
+ export const DISCLOSURE_POLICY: DisclosurePolicy = "lifecycle";
6
+
7
+ export function reconcileExpandedWithAutoOpen(
8
+ expanded: ReadonlySet<string>,
9
+ previousAutoOpenKeys: ReadonlySet<string>,
10
+ autoOpenKeys: ReadonlySet<string>,
11
+ ): ReadonlySet<string> {
12
+ const next = new Set(expanded);
13
+ for (const key of previousAutoOpenKeys) {
14
+ if (!autoOpenKeys.has(key)) next.delete(key);
15
+ }
16
+ for (const key of autoOpenKeys) {
17
+ if (!previousAutoOpenKeys.has(key)) next.add(key);
18
+ }
19
+ if (next.size !== expanded.size) return next;
20
+ return [...next].every((key) => expanded.has(key)) ? expanded : next;
21
+ }
22
+
23
+ export function useDisclosureExpandedKeys(
24
+ disclosureKeys: ReadonlySet<string>,
25
+ runningKeys: ReadonlySet<string>,
26
+ ) {
27
+ const autoOpenKeys =
28
+ DISCLOSURE_POLICY === "always-open" ? disclosureKeys : runningKeys;
29
+ const [expanded, setExpanded] = useState<ReadonlySet<string>>(autoOpenKeys);
30
+ const previousAutoOpenKeys = useRef(autoOpenKeys);
31
+
32
+ useEffect(() => {
33
+ const previous = previousAutoOpenKeys.current;
34
+ previousAutoOpenKeys.current = autoOpenKeys;
35
+ setExpanded((current) =>
36
+ reconcileExpandedWithAutoOpen(current, previous, autoOpenKeys),
37
+ );
38
+ }, [autoOpenKeys]);
39
+
40
+ const toggle = useCallback((key: string) => {
41
+ setExpanded((current) => {
42
+ const next = new Set(current);
43
+ if (next.has(key)) next.delete(key);
44
+ else next.add(key);
45
+ return next;
46
+ });
47
+ }, []);
48
+
49
+ return [expanded, toggle] as const;
50
+ }
51
+
52
+ /** User choice persists while the policy-derived automatic state is unchanged. */
53
+ export function useLifecycleDisclosure(running: boolean) {
54
+ const autoOpen = DISCLOSURE_POLICY === "always-open" || running;
55
+ const [state, setState] = useState(() => ({ autoOpen, open: autoOpen }));
56
+ const open = state.autoOpen === autoOpen ? state.open : autoOpen;
57
+
58
+ useEffect(() => {
59
+ setState((current) =>
60
+ current.autoOpen === autoOpen ? current : { autoOpen, open: autoOpen }
61
+ );
62
+ }, [autoOpen]);
63
+
64
+ return [open, () => setState({ autoOpen, open: !open })] as const;
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/message-panel",
3
- "version": "0.1.3-alpha.31",
3
+ "version": "0.1.3-alpha.32",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",