@steerable/agent-ui 0.2.3 → 0.2.4

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 (178) hide show
  1. package/dist/cards/ActionSegmentCard.d.ts +18 -0
  2. package/dist/cards/ActionSegmentCard.d.ts.map +1 -0
  3. package/dist/cards/ActionSegmentCard.js +26 -0
  4. package/dist/cards/ActionSegmentCard.js.map +1 -0
  5. package/dist/cards/AnalysisDocumentCard.d.ts +33 -0
  6. package/dist/cards/AnalysisDocumentCard.d.ts.map +1 -0
  7. package/dist/cards/AnalysisDocumentCard.js +11 -0
  8. package/dist/cards/AnalysisDocumentCard.js.map +1 -0
  9. package/dist/cards/AskUserQuestionsCard.d.ts +43 -0
  10. package/dist/cards/AskUserQuestionsCard.d.ts.map +1 -0
  11. package/dist/cards/AskUserQuestionsCard.js +171 -0
  12. package/dist/cards/AskUserQuestionsCard.js.map +1 -0
  13. package/dist/cards/CoverageReportCard.d.ts +41 -0
  14. package/dist/cards/CoverageReportCard.d.ts.map +1 -0
  15. package/dist/cards/CoverageReportCard.js +55 -0
  16. package/dist/cards/CoverageReportCard.js.map +1 -0
  17. package/dist/cards/OrchestrationPlanCard.d.ts +60 -0
  18. package/dist/cards/OrchestrationPlanCard.d.ts.map +1 -0
  19. package/dist/cards/OrchestrationPlanCard.js +103 -0
  20. package/dist/cards/OrchestrationPlanCard.js.map +1 -0
  21. package/dist/cards/PlanSelectorCard.d.ts +40 -0
  22. package/dist/cards/PlanSelectorCard.d.ts.map +1 -0
  23. package/dist/cards/PlanSelectorCard.js +106 -0
  24. package/dist/cards/PlanSelectorCard.js.map +1 -0
  25. package/dist/cards/PlanStepsCard.d.ts +18 -0
  26. package/dist/cards/PlanStepsCard.d.ts.map +1 -0
  27. package/dist/cards/PlanStepsCard.js +25 -0
  28. package/dist/cards/PlanStepsCard.js.map +1 -0
  29. package/dist/cards/QuizCard.d.ts +44 -0
  30. package/dist/cards/QuizCard.d.ts.map +1 -0
  31. package/dist/cards/QuizCard.js +113 -0
  32. package/dist/cards/QuizCard.js.map +1 -0
  33. package/dist/cards/ResearchPlanCard.d.ts +23 -0
  34. package/dist/cards/ResearchPlanCard.d.ts.map +1 -0
  35. package/dist/cards/ResearchPlanCard.js +50 -0
  36. package/dist/cards/ResearchPlanCard.js.map +1 -0
  37. package/dist/cards/SearchSourcesCard.d.ts +29 -0
  38. package/dist/cards/SearchSourcesCard.d.ts.map +1 -0
  39. package/dist/cards/SearchSourcesCard.js +83 -0
  40. package/dist/cards/SearchSourcesCard.js.map +1 -0
  41. package/dist/cards/SuggestedRepliesCard.d.ts +18 -0
  42. package/dist/cards/SuggestedRepliesCard.d.ts.map +1 -0
  43. package/dist/cards/SuggestedRepliesCard.js +14 -0
  44. package/dist/cards/SuggestedRepliesCard.js.map +1 -0
  45. package/dist/cards/SummaryMessageCard.d.ts +40 -0
  46. package/dist/cards/SummaryMessageCard.d.ts.map +1 -0
  47. package/dist/cards/SummaryMessageCard.js +56 -0
  48. package/dist/cards/SummaryMessageCard.js.map +1 -0
  49. package/dist/cards/ThinkingProcessCard.d.ts +46 -0
  50. package/dist/cards/ThinkingProcessCard.d.ts.map +1 -0
  51. package/dist/cards/ThinkingProcessCard.js +79 -0
  52. package/dist/cards/ThinkingProcessCard.js.map +1 -0
  53. package/dist/cards/ToolExecutionCard.d.ts +21 -0
  54. package/dist/cards/ToolExecutionCard.d.ts.map +1 -0
  55. package/dist/cards/ToolExecutionCard.js +65 -0
  56. package/dist/cards/ToolExecutionCard.js.map +1 -0
  57. package/dist/cards/icons.d.ts +38 -0
  58. package/dist/cards/icons.d.ts.map +1 -0
  59. package/dist/cards/icons.js +87 -0
  60. package/dist/cards/icons.js.map +1 -0
  61. package/dist/cards/index.d.ts +38 -0
  62. package/dist/cards/index.d.ts.map +1 -0
  63. package/dist/cards/index.js +24 -0
  64. package/dist/cards/index.js.map +1 -0
  65. package/dist/cards/types.d.ts +24 -0
  66. package/dist/cards/types.d.ts.map +1 -0
  67. package/dist/cards/types.js +7 -0
  68. package/dist/cards/types.js.map +1 -0
  69. package/dist/components/AgentSelector.d.ts +23 -0
  70. package/dist/components/AgentSelector.d.ts.map +1 -0
  71. package/dist/components/AgentSelector.js +63 -0
  72. package/dist/components/AgentSelector.js.map +1 -0
  73. package/dist/components/AgentSelector.stories.d.ts +12 -0
  74. package/dist/components/AgentSelector.stories.d.ts.map +1 -0
  75. package/dist/components/AgentSelector.stories.js +95 -0
  76. package/dist/components/AgentSelector.stories.js.map +1 -0
  77. package/dist/components/ChatPanel.d.ts +138 -21
  78. package/dist/components/ChatPanel.d.ts.map +1 -1
  79. package/dist/components/ChatPanel.js +181 -26
  80. package/dist/components/ChatPanel.js.map +1 -1
  81. package/dist/components/index.d.ts +3 -1
  82. package/dist/components/index.d.ts.map +1 -1
  83. package/dist/components/index.js +1 -0
  84. package/dist/components/index.js.map +1 -1
  85. package/dist/index.d.ts +2 -0
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +5 -0
  88. package/dist/index.js.map +1 -1
  89. package/dist/state/ChatSessionProvider.d.ts +59 -0
  90. package/dist/state/ChatSessionProvider.d.ts.map +1 -0
  91. package/dist/state/ChatSessionProvider.js +71 -0
  92. package/dist/state/ChatSessionProvider.js.map +1 -0
  93. package/dist/state/MockChatStreamTransport.d.ts +54 -0
  94. package/dist/state/MockChatStreamTransport.d.ts.map +1 -0
  95. package/dist/state/MockChatStreamTransport.js +86 -0
  96. package/dist/state/MockChatStreamTransport.js.map +1 -0
  97. package/dist/state/MockChatStreamTransport.stories.d.ts +13 -0
  98. package/dist/state/MockChatStreamTransport.stories.d.ts.map +1 -0
  99. package/dist/state/MockChatStreamTransport.stories.js +60 -0
  100. package/dist/state/MockChatStreamTransport.stories.js.map +1 -0
  101. package/dist/state/bridgeLegacySSE.d.ts +42 -0
  102. package/dist/state/bridgeLegacySSE.d.ts.map +1 -0
  103. package/dist/state/bridgeLegacySSE.js +102 -0
  104. package/dist/state/bridgeLegacySSE.js.map +1 -0
  105. package/dist/state/index.d.ts +32 -0
  106. package/dist/state/index.d.ts.map +1 -0
  107. package/dist/state/index.js +24 -0
  108. package/dist/state/index.js.map +1 -0
  109. package/dist/state/parseSSE.d.ts +50 -0
  110. package/dist/state/parseSSE.d.ts.map +1 -0
  111. package/dist/state/parseSSE.js +112 -0
  112. package/dist/state/parseSSE.js.map +1 -0
  113. package/dist/state/useChatComposer.d.ts +51 -0
  114. package/dist/state/useChatComposer.d.ts.map +1 -0
  115. package/dist/state/useChatComposer.js +82 -0
  116. package/dist/state/useChatComposer.js.map +1 -0
  117. package/dist/state/useChatList.d.ts +71 -0
  118. package/dist/state/useChatList.d.ts.map +1 -0
  119. package/dist/state/useChatList.js +158 -0
  120. package/dist/state/useChatList.js.map +1 -0
  121. package/dist/state/useChatSession.d.ts +29 -0
  122. package/dist/state/useChatSession.d.ts.map +1 -0
  123. package/dist/state/useChatSession.js +37 -0
  124. package/dist/state/useChatSession.js.map +1 -0
  125. package/dist/state/useScrollLock.d.ts +35 -0
  126. package/dist/state/useScrollLock.d.ts.map +1 -0
  127. package/dist/state/useScrollLock.js +74 -0
  128. package/dist/state/useScrollLock.js.map +1 -0
  129. package/dist/state/useToolCallStream.d.ts +38 -0
  130. package/dist/state/useToolCallStream.d.ts.map +1 -0
  131. package/dist/state/useToolCallStream.js +74 -0
  132. package/dist/state/useToolCallStream.js.map +1 -0
  133. package/package.json +10 -2
  134. package/src/cards/ActionSegmentCard.tsx +60 -0
  135. package/src/cards/AnalysisDocumentCard.tsx +81 -0
  136. package/src/cards/AskUserQuestionsCard.tsx +354 -0
  137. package/src/cards/CoverageReportCard.tsx +215 -0
  138. package/src/cards/OrchestrationPlanCard.tsx +328 -0
  139. package/src/cards/PlanSelectorCard.tsx +312 -0
  140. package/src/cards/PlanStepsCard.tsx +52 -0
  141. package/src/cards/QuizCard.tsx +309 -0
  142. package/src/cards/ResearchPlanCard.tsx +169 -0
  143. package/src/cards/SearchSourcesCard.tsx +244 -0
  144. package/src/cards/SuggestedRepliesCard.tsx +48 -0
  145. package/src/cards/SummaryMessageCard.tsx +145 -0
  146. package/src/cards/ThinkingProcessCard.tsx +155 -0
  147. package/src/cards/ToolExecutionCard.tsx +152 -0
  148. package/src/cards/cards.test.tsx +492 -0
  149. package/src/cards/icons.tsx +249 -0
  150. package/src/cards/index.ts +53 -0
  151. package/src/cards/types.ts +27 -0
  152. package/src/components/AgentSelector.stories.tsx +113 -0
  153. package/src/components/AgentSelector.test.tsx +70 -0
  154. package/src/components/AgentSelector.tsx +147 -0
  155. package/src/components/ChatPanel.test.tsx +130 -0
  156. package/src/components/ChatPanel.tsx +612 -138
  157. package/src/components/index.ts +13 -1
  158. package/src/index.ts +43 -0
  159. package/src/state/ChatSessionProvider.test.tsx +85 -0
  160. package/src/state/ChatSessionProvider.tsx +98 -0
  161. package/src/state/MockChatStreamTransport.stories.tsx +95 -0
  162. package/src/state/MockChatStreamTransport.test.ts +163 -0
  163. package/src/state/MockChatStreamTransport.ts +123 -0
  164. package/src/state/bridgeLegacySSE.test.ts +134 -0
  165. package/src/state/bridgeLegacySSE.ts +134 -0
  166. package/src/state/index.ts +82 -0
  167. package/src/state/parseSSE.test.ts +123 -0
  168. package/src/state/parseSSE.ts +123 -0
  169. package/src/state/useChatComposer.test.ts +145 -0
  170. package/src/state/useChatComposer.ts +117 -0
  171. package/src/state/useChatList.test.ts +135 -0
  172. package/src/state/useChatList.ts +217 -0
  173. package/src/state/useChatSession.test.ts +61 -0
  174. package/src/state/useChatSession.ts +64 -0
  175. package/src/state/useScrollLock.test.ts +99 -0
  176. package/src/state/useScrollLock.ts +105 -0
  177. package/src/state/useToolCallStream.test.ts +86 -0
  178. package/src/state/useToolCallStream.ts +110 -0
@@ -0,0 +1,244 @@
1
+ /**
2
+ * `<SearchSourcesCard />`
3
+ *
4
+ * Renders a row of stacked favicons summarising the web sources an agent
5
+ * consulted, plus an expandable detail panel listing each `(title, domain,
6
+ * snippet)` triple.
7
+ *
8
+ * Appearance variants:
9
+ * - `card` (default): bordered card chrome with a divided expanded list.
10
+ * - `inline`: no chrome around the trigger; expanded items use a borderless
11
+ * `:hover` background. Matches the deeppath "above the input" aesthetic.
12
+ *
13
+ * Slots:
14
+ * - `renderSummary(count, more)` -- replace the default "{count} 个来源" label.
15
+ * - `faviconUrlFor(url)` -- replace the default favicon resolver.
16
+ */
17
+ import * as React from 'react';
18
+ import type { SearchSourcesPayload } from '@steerable/agent-protocol';
19
+ import { GlobeIcon, ChevronDownIcon, ExternalLinkIcon } from './icons.js';
20
+
21
+ export type SearchSourcesCardAppearance = 'card' | 'inline';
22
+
23
+ export interface SearchSourcesCardProps {
24
+ payload: SearchSourcesPayload;
25
+ className?: string;
26
+ appearance?: SearchSourcesCardAppearance;
27
+ faviconUrlFor?: (url: string) => string | null;
28
+ renderSummary?: (uniqueCount: number, overflowCount: number) => React.ReactNode;
29
+ }
30
+
31
+ const MAX_STACKED = 5;
32
+
33
+ function extractDomain(url: string): string {
34
+ try {
35
+ return new URL(url).hostname.replace(/^www\./, '');
36
+ } catch {
37
+ return url;
38
+ }
39
+ }
40
+
41
+ function defaultFaviconUrl(url: string): string | null {
42
+ try {
43
+ const host = new URL(url).hostname;
44
+ return `https://www.google.com/s2/favicons?sz=64&domain=${host}`;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function FaviconCircle({ src, size = 18 }: { src: string | null; size?: number }) {
51
+ const [failed, setFailed] = React.useState(false);
52
+ const showFallback = !src || failed;
53
+
54
+ if (showFallback) {
55
+ return (
56
+ <span
57
+ className="inline-flex items-center justify-center rounded-full border-2 border-[var(--agent-background,#fff)] bg-[var(--agent-muted,#f3f4f6)]"
58
+ style={{ width: size, height: size }}
59
+ >
60
+ <GlobeIcon
61
+ size={Math.round(size * 0.55)}
62
+ className="text-[var(--agent-muted-foreground,#6b7280)]"
63
+ />
64
+ </span>
65
+ );
66
+ }
67
+
68
+ return (
69
+ <img
70
+ src={src}
71
+ alt=""
72
+ width={size}
73
+ height={size}
74
+ className="rounded-full border-2 border-[var(--agent-background,#fff)] bg-[var(--agent-background,#fff)] object-cover"
75
+ style={{ width: size, height: size }}
76
+ loading="lazy"
77
+ onError={() => setFailed(true)}
78
+ />
79
+ );
80
+ }
81
+
82
+ export const SearchSourcesCard: React.FC<SearchSourcesCardProps> = ({
83
+ payload,
84
+ className,
85
+ appearance = 'card',
86
+ faviconUrlFor = defaultFaviconUrl,
87
+ renderSummary,
88
+ }) => {
89
+ const [expanded, setExpanded] = React.useState(false);
90
+ const sources = payload.sources ?? [];
91
+
92
+ const unique = React.useMemo(() => {
93
+ const seen = new Set<string>();
94
+ return sources.filter((s) => {
95
+ if (!s?.url || seen.has(s.url)) return false;
96
+ seen.add(s.url);
97
+ return true;
98
+ });
99
+ }, [sources]);
100
+
101
+ if (unique.length === 0) return null;
102
+
103
+ const stacked = unique.slice(0, MAX_STACKED);
104
+ const more = unique.length - stacked.length;
105
+
106
+ const isCard = appearance === 'card';
107
+
108
+ const summaryLabel = renderSummary
109
+ ? renderSummary(unique.length, more)
110
+ : isCard
111
+ ? <>
112
+ {unique.length} 个来源{more > 0 ? `(+${more})` : ''}
113
+ </>
114
+ : <>搜索了 {unique.length} 个网站</>;
115
+
116
+ return (
117
+ <div
118
+ className={[
119
+ 'steerable-search-sources',
120
+ isCard
121
+ ? 'rounded-lg border border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-card-bg,#fff)]'
122
+ : '',
123
+ className,
124
+ ].filter(Boolean).join(' ')}
125
+ >
126
+ <button
127
+ type="button"
128
+ onClick={() => setExpanded((v) => !v)}
129
+ className={
130
+ isCard
131
+ ? 'flex w-full items-center justify-between gap-2 px-3 py-2 text-left'
132
+ : 'group flex items-center gap-2 rounded-md py-1 text-sm text-[var(--agent-muted-foreground,#6b7280)] transition-colors duration-150 hover:text-[var(--agent-foreground,#111827)]'
133
+ }
134
+ >
135
+ <span className={isCard ? 'flex items-center gap-2' : 'flex items-center'}>
136
+ {isCard ? (
137
+ <>
138
+ <span className="flex -space-x-2">
139
+ {stacked.map((s, idx) => (
140
+ <FaviconCircle key={`${idx}-${s.url}`} src={s.favicon ?? faviconUrlFor(s.url)} />
141
+ ))}
142
+ </span>
143
+ <span className="text-xs text-[var(--agent-muted-foreground,#6b7280)]">
144
+ {summaryLabel}
145
+ </span>
146
+ </>
147
+ ) : (
148
+ <>
149
+ <span className="flex" style={{ paddingLeft: 0 }}>
150
+ {stacked.map((s, i) => (
151
+ <span
152
+ key={`${i}-${s.url}`}
153
+ className="inline-block"
154
+ style={{ marginLeft: i === 0 ? 0 : -6, zIndex: MAX_STACKED - i }}
155
+ >
156
+ <FaviconCircle src={s.favicon ?? faviconUrlFor(s.url)} />
157
+ </span>
158
+ ))}
159
+ {more > 0 && (
160
+ <span
161
+ className="inline-flex items-center justify-center rounded-full border-2 border-[var(--agent-background,#fff)] bg-[var(--agent-muted,#f3f4f6)] text-[10px] font-medium text-[var(--agent-muted-foreground,#6b7280)]"
162
+ style={{ width: 18, height: 18, marginLeft: -6, zIndex: 0 }}
163
+ >
164
+ +{more}
165
+ </span>
166
+ )}
167
+ </span>
168
+ </>
169
+ )}
170
+ </span>
171
+ {!isCard && <span className="ml-2">{summaryLabel}</span>}
172
+ <ChevronDownIcon
173
+ size={isCard ? 14 : 14}
174
+ className={[
175
+ isCard ? 'text-[var(--agent-muted-foreground,#6b7280)]' : 'h-3.5 w-3.5',
176
+ 'transition-transform duration-200',
177
+ expanded ? 'rotate-180' : '',
178
+ ].filter(Boolean).join(' ')}
179
+ />
180
+ </button>
181
+ {expanded && (
182
+ isCard ? (
183
+ <ul className="divide-y divide-[var(--agent-border,#e5e7eb)] border-t border-[var(--agent-border,#e5e7eb)]">
184
+ {unique.map((s, idx) => (
185
+ <li key={`${idx}-${s.url}`} className="px-3 py-2 text-sm">
186
+ <a
187
+ href={s.url}
188
+ target="_blank"
189
+ rel="noopener noreferrer"
190
+ className="flex items-start gap-2 text-[var(--agent-foreground,#111827)] hover:underline"
191
+ >
192
+ <FaviconCircle src={s.favicon ?? faviconUrlFor(s.url)} size={16} />
193
+ <span className="min-w-0 flex-1">
194
+ <span className="block truncate font-medium">{s.title || extractDomain(s.url)}</span>
195
+ <span className="block truncate text-xs text-[var(--agent-muted-foreground,#6b7280)]">
196
+ {extractDomain(s.url)}
197
+ </span>
198
+ {s.snippet && (
199
+ <span className="mt-1 block text-xs text-[var(--agent-muted-foreground,#6b7280)] line-clamp-2">
200
+ {s.snippet}
201
+ </span>
202
+ )}
203
+ </span>
204
+ <ExternalLinkIcon
205
+ size={12}
206
+ className="mt-1 flex-shrink-0 text-[var(--agent-muted-foreground,#6b7280)]"
207
+ />
208
+ </a>
209
+ </li>
210
+ ))}
211
+ </ul>
212
+ ) : (
213
+ <div className="mt-1.5 flex flex-col gap-0.5">
214
+ {unique.map((s, idx) => (
215
+ <a
216
+ key={`${idx}-${s.url}`}
217
+ href={s.url}
218
+ target="_blank"
219
+ rel="noopener noreferrer"
220
+ className="group/item flex items-center gap-2.5 rounded-md px-2 py-1.5 transition-colors duration-150 hover:bg-[var(--agent-muted,#f3f4f6)]"
221
+ >
222
+ <FaviconCircle src={s.favicon ?? faviconUrlFor(s.url)} size={16} />
223
+ <div className="min-w-0 flex-1">
224
+ <div className="truncate text-xs text-[var(--agent-foreground,#111827)]">
225
+ {s.title || extractDomain(s.url)}
226
+ </div>
227
+ <div className="truncate text-[11px] text-[var(--agent-muted-foreground,#6b7280)]">
228
+ {extractDomain(s.url)}
229
+ </div>
230
+ </div>
231
+ <ExternalLinkIcon
232
+ size={12}
233
+ className="shrink-0 text-[var(--agent-muted-foreground,#6b7280)] opacity-0 transition-opacity group-hover/item:opacity-100"
234
+ />
235
+ </a>
236
+ ))}
237
+ </div>
238
+ )
239
+ )}
240
+ </div>
241
+ );
242
+ };
243
+
244
+ export default SearchSourcesCard;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `<SuggestedRepliesCard />`
3
+ *
4
+ * Quick-reply chips rendered below the composer. The card is pure presentation:
5
+ * the parent decides where to mount it and what `onSelect` does (typically:
6
+ * push the chosen text into the composer and submit).
7
+ */
8
+ import * as React from 'react';
9
+ import type { SuggestedRepliesPayload } from '@steerable/agent-protocol';
10
+
11
+ export interface SuggestedRepliesCardProps {
12
+ payload: SuggestedRepliesPayload;
13
+ onSelect: (text: string) => void;
14
+ className?: string;
15
+ buttonClassName?: string;
16
+ }
17
+
18
+ export const SuggestedRepliesCard: React.FC<SuggestedRepliesCardProps> = ({
19
+ payload,
20
+ onSelect,
21
+ className,
22
+ buttonClassName,
23
+ }) => {
24
+ const suggestions = payload.suggestions ?? [];
25
+ if (suggestions.length === 0) return null;
26
+
27
+ return (
28
+ <div className={['steerable-suggested-replies flex flex-wrap gap-2 py-2', className].filter(Boolean).join(' ')}>
29
+ {suggestions.map((text, idx) => (
30
+ <button
31
+ key={`${idx}-${text}`}
32
+ type="button"
33
+ onClick={() => onSelect(text)}
34
+ className={[
35
+ 'rounded-full border px-3 py-1.5 text-sm transition-colors duration-150',
36
+ 'border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-card-bg,#f9fafb)]',
37
+ 'text-[var(--agent-foreground,#111827)] hover:bg-[var(--agent-accent,#e5e7eb)]',
38
+ buttonClassName,
39
+ ].filter(Boolean).join(' ')}
40
+ >
41
+ {text}
42
+ </button>
43
+ ))}
44
+ </div>
45
+ );
46
+ };
47
+
48
+ export default SuggestedRepliesCard;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * `<SummaryMessageCard />`
3
+ *
4
+ * Compact summary of N previous messages (e.g. history compaction). Collapsed
5
+ * by default so it doesn't dominate the chat scrollback.
6
+ *
7
+ * Appearance variants:
8
+ * - `card` (default): bordered chrome.
9
+ * - `inline`: a small pill button + indented body block below. Matches the
10
+ * deeppath "above the input" aesthetic.
11
+ *
12
+ * Slots:
13
+ * - `renderMarkdown(body)` -- supply your own markdown renderer.
14
+ * - `renderLabel({ isPending, count })` -- replace the trigger label.
15
+ * - `renderToggle({ expanded, canToggle })` -- replace the toggle chevron + text.
16
+ */
17
+ import * as React from 'react';
18
+ import type { SummaryMessagePayload } from '@steerable/agent-protocol';
19
+ import { ArchiveIcon, ChevronDownIcon, ChevronRightIcon, LoaderIcon } from './icons.js';
20
+ import { asReactNode, type RenderSlotResult } from './types.js';
21
+
22
+ export type SummaryMessageCardAppearance = 'card' | 'inline';
23
+
24
+ export interface SummaryMessageLabelArgs {
25
+ isPending: boolean;
26
+ count: number | null;
27
+ }
28
+
29
+ export interface SummaryMessageToggleArgs {
30
+ expanded: boolean;
31
+ canToggle: boolean;
32
+ }
33
+
34
+ export interface SummaryMessageCardProps {
35
+ payload: SummaryMessagePayload;
36
+ renderMarkdown?: (body: string) => RenderSlotResult;
37
+ renderLabel?: (args: SummaryMessageLabelArgs) => RenderSlotResult;
38
+ renderToggle?: (args: SummaryMessageToggleArgs) => RenderSlotResult;
39
+ appearance?: SummaryMessageCardAppearance;
40
+ className?: string;
41
+ defaultExpanded?: boolean;
42
+ }
43
+
44
+ export const SummaryMessageCard: React.FC<SummaryMessageCardProps> = ({
45
+ payload,
46
+ renderMarkdown,
47
+ renderLabel,
48
+ renderToggle,
49
+ appearance = 'card',
50
+ className,
51
+ defaultExpanded = false,
52
+ }) => {
53
+ const [expanded, setExpanded] = React.useState(defaultExpanded);
54
+ const isPending = payload.status === 'pending';
55
+ const count =
56
+ typeof payload.summarizedCount === 'number' && payload.summarizedCount > 0
57
+ ? payload.summarizedCount
58
+ : null;
59
+ const hasBody = Boolean(payload.body && payload.body.trim());
60
+ const canToggle = !isPending && hasBody;
61
+
62
+ const defaultLabel = isPending
63
+ ? '总结中…'
64
+ : `历史摘要${count ? `(${count} 条消息)` : ''}`;
65
+ const labelNode: React.ReactNode = renderLabel
66
+ ? asReactNode(renderLabel({ isPending, count }))
67
+ : defaultLabel;
68
+
69
+ const defaultToggle = expanded
70
+ ? <ChevronDownIcon size={12} />
71
+ : <ChevronRightIcon size={12} />;
72
+ const toggleNode: React.ReactNode = renderToggle
73
+ ? asReactNode(renderToggle({ expanded, canToggle }))
74
+ : defaultToggle;
75
+
76
+ const body: React.ReactNode = expanded && canToggle ? (
77
+ renderMarkdown ? (
78
+ asReactNode(renderMarkdown(payload.body))
79
+ ) : (
80
+ <pre className="whitespace-pre-wrap break-words font-sans">{payload.body}</pre>
81
+ )
82
+ ) : null;
83
+
84
+ if (appearance === 'inline') {
85
+ return (
86
+ <div className={['steerable-summary-message', className].filter(Boolean).join(' ')}>
87
+ <button
88
+ type="button"
89
+ onClick={() => canToggle && setExpanded((v) => !v)}
90
+ disabled={!canToggle}
91
+ className={[
92
+ 'group inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] leading-none text-[var(--agent-muted-foreground,#6b7280)] transition-colors',
93
+ canToggle
94
+ ? 'cursor-pointer hover:bg-[var(--agent-muted,#f3f4f6)] hover:text-[var(--agent-foreground,#111827)]'
95
+ : 'cursor-default',
96
+ ].join(' ')}
97
+ >
98
+ {isPending ? (
99
+ <LoaderIcon size={12} className="animate-spin" />
100
+ ) : (
101
+ <ArchiveIcon size={12} />
102
+ )}
103
+ <span>{labelNode}</span>
104
+ {canToggle && (
105
+ <span className="inline-flex items-center gap-0.5 opacity-60 group-hover:opacity-100">
106
+ {toggleNode}
107
+ </span>
108
+ )}
109
+ </button>
110
+ {body && (
111
+ <div className="ml-3 mt-1.5 rounded-md border border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-muted,#f3f4f6)]/30 px-3 py-2 text-[12px] leading-relaxed text-[var(--agent-muted-foreground,#6b7280)]">
112
+ {body}
113
+ </div>
114
+ )}
115
+ </div>
116
+ );
117
+ }
118
+
119
+ return (
120
+ <div
121
+ className={[
122
+ 'steerable-summary-message rounded-lg border border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-muted,#f3f4f6)]/40 text-xs',
123
+ className,
124
+ ].filter(Boolean).join(' ')}
125
+ >
126
+ <button
127
+ type="button"
128
+ onClick={() => canToggle && setExpanded((v) => !v)}
129
+ disabled={!canToggle}
130
+ className="flex w-full items-center gap-2 px-3 py-2 text-left text-[var(--agent-muted-foreground,#6b7280)]"
131
+ >
132
+ {isPending ? <LoaderIcon size={12} /> : <ArchiveIcon size={12} />}
133
+ <span>{labelNode}</span>
134
+ {canToggle && <span className="ml-auto">{toggleNode}</span>}
135
+ </button>
136
+ {body && (
137
+ <div className="border-t border-[var(--agent-border,#e5e7eb)] px-3 py-2 text-[var(--agent-foreground,#111827)]">
138
+ {body}
139
+ </div>
140
+ )}
141
+ </div>
142
+ );
143
+ };
144
+
145
+ export default SummaryMessageCard;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * `<ThinkingProcessCard />`
3
+ *
4
+ * Collapsible chain-of-thought panel.
5
+ *
6
+ * Behaviour switches:
7
+ * - `isStreaming` paints the card with a soft pulsing border to convey
8
+ * "thoughts still arriving".
9
+ * - `autoExpandWhileStreaming` opens the panel automatically when streaming
10
+ * starts; `autoCollapseOnComplete` closes it once streaming finishes.
11
+ * - Controlled mode: pass `expanded` + `onExpandedChange` to delegate the
12
+ * open/close state (used by host apps that persist expansion in a
13
+ * context like deeppath's `MessageListContext`).
14
+ *
15
+ * Slots:
16
+ * - `renderMarkdown(body)`: replace the default `<pre>` body renderer.
17
+ * - `renderLabel({ isStreaming, isExpanded })`: replace the trigger label
18
+ * (deeppath uses "正在思考:...", "思考过程", "下一步推理"... variants).
19
+ * - `renderStreamingFooter`: extra content rendered below the body while
20
+ * streaming (deeppath shows a pulsing "思考中..." line).
21
+ * - `bodyClassName` overrides default compact body typography.
22
+ */
23
+ import * as React from 'react';
24
+ import type { ThinkingProcessPayload } from '@steerable/agent-protocol';
25
+ import { ChevronDownIcon, ChevronRightIcon } from './icons.js';
26
+ import { asReactNode, type RenderSlotResult } from './types.js';
27
+
28
+ export interface ThinkingProcessLabelArgs {
29
+ isStreaming: boolean;
30
+ isExpanded: boolean;
31
+ }
32
+
33
+ export interface ThinkingProcessCardProps {
34
+ payload: ThinkingProcessPayload;
35
+ renderMarkdown?: (body: string) => RenderSlotResult;
36
+ renderLabel?: (args: ThinkingProcessLabelArgs) => RenderSlotResult;
37
+ renderStreamingFooter?: () => RenderSlotResult;
38
+ className?: string;
39
+ bodyClassName?: string;
40
+ isStreaming?: boolean;
41
+ expanded?: boolean;
42
+ onExpandedChange?: (next: boolean) => void;
43
+ autoExpandWhileStreaming?: boolean;
44
+ autoCollapseOnComplete?: boolean;
45
+ durationMs?: number | null;
46
+ }
47
+
48
+ function formatDuration(ms?: number | null): string | null {
49
+ if (!ms || ms <= 0) return null;
50
+ return `${(ms / 1000).toFixed(1)}秒`;
51
+ }
52
+
53
+ export const ThinkingProcessCard: React.FC<ThinkingProcessCardProps> = ({
54
+ payload,
55
+ renderMarkdown,
56
+ renderLabel,
57
+ renderStreamingFooter,
58
+ className,
59
+ bodyClassName,
60
+ isStreaming = false,
61
+ expanded: expandedProp,
62
+ onExpandedChange,
63
+ autoExpandWhileStreaming = false,
64
+ autoCollapseOnComplete = false,
65
+ durationMs = null,
66
+ }) => {
67
+ const isControlled = expandedProp !== undefined;
68
+ const [internalExpanded, setInternalExpanded] = React.useState(
69
+ Boolean(payload.defaultExpanded),
70
+ );
71
+ const expanded = isControlled ? Boolean(expandedProp) : internalExpanded;
72
+ const setExpanded = React.useCallback(
73
+ (next: boolean) => {
74
+ if (!isControlled) setInternalExpanded(next);
75
+ onExpandedChange?.(next);
76
+ },
77
+ [isControlled, onExpandedChange],
78
+ );
79
+
80
+ const prevStreamingRef = React.useRef(isStreaming);
81
+ React.useEffect(() => {
82
+ const wasStreaming = prevStreamingRef.current;
83
+ if (autoExpandWhileStreaming && isStreaming && !expanded && payload.body?.trim()) {
84
+ setExpanded(true);
85
+ }
86
+ if (autoCollapseOnComplete && wasStreaming && !isStreaming && expanded) {
87
+ setExpanded(false);
88
+ }
89
+ prevStreamingRef.current = isStreaming;
90
+ }, [
91
+ isStreaming,
92
+ expanded,
93
+ autoExpandWhileStreaming,
94
+ autoCollapseOnComplete,
95
+ payload.body,
96
+ setExpanded,
97
+ ]);
98
+
99
+ const showBody = expanded || (isStreaming && Boolean(payload.body));
100
+ const duration = formatDuration(durationMs);
101
+
102
+ const defaultLabel = isStreaming ? '正在思考...' : '思考过程';
103
+ const labelNode = renderLabel
104
+ ? asReactNode(renderLabel({ isStreaming, isExpanded: expanded }))
105
+ : defaultLabel;
106
+
107
+ const defaultBodyClasses =
108
+ 'whitespace-pre-wrap break-words font-sans text-xs leading-relaxed text-[var(--agent-muted-foreground,#6b7280)]';
109
+
110
+ return (
111
+ <div className={['steerable-thinking-process', className].filter(Boolean).join(' ')}>
112
+ <button
113
+ type="button"
114
+ onClick={() => setExpanded(!expanded)}
115
+ className={[
116
+ 'flex items-center gap-1 text-xs transition-colors duration-200',
117
+ isStreaming
118
+ ? 'font-medium text-[var(--agent-foreground,#111827)]'
119
+ : 'text-[var(--agent-muted-foreground,#6b7280)] hover:text-[var(--agent-foreground,#111827)]',
120
+ ].join(' ')}
121
+ >
122
+ {expanded ? <ChevronDownIcon size={14} /> : <ChevronRightIcon size={14} />}
123
+ <span>{labelNode}</span>
124
+ {duration && !isStreaming && (
125
+ <span className="ml-1 text-[var(--agent-muted-foreground,#6b7280)]">({duration})</span>
126
+ )}
127
+ {isStreaming && !expanded && (
128
+ <span
129
+ aria-hidden
130
+ className="ml-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-[var(--agent-muted-foreground,#6b7280)]"
131
+ />
132
+ )}
133
+ </button>
134
+ {showBody && (
135
+ <div
136
+ className={[
137
+ 'mt-1 overflow-hidden rounded-lg border p-3',
138
+ isStreaming
139
+ ? 'border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-muted,#f3f4f6)]/30 shadow-sm animate-pulse'
140
+ : 'border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-muted,#f3f4f6)]/30',
141
+ ].join(' ')}
142
+ >
143
+ <div className={bodyClassName ?? defaultBodyClasses}>
144
+ {renderMarkdown ? asReactNode(renderMarkdown(payload.body)) : payload.body}
145
+ </div>
146
+ {isStreaming && renderStreamingFooter && (
147
+ <div className="mt-2">{asReactNode(renderStreamingFooter())}</div>
148
+ )}
149
+ </div>
150
+ )}
151
+ </div>
152
+ );
153
+ };
154
+
155
+ export default ThinkingProcessCard;