@springbrand/message-panel 0.1.3-alpha.34 → 0.1.3-alpha.36

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.
@@ -0,0 +1,123 @@
1
+ "use client";
2
+
3
+ import { ChevronRightIcon, type LucideIcon } from "lucide-react";
4
+ import type { ReactNode } from "react";
5
+ import {
6
+ Collapsible,
7
+ CollapsibleContent,
8
+ CollapsibleTrigger,
9
+ } from "../primitives/collapsible";
10
+ import { cn } from "../internal/cn";
11
+ import { ShimmerLabel, SwapLabel } from "./surfaces";
12
+ import { take } from "./range";
13
+
14
+ export interface TimelineStep {
15
+ verb: string;
16
+ chip: string;
17
+ icon: LucideIcon;
18
+ }
19
+
20
+ export interface TimelineStat {
21
+ file: string;
22
+ added?: number;
23
+ removed?: number;
24
+ }
25
+
26
+ export interface ToolTimelineProps {
27
+ steps: readonly TimelineStep[];
28
+ visibleSteps: number;
29
+ streaming: boolean;
30
+ open: boolean;
31
+ onOpenChange: (open: boolean) => void;
32
+ restingLabel: string;
33
+ activeLabel: string;
34
+ stats: TimelineStat[];
35
+ className?: string;
36
+ children?: ReactNode;
37
+ }
38
+
39
+ export function ToolTimeline({
40
+ steps,
41
+ visibleSteps,
42
+ streaming,
43
+ open,
44
+ onOpenChange,
45
+ restingLabel,
46
+ activeLabel,
47
+ stats,
48
+ className,
49
+ children,
50
+ }: ToolTimelineProps) {
51
+ return (
52
+ <Collapsible
53
+ data-slot="tool-timeline"
54
+ open={open}
55
+ onOpenChange={onOpenChange}
56
+ className={cn("w-full max-w-sm", className)}
57
+ >
58
+ <CollapsibleTrigger className="group/trigger text-foreground/55 hover:text-foreground/90 flex items-center gap-1.5 rounded-md py-1 text-[13.5px] transition-colors outline-none">
59
+ <ChevronRightIcon className="size-3.5 shrink-0 opacity-60 transition-transform duration-200 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-open/trigger:rotate-90 group-data-panel-open/trigger:rotate-90 motion-reduce:transition-none" />
60
+ <SwapLabel
61
+ active={streaming ? 0 : 1}
62
+ className="text-start tabular-nums"
63
+ >
64
+ <ShimmerLabel
65
+ active={streaming}
66
+ className="relative inline-block leading-none"
67
+ >
68
+ {activeLabel}
69
+ </ShimmerLabel>
70
+ <>{restingLabel}</>
71
+ </SwapLabel>
72
+ </CollapsibleTrigger>
73
+ <CollapsibleContent className="outline-none">
74
+ <div className="flex flex-col gap-2.5 ps-4 pt-2.5">
75
+ {children ?? take(steps, visibleSteps).map((step, index, shown) => {
76
+ const Icon = step.icon;
77
+ const active = streaming && index === shown.length - 1;
78
+
79
+ return (
80
+ <div
81
+ key={step.chip}
82
+ className="fade-in slide-in-from-bottom-1 animate-in fill-mode-both text-foreground/55 flex items-center gap-2 text-[13.5px] duration-300"
83
+ >
84
+ <Icon className="text-foreground/35 size-3.5 shrink-0" />
85
+ <ShimmerLabel
86
+ active={active}
87
+ className="relative inline-block leading-none"
88
+ >
89
+ {step.verb}
90
+ </ShimmerLabel>
91
+ <span className="bg-foreground/[0.06] text-foreground/70 rounded-md px-1.5 py-0.5 font-mono text-[11px]">
92
+ {step.chip}
93
+ </span>
94
+ </div>
95
+ );
96
+ })}
97
+ {stats.length > 0 && (
98
+ <div className="flex flex-wrap gap-1.5 pt-1">
99
+ {stats.map((stat) => (
100
+ <span
101
+ key={stat.file}
102
+ className="bg-foreground/[0.06] text-foreground/70 inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-mono text-[11px]"
103
+ >
104
+ <span>{stat.file}</span>
105
+ {stat.added !== undefined && (
106
+ <span className="text-emerald-600 dark:text-emerald-400">
107
+ +{stat.added}
108
+ </span>
109
+ )}
110
+ {stat.removed !== undefined && (
111
+ <span className="text-red-600 dark:text-red-400">
112
+ −{stat.removed}
113
+ </span>
114
+ )}
115
+ </span>
116
+ ))}
117
+ </div>
118
+ )}
119
+ </div>
120
+ </CollapsibleContent>
121
+ </Collapsible>
122
+ );
123
+ }
@@ -28,6 +28,7 @@ export interface CloudOsMessageMetadata extends UnknownRecord {
28
28
  error?: string;
29
29
  interruptedByUser?: boolean;
30
30
  turnDurationMs?: number;
31
+ turnId?: string;
31
32
  turnStartedAt?: number;
32
33
  turnStatus?: string;
33
34
  requestedCapabilities?: unknown;
@@ -77,7 +78,7 @@ export type AssistantBlock =
77
78
  text: string;
78
79
  running: boolean;
79
80
  }
80
- | { kind: "text"; key: string; text: string }
81
+ | { kind: "text"; key: string; text: string; final: boolean }
81
82
  | { kind: "toolGroup"; key: string; group: ToolCallGroup }
82
83
  | { kind: "plan"; key: string; steps: PlanStep[]; running: boolean }
83
84
  // 答案就在 call.output 里(`ask_user` 是 client-settled tool,用户点选后
@@ -143,6 +144,7 @@ export type CloudOsEntry =
143
144
  type: "assistant";
144
145
  key: string;
145
146
  messageId: string;
147
+ turnId: string;
146
148
  blocks: AssistantBlock[];
147
149
  copyText: string;
148
150
  timestamp?: number;
@@ -416,7 +418,7 @@ function assistantBlocks(
416
418
  const text = String(record.text ?? "").trim();
417
419
  if (!text) return;
418
420
  flush();
419
- blocks.push({ kind: "text", key, text });
421
+ blocks.push({ kind: "text", key, text, final: false });
420
422
  return;
421
423
  }
422
424
 
@@ -622,6 +624,7 @@ export function buildCloudOsEntries({
622
624
  approvalIdOf = () => undefined,
623
625
  }: BuildEntriesOptions): CloudOsEntry[] {
624
626
  const entries: CloudOsEntry[] = [];
627
+ let fallbackTurnId = "turn:initial";
625
628
  const lastAssistantIndex = (() => {
626
629
  for (let index = messages.length - 1; index >= 0; index -= 1) {
627
630
  if (messages[index].role === "assistant") return index;
@@ -655,6 +658,9 @@ export function buildCloudOsEntries({
655
658
  };
656
659
  });
657
660
  if (!text && attachments.length === 0) return;
661
+ fallbackTurnId = typeof metadata.turnId === "string" && metadata.turnId
662
+ ? metadata.turnId
663
+ : `turn:user:${message.id}`;
658
664
  entries.push({
659
665
  type: "user",
660
666
  key: `user-${message.id}-${index}`,
@@ -682,6 +688,9 @@ export function buildCloudOsEntries({
682
688
  type: "assistant",
683
689
  key: `assistant-${message.id}-${index}`,
684
690
  messageId: message.id,
691
+ turnId: typeof metadata.turnId === "string" && metadata.turnId
692
+ ? metadata.turnId
693
+ : fallbackTurnId,
685
694
  blocks,
686
695
  copyText: messageText(message),
687
696
  timestamp:
@@ -691,7 +700,70 @@ export function buildCloudOsEntries({
691
700
  });
692
701
  });
693
702
 
694
- return dropSupersededActionPresentations(dropSupersededPlans(entries));
703
+ return markFinalTextBlocks(mergeConsecutiveAssistantTurns(
704
+ dropSupersededActionPresentations(dropSupersededPlans(entries)),
705
+ ));
706
+ }
707
+
708
+ function isFinalOutputBlock(block: AssistantBlock): boolean {
709
+ return block.kind === "suggestions" ||
710
+ block.kind === "image" ||
711
+ block.kind === "file" ||
712
+ (block.kind === "actionPresentation" && block.presentation.state === "ready");
713
+ }
714
+
715
+ function isWorkBoundaryBlock(block: AssistantBlock): boolean {
716
+ return block.kind !== "text" && !isFinalOutputBlock(block);
717
+ }
718
+
719
+ function markFinalTextBlocks(entries: CloudOsEntry[]): CloudOsEntry[] {
720
+ return entries.map((entry) => {
721
+ if (entry.type !== "assistant") return entry;
722
+
723
+ const finalOutputBoundary = entry.blocks.findLastIndex(isFinalOutputBlock);
724
+ // Provider cleanup after final output must not pull the answer back into Work.
725
+ const searchBefore = finalOutputBoundary === -1
726
+ ? entry.blocks.length
727
+ : finalOutputBoundary;
728
+ let lastWorkBoundary = -1;
729
+ for (let index = searchBefore - 1; index >= 0; index -= 1) {
730
+ if (!isWorkBoundaryBlock(entry.blocks[index]!)) continue;
731
+ lastWorkBoundary = index;
732
+ break;
733
+ }
734
+
735
+ return {
736
+ ...entry,
737
+ blocks: entry.blocks.map((block, index) =>
738
+ block.kind === "text"
739
+ ? { ...block, final: index > lastWorkBoundary }
740
+ : block
741
+ ),
742
+ };
743
+ });
744
+ }
745
+
746
+ function mergeConsecutiveAssistantTurns(entries: CloudOsEntry[]): CloudOsEntry[] {
747
+ return entries.reduce<CloudOsEntry[]>((merged, entry) => {
748
+ const previous = merged.at(-1);
749
+ if (
750
+ entry.type !== "assistant" ||
751
+ previous?.type !== "assistant" ||
752
+ previous.turnId !== entry.turnId
753
+ ) {
754
+ merged.push(entry);
755
+ return merged;
756
+ }
757
+ merged[merged.length - 1] = {
758
+ ...previous,
759
+ messageId: entry.messageId,
760
+ blocks: [...previous.blocks, ...entry.blocks],
761
+ copyText: [previous.copyText, entry.copyText].filter(Boolean).join("\n\n"),
762
+ timestamp: entry.timestamp ?? previous.timestamp,
763
+ terminal: entry.terminal ?? previous.terminal,
764
+ };
765
+ return merged;
766
+ }, []);
695
767
  }
696
768
 
697
769
  function dropSupersededActionPresentations(entries: CloudOsEntry[]): CloudOsEntry[] {
@@ -741,31 +813,31 @@ function dropSupersededActionPresentations(entries: CloudOsEntry[]): CloudOsEntr
741
813
  }
742
814
 
743
815
  /**
744
- * update_plan 是**整份快照覆盖**,不是增量事件 —— 一次任务里每推进一步就会再发一份
745
- * 完整计划。原样按序渲染会把同一份计划画四遍(0/3、1/3、2/3、3/3),只有最后那份
746
- * 是当前事实。所以整条会话里只保留最后一份计划快照。
816
+ * update_plan 是**整份快照覆盖**,不是增量事件 —— 一次 Turn 里每推进一步就会再发一份
817
+ * 完整计划。原样按序渲染会把同一份计划画四遍(0/3、1/3、2/3、3/3),只有该 Turn 的
818
+ * 最后一份是当前事实。所以每个 turnId 各自保留最后一份计划快照。
747
819
  */
748
820
  function dropSupersededPlans(entries: CloudOsEntry[]): CloudOsEntry[] {
749
- let lastPlanEntry = -1;
750
- let lastPlanBlock = -1;
821
+ const lastPlanByTurn = new Map<string, { entryIndex: number; blockIndex: number }>();
751
822
  entries.forEach((entry, entryIndex) => {
752
823
  if (entry.type !== "assistant") return;
753
824
  entry.blocks.forEach((block, blockIndex) => {
754
825
  if (block.kind !== "plan") return;
755
- lastPlanEntry = entryIndex;
756
- lastPlanBlock = blockIndex;
826
+ lastPlanByTurn.set(entry.turnId, { entryIndex, blockIndex });
757
827
  });
758
828
  });
759
- if (lastPlanEntry === -1) return entries;
829
+ if (lastPlanByTurn.size === 0) return entries;
760
830
 
761
831
  return entries.map((entry, entryIndex) =>
762
832
  entry.type === "assistant"
763
833
  ? {
764
834
  ...entry,
765
835
  blocks: entry.blocks.filter(
766
- (block, blockIndex) =>
767
- block.kind !== "plan" ||
768
- (entryIndex === lastPlanEntry && blockIndex === lastPlanBlock),
836
+ (block, blockIndex) => {
837
+ if (block.kind !== "plan") return true;
838
+ const last = lastPlanByTurn.get(entry.turnId);
839
+ return last?.entryIndex === entryIndex && last.blockIndex === blockIndex;
840
+ },
769
841
  ),
770
842
  }
771
843
  : entry,
@@ -876,6 +948,8 @@ export function deriveTurnActivity(
876
948
  options: {
877
949
  isActive: boolean;
878
950
  isRecovering?: boolean;
951
+ recoveryAttempt?: number;
952
+ recoveryMax?: number;
879
953
  recoveryStatusLabel?: string;
880
954
  awaitingApproval?: boolean;
881
955
  hasPendingSteer?: boolean;
@@ -884,7 +958,10 @@ export function deriveTurnActivity(
884
958
  if (options.isRecovering) {
885
959
  return {
886
960
  state: "connecting",
887
- label: options.recoveryStatusLabel ?? "Reconnecting…",
961
+ label: options.recoveryStatusLabel ??
962
+ (options.recoveryAttempt === undefined || options.recoveryMax === undefined
963
+ ? "Recovering model response…"
964
+ : `Recovering model response ${options.recoveryAttempt}/${options.recoveryMax}…`),
888
965
  };
889
966
  }
890
967
  if (!options.isActive) return null;
@@ -0,0 +1,135 @@
1
+ "use client";
2
+
3
+ import type { ComponentProps } from "react";
4
+ import { SearchIcon } from "lucide-react";
5
+ import { cn } from "../internal/cn";
6
+ import { field, mono, ShimmerLabel } from "./surfaces";
7
+ import { take } from "./range";
8
+ import type { CloudOsToolCall } from "./tool-presentation";
9
+
10
+ /** Adapted from assistant-ui's MIT-licensed Web Search element. */
11
+ export interface WebSearchResult {
12
+ title: string;
13
+ domain: string;
14
+ }
15
+
16
+ export function WebSearch({
17
+ query,
18
+ results,
19
+ visibleResults,
20
+ searching,
21
+ cycle,
22
+ className,
23
+ ...props
24
+ }: Omit<
25
+ ComponentProps<"div">,
26
+ "children" | "results"
27
+ > & {
28
+ query: string;
29
+ results: readonly WebSearchResult[];
30
+ visibleResults: number;
31
+ searching: boolean;
32
+ cycle: number;
33
+ }) {
34
+ const revealedResults = take(results, visibleResults);
35
+ return (
36
+ <div
37
+ data-slot="web-search"
38
+ className={cn("flex w-full max-w-sm flex-col gap-2.5", className)}
39
+ {...props}
40
+ >
41
+ <span
42
+ className={cn(
43
+ field,
44
+ "text-foreground/70 inline-flex w-fit items-center gap-1.5 rounded-full px-3.5 py-2 text-xs",
45
+ )}
46
+ >
47
+ <SearchIcon className="text-foreground/40 size-3" />
48
+ {query}
49
+ </span>
50
+ <div className="text-foreground/45 text-xs">
51
+ {searching ? (
52
+ <ShimmerLabel className="relative inline-block leading-none">
53
+ Searching
54
+ </ShimmerLabel>
55
+ ) : (
56
+ <span className="fade-in animate-in duration-300">
57
+ Read {results.length} {results.length === 1 ? "source" : "sources"}
58
+ </span>
59
+ )}
60
+ </div>
61
+ <div className="flex min-h-[5.75rem] max-h-48 flex-col overflow-y-auto overscroll-contain pr-1">
62
+ {revealedResults.map((result, index) => (
63
+ <div
64
+ key={`${cycle}-${result.domain}-${index}`}
65
+ className="fade-in slide-in-from-bottom-1 animate-in fill-mode-both hover:bg-foreground/[0.03] -mx-2.5 flex items-center gap-2.5 rounded-xl px-2.5 py-1.5 transition-colors duration-300"
66
+ >
67
+ <span className="bg-foreground/[0.06] text-foreground/45 flex size-4 shrink-0 items-center justify-center rounded text-[9px] font-medium">
68
+ {result.domain.charAt(0).toUpperCase()}
69
+ </span>
70
+ <span className="text-foreground/90 min-w-0 flex-1 truncate text-[13.5px]">
71
+ {result.title}
72
+ </span>
73
+ <span className={cn(mono, "text-foreground/35 shrink-0")}>
74
+ {result.domain}
75
+ </span>
76
+ </div>
77
+ ))}
78
+ </div>
79
+ </div>
80
+ );
81
+ }
82
+
83
+ type UnknownRecord = Record<string, unknown>;
84
+
85
+ function recordOf(value: unknown): UnknownRecord {
86
+ return value != null && typeof value === "object" && !Array.isArray(value)
87
+ ? value as UnknownRecord
88
+ : {};
89
+ }
90
+
91
+ function webSearchResults(output: unknown): WebSearchResult[] {
92
+ const record = recordOf(output);
93
+ const candidates = [record.sources, record.searchResults]
94
+ .flatMap((value) => Array.isArray(value) ? value : []);
95
+ const seen = new Set<string>();
96
+ return candidates.flatMap((value) => {
97
+ const result = recordOf(value);
98
+ if (typeof result.url !== "string") return [];
99
+ try {
100
+ const url = new URL(result.url);
101
+ if (url.protocol !== "http:" && url.protocol !== "https:") return [];
102
+ if (seen.has(url.href)) return [];
103
+ seen.add(url.href);
104
+ return [{
105
+ title: typeof result.title === "string" && result.title.trim()
106
+ ? result.title.trim()
107
+ : url.hostname,
108
+ domain: url.hostname,
109
+ }];
110
+ } catch {
111
+ return [];
112
+ }
113
+ });
114
+ }
115
+
116
+ export function WebSearchToolCall({
117
+ toolCall,
118
+ }: {
119
+ toolCall: CloudOsToolCall;
120
+ }) {
121
+ const query = typeof toolCall.input.query === "string" &&
122
+ toolCall.input.query.trim()
123
+ ? toolCall.input.query.trim()
124
+ : "Web search";
125
+ const results = webSearchResults(toolCall.output);
126
+ return (
127
+ <WebSearch
128
+ query={query}
129
+ results={results}
130
+ visibleResults={toolCall.running ? 0 : results.length}
131
+ searching={toolCall.running}
132
+ cycle={0}
133
+ />
134
+ );
135
+ }
@@ -320,7 +320,7 @@ function CloudOsChatInputContent({
320
320
 
321
321
  return (
322
322
  // isolation: isolate 把输入框内部的 z-index 关起来,免得盖到 portal 出去的模型下拉。
323
- <div className="cos-chat-input-root relative isolate px-4 py-4">
323
+ <div className="cos-chat-input-root relative isolate px-4 pb-4">
324
324
  <ComposerTriggerPopover
325
325
  char="/"
326
326
  {...slash}
package/cloud-os/index.ts CHANGED
@@ -71,6 +71,16 @@ export {
71
71
  ToolGroupRow,
72
72
  WorkIcon,
73
73
  } from "./chat/tool-rows";
74
+ export { ToolCall } from "./chat/tool-call";
75
+ export type { ToolCallProps } from "./chat/tool-call";
76
+ export { CodeRunner } from "./chat/code-runner";
77
+ export type { RunState } from "./chat/code-runner";
78
+ export { ToolTimeline } from "./chat/tool-timeline";
79
+ export type {
80
+ TimelineStat,
81
+ TimelineStep,
82
+ ToolTimelineProps,
83
+ } from "./chat/tool-timeline";
74
84
  export {
75
85
  ApprovalBlock,
76
86
  AskUserBlock,
@@ -0,0 +1,21 @@
1
+ "use client";
2
+
3
+ import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible";
4
+
5
+ function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
6
+ return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
7
+ }
8
+
9
+ function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
10
+ return (
11
+ <CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
12
+ );
13
+ }
14
+
15
+ function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
16
+ return (
17
+ <CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
18
+ );
19
+ }
20
+
21
+ export { Collapsible, CollapsibleTrigger, CollapsibleContent };
@@ -557,6 +557,24 @@
557
557
  flex: none;
558
558
  }
559
559
 
560
+ /* Trace 新块进入时轻推上浮;已有块更新不会重复播放。 */
561
+ .cos-trace-chain > * {
562
+ animation: cos-trace-enter 180ms cubic-bezier(0.32, 0.72, 0, 1) both;
563
+ }
564
+
565
+ @keyframes cos-trace-enter {
566
+ from {
567
+ opacity: 0;
568
+ transform: translateY(6px);
569
+ }
570
+ }
571
+
572
+ @media (prefers-reduced-motion: reduce) {
573
+ .cos-trace-chain > * {
574
+ animation: none;
575
+ }
576
+ }
577
+
560
578
  /* ── 流式占位:Thinking 扫光 ──────────────────────────────────────────────── */
561
579
  .cos-thinking-shimmer {
562
580
  color: var(--text-color-kumo-inactive);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/message-panel",
3
- "version": "0.1.3-alpha.34",
3
+ "version": "0.1.3-alpha.36",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -25,6 +25,7 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@assistant-ui/react": "^0.15.1",
28
+ "@base-ui/react": "^1.7.0",
28
29
  "@phosphor-icons/react": "^2.1.10",
29
30
  "@streamdown/cjk": "^1.0.3",
30
31
  "@streamdown/code": "^1.1.1",
@@ -43,6 +44,7 @@
43
44
  "@types/react-dom": "^19.2.3",
44
45
  "@vitejs/plugin-react": "^6.0.3",
45
46
  "tailwindcss": "^4.3.2",
47
+ "tw-animate-css": "^1.4.0",
46
48
  "typescript": "^7.0.2",
47
49
  "vite": "^8.1.4"
48
50
  },