@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,328 @@
1
+ /**
2
+ * `<OrchestrationPlanCard />`
3
+ *
4
+ * The visible surface of an otherwise invisible Coordinator agent. Renders
5
+ * one row per worker task with a status dot driven by `taskStatuses` (a map
6
+ * the parent computes off sibling assistant bubbles tagged with the same
7
+ * orchestrationGroupId + orchestrationTaskId).
8
+ *
9
+ * Three top-level shells are supported via mutually-exclusive flags so the
10
+ * Coordinator can express its lifecycle without forcing the caller to render
11
+ * three different components:
12
+ * - `loading` + optional `loadingHint` — Coordinator is still planning.
13
+ * - `failed` + optional `failure` — planning errored.
14
+ * - default — `payload.tasks` are rendered.
15
+ *
16
+ * Slots:
17
+ * - `renderAbove` / `renderBelowHeader` — out-of-flow rails for badges and
18
+ * context-load lines that deeppath wants to ride along.
19
+ * - `renderTaskRow(task, status)` — full row override (deeppath uses
20
+ * it to draw the per-agent colored chip + monospaced task id + right-
21
+ * aligned status label).
22
+ * - `headerLabel`, `headerSecondary` — override the default
23
+ * "编排计划 · N 个子任务" header label/subline.
24
+ * - `hideMode`, `hideHeaderCounts` — drop the optional header chips
25
+ * when the host UI does not want them (deeppath hides both).
26
+ * - controlled `expanded` + `onExpandedChange`.
27
+ */
28
+ import * as React from 'react';
29
+ import type { OrchestrationPlanPayload } from '@steerable/agent-protocol';
30
+ import {
31
+ ChevronDownIcon,
32
+ ChevronRightIcon,
33
+ GitBranchIcon,
34
+ LoaderIcon,
35
+ AlertIcon,
36
+ } from './icons.js';
37
+ import { asReactNode, type RenderSlotResult } from './types.js';
38
+
39
+ export type OrchestrationTaskStatus =
40
+ | 'pending'
41
+ | 'running'
42
+ | 'ok'
43
+ | 'skipped'
44
+ | 'failed'
45
+ | 'ask_user_paused';
46
+
47
+ export type OrchestrationTask = OrchestrationPlanPayload['tasks'][number];
48
+
49
+ export interface OrchestrationPlanCardProps {
50
+ payload: OrchestrationPlanPayload;
51
+ /** Status of each task, keyed by `payload.tasks[i].id`. Missing → pending. */
52
+ taskStatuses?: Record<string, OrchestrationTaskStatus>;
53
+ /** Lookup of agentId → display name; falls back to the raw id. */
54
+ agentNameFor?: (agentId: string) => string;
55
+ className?: string;
56
+ defaultExpanded?: boolean;
57
+ expanded?: boolean;
58
+ onExpandedChange?: (next: boolean) => void;
59
+
60
+ // Lifecycle shells -- mutually exclusive with each other & the default body
61
+ loading?: boolean;
62
+ loadingHint?: React.ReactNode;
63
+ failed?: boolean;
64
+ failure?: React.ReactNode;
65
+
66
+ // Header customisation
67
+ headerLabel?: React.ReactNode;
68
+ headerSecondary?: React.ReactNode;
69
+ hideMode?: boolean;
70
+ hideHeaderCounts?: boolean;
71
+
72
+ // Out-of-flow rails
73
+ renderAbove?: () => RenderSlotResult;
74
+ renderBelowHeader?: () => RenderSlotResult;
75
+
76
+ // Per-row override
77
+ renderTaskRow?: (task: OrchestrationTask, status: OrchestrationTaskStatus) => RenderSlotResult;
78
+ }
79
+
80
+ const STATUS_LABEL: Record<OrchestrationTaskStatus, string> = {
81
+ pending: '排队中',
82
+ running: '执行中',
83
+ ok: '已完成',
84
+ skipped: '已跳过',
85
+ failed: '失败',
86
+ ask_user_paused: '等待用户',
87
+ };
88
+
89
+ const DOT_CLASS: Record<OrchestrationTaskStatus, string> = {
90
+ pending: 'bg-[var(--agent-muted-foreground,#9ca3af)]',
91
+ running: 'bg-amber-500 animate-pulse',
92
+ ok: 'bg-emerald-500',
93
+ skipped: 'bg-[var(--agent-muted-foreground,#d1d5db)]',
94
+ failed: 'bg-rose-500',
95
+ ask_user_paused: 'bg-sky-500',
96
+ };
97
+
98
+ export const OrchestrationPlanCard: React.FC<OrchestrationPlanCardProps> = ({
99
+ payload,
100
+ taskStatuses,
101
+ agentNameFor,
102
+ className,
103
+ defaultExpanded = true,
104
+ expanded: expandedProp,
105
+ onExpandedChange,
106
+ loading,
107
+ loadingHint,
108
+ failed,
109
+ failure,
110
+ headerLabel,
111
+ headerSecondary,
112
+ hideMode,
113
+ hideHeaderCounts,
114
+ renderAbove,
115
+ renderBelowHeader,
116
+ renderTaskRow,
117
+ }) => {
118
+ const [expandedState, setExpandedState] = React.useState(defaultExpanded);
119
+ const isControlled = expandedProp !== undefined;
120
+ const expanded = isControlled ? Boolean(expandedProp) : expandedState;
121
+ const setExpanded = (next: boolean) => {
122
+ if (!isControlled) setExpandedState(next);
123
+ onExpandedChange?.(next);
124
+ };
125
+
126
+ const above = renderAbove ? asReactNode(renderAbove()) : null;
127
+ const belowHeader = renderBelowHeader ? asReactNode(renderBelowHeader()) : null;
128
+
129
+ // ---------------- loading shell ----------------
130
+ if (loading) {
131
+ return (
132
+ <div
133
+ className={[
134
+ 'steerable-orchestration-plan',
135
+ className,
136
+ ].filter(Boolean).join(' ')}
137
+ >
138
+ {above}
139
+ <div className="rounded-lg border border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-card-bg,#fff)] text-sm shadow-sm">
140
+ <div className="flex w-full items-center gap-2 px-3 py-2 text-xs text-[var(--agent-muted-foreground,#6b7280)]">
141
+ <LoaderIcon size={14} className="shrink-0 animate-spin" />
142
+ <GitBranchIcon size={14} className="shrink-0" />
143
+ <span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap">
144
+ {headerLabel ?? (
145
+ <span className="font-medium text-[var(--agent-foreground,#111827)]">协调员</span>
146
+ )}
147
+ <span className="text-[var(--agent-muted-foreground,#9ca3af)]">·</span>
148
+ <span>{loadingHint ?? '正在规划'}</span>
149
+ </span>
150
+ </div>
151
+ {belowHeader}
152
+ </div>
153
+ </div>
154
+ );
155
+ }
156
+
157
+ // ---------------- failed shell ----------------
158
+ if (failed) {
159
+ return (
160
+ <div
161
+ className={[
162
+ 'steerable-orchestration-plan',
163
+ className,
164
+ ].filter(Boolean).join(' ')}
165
+ >
166
+ {above}
167
+ <div className="rounded-lg border border-rose-500/30 bg-rose-500/5 text-sm shadow-sm">
168
+ <div className="flex w-full items-start gap-2 px-3 py-2 text-xs">
169
+ <AlertIcon size={14} className="mt-0.5 shrink-0 text-rose-500" />
170
+ <GitBranchIcon size={14} className="mt-0.5 shrink-0 text-[var(--agent-muted-foreground,#6b7280)]" />
171
+ <div className="min-w-0 flex-1">
172
+ <div className="inline-flex items-center gap-1.5 whitespace-nowrap">
173
+ {headerLabel ?? (
174
+ <span className="font-medium text-[var(--agent-foreground,#111827)]">协调员</span>
175
+ )}
176
+ <span className="text-[var(--agent-muted-foreground,#9ca3af)]">·</span>
177
+ <span className="text-rose-500">规划失败</span>
178
+ </div>
179
+ {failure && (
180
+ <div className="mt-1 break-words leading-relaxed text-[var(--agent-muted-foreground,#6b7280)]">
181
+ {failure}
182
+ </div>
183
+ )}
184
+ </div>
185
+ </div>
186
+ {belowHeader}
187
+ </div>
188
+ </div>
189
+ );
190
+ }
191
+
192
+ // ---------------- default ready shell ----------------
193
+ const tasks = payload.tasks ?? [];
194
+
195
+ const counts = React.useMemo(() => {
196
+ const c = { running: 0, ok: 0, failed: 0, paused: 0, total: tasks.length };
197
+ for (const t of tasks) {
198
+ const s = (taskStatuses?.[t.id] ?? 'pending') as OrchestrationTaskStatus;
199
+ if (s === 'running') c.running += 1;
200
+ else if (s === 'ok') c.ok += 1;
201
+ else if (s === 'failed') c.failed += 1;
202
+ else if (s === 'ask_user_paused') c.paused += 1;
203
+ }
204
+ return c;
205
+ }, [tasks, taskStatuses]);
206
+
207
+ return (
208
+ <div
209
+ className={[
210
+ 'steerable-orchestration-plan',
211
+ className,
212
+ ].filter(Boolean).join(' ')}
213
+ >
214
+ {above}
215
+ <div className="rounded-lg border border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-card-bg,#fff)] text-sm shadow-sm">
216
+ <button
217
+ type="button"
218
+ onClick={() => setExpanded(!expanded)}
219
+ className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-xs text-[var(--agent-muted-foreground,#6b7280)] transition-all duration-200 hover:text-[var(--agent-foreground,#111827)]"
220
+ >
221
+ <span className="flex min-w-0 items-center gap-2">
222
+ {expanded ? <ChevronDownIcon size={14} /> : <ChevronRightIcon size={14} />}
223
+ <GitBranchIcon size={14} className="shrink-0" />
224
+ <span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap">
225
+ {headerLabel ?? (
226
+ <span className="font-medium text-[var(--agent-foreground,#111827)]">编排计划</span>
227
+ )}
228
+ {headerSecondary !== undefined ? (
229
+ <>
230
+ <span className="text-[var(--agent-muted-foreground,#9ca3af)]">·</span>
231
+ <span>{headerSecondary}</span>
232
+ </>
233
+ ) : (
234
+ <>
235
+ <span className="text-[var(--agent-muted-foreground,#9ca3af)]">·</span>
236
+ <span>{tasks.length} 个子任务</span>
237
+ </>
238
+ )}
239
+ {!hideMode && payload.mode && (
240
+ <span className="ml-1 rounded-full bg-[var(--agent-muted,#f3f4f6)] px-2 py-0.5 text-[10px] uppercase tracking-wider text-[var(--agent-muted-foreground,#6b7280)]">
241
+ {payload.mode}
242
+ </span>
243
+ )}
244
+ </span>
245
+ {payload.rationale && !expanded && (
246
+ <span className="min-w-0 flex-1 truncate text-[var(--agent-muted-foreground,#9ca3af)]">
247
+ {payload.rationale}
248
+ </span>
249
+ )}
250
+ </span>
251
+ {!hideHeaderCounts && (
252
+ <span className="flex shrink-0 items-center gap-2">
253
+ {counts.running > 0 && (
254
+ <span className="flex items-center gap-1">
255
+ <LoaderIcon size={12} className="text-amber-500" /> {counts.running}
256
+ </span>
257
+ )}
258
+ {counts.failed > 0 && (
259
+ <span className="flex items-center gap-1 text-rose-600">
260
+ <AlertIcon size={12} /> {counts.failed}
261
+ </span>
262
+ )}
263
+ <span>
264
+ {counts.ok}/{counts.total}
265
+ </span>
266
+ </span>
267
+ )}
268
+ </button>
269
+
270
+ {belowHeader}
271
+
272
+ {expanded && (
273
+ <div className="px-3 pb-3">
274
+ {payload.rationale && (
275
+ <p className="pb-2 text-xs leading-relaxed text-[var(--agent-muted-foreground,#6b7280)]">
276
+ {payload.rationale}
277
+ </p>
278
+ )}
279
+ <ul className="space-y-1.5">
280
+ {tasks.map((t) => {
281
+ const status = (taskStatuses?.[t.id] ?? 'pending') as OrchestrationTaskStatus;
282
+ if (renderTaskRow) {
283
+ return (
284
+ <React.Fragment key={t.id}>
285
+ {asReactNode(renderTaskRow(t, status))}
286
+ </React.Fragment>
287
+ );
288
+ }
289
+ return (
290
+ <li key={t.id} className="flex items-start gap-2 text-sm">
291
+ <span
292
+ className={`mt-1.5 h-2 w-2 flex-shrink-0 rounded-full ${DOT_CLASS[status]}`}
293
+ aria-hidden
294
+ />
295
+ <div className="min-w-0 flex-1">
296
+ <div className="flex items-center gap-2">
297
+ <span className="font-medium text-[var(--agent-foreground,#111827)]">
298
+ {agentNameFor ? agentNameFor(t.agentId) : t.agentId}
299
+ </span>
300
+ <span className="text-xs text-[var(--agent-muted-foreground,#6b7280)]">
301
+ {STATUS_LABEL[status]}
302
+ </span>
303
+ </div>
304
+ {t.prompt && (
305
+ <p className="mt-0.5 line-clamp-2 text-xs text-[var(--agent-muted-foreground,#6b7280)]">
306
+ {t.prompt}
307
+ </p>
308
+ )}
309
+ {(t.dependsOn?.length ?? 0) > 0 && (
310
+ <p className="mt-0.5 text-[10px] uppercase tracking-wider text-[var(--agent-muted-foreground,#9ca3af)]">
311
+ 依赖:{t.dependsOn!.join(', ')}
312
+ </p>
313
+ )}
314
+ </div>
315
+ </li>
316
+ );
317
+ })}
318
+ </ul>
319
+ </div>
320
+ )}
321
+ </div>
322
+ </div>
323
+ );
324
+ };
325
+
326
+ export { STATUS_LABEL as ORCHESTRATION_STATUS_LABEL, DOT_CLASS as ORCHESTRATION_DOT_CLASS };
327
+
328
+ export default OrchestrationPlanCard;
@@ -0,0 +1,312 @@
1
+ /**
2
+ * `<PlanSelectorCard />`
3
+ *
4
+ * Two-to-four candidate plans with effort / risk metrics and pros/cons.
5
+ *
6
+ * Calls `onSelect(planId)` once the user picks one. If `payload.selectedPlan`
7
+ * is filled in by the backend the card switches to read-only mode and
8
+ * highlights the chosen plan.
9
+ *
10
+ * Behaviour switches:
11
+ * - `expandableDetails` (default `false`): when on, only the plan summary +
12
+ * metrics + select button are visible by default; approach / pros / cons
13
+ * sit behind a "查看详情" toggle.
14
+ *
15
+ * Slots:
16
+ * - `renderHeader(plansCount)` -- replace the "候选方案 / 为你准备了 N 个方案"
17
+ * label area.
18
+ * - `renderBottomHint({ submitted })` -- swap the hint shown below the list.
19
+ * - `existingGoalPrefix`, `newGoalPrefix` -- localise goal-attribution
20
+ * phrasing.
21
+ */
22
+ import * as React from 'react';
23
+ import type { PlanSelectorPayload } from '@steerable/agent-protocol';
24
+ import {
25
+ ClockIcon,
26
+ FlameIcon,
27
+ ShieldIcon,
28
+ CheckIcon,
29
+ TargetIcon,
30
+ AlertIcon,
31
+ ThumbsUpIcon,
32
+ LoaderIcon,
33
+ ChevronDownIcon,
34
+ ChevronUpIcon,
35
+ } from './icons.js';
36
+ import { asReactNode, type RenderSlotResult } from './types.js';
37
+
38
+ type Level = 'low' | 'medium' | 'high';
39
+
40
+ const EFFORT_CONFIG: Record<Level, { label: string; text: string; bg: string }> = {
41
+ low: {
42
+ label: '低投入',
43
+ text: 'text-green-600 dark:text-green-400',
44
+ bg: 'bg-green-50 dark:bg-green-950/30',
45
+ },
46
+ medium: {
47
+ label: '中投入',
48
+ text: 'text-amber-600 dark:text-amber-400',
49
+ bg: 'bg-amber-50 dark:bg-amber-950/30',
50
+ },
51
+ high: {
52
+ label: '高投入',
53
+ text: 'text-red-600 dark:text-red-400',
54
+ bg: 'bg-red-50 dark:bg-red-950/30',
55
+ },
56
+ };
57
+ const RISK_CONFIG: Record<Level, { label: string; text: string; bg: string }> = {
58
+ low: {
59
+ label: '低风险',
60
+ text: 'text-green-600 dark:text-green-400',
61
+ bg: 'bg-green-50 dark:bg-green-950/30',
62
+ },
63
+ medium: {
64
+ label: '中风险',
65
+ text: 'text-amber-600 dark:text-amber-400',
66
+ bg: 'bg-amber-50 dark:bg-amber-950/30',
67
+ },
68
+ high: {
69
+ label: '高风险',
70
+ text: 'text-red-600 dark:text-red-400',
71
+ bg: 'bg-red-50 dark:bg-red-950/30',
72
+ },
73
+ };
74
+
75
+ export interface PlanSelectorCardProps {
76
+ payload: PlanSelectorPayload;
77
+ isComplete?: boolean;
78
+ onSelect?: (planId: string) => void;
79
+ className?: string;
80
+ expandableDetails?: boolean;
81
+ existingGoalPrefix?: string;
82
+ newGoalPrefix?: string;
83
+ renderHeader?: (plansCount: number) => RenderSlotResult;
84
+ renderBottomHint?: (args: { submitted: boolean }) => RenderSlotResult;
85
+ }
86
+
87
+ export const PlanSelectorCard: React.FC<PlanSelectorCardProps> = ({
88
+ payload,
89
+ isComplete,
90
+ onSelect,
91
+ className,
92
+ expandableDetails = false,
93
+ existingGoalPrefix = '归属目标:',
94
+ newGoalPrefix = '新目标:',
95
+ renderHeader,
96
+ renderBottomHint,
97
+ }) => {
98
+ const isReadOnly = Boolean(isComplete || payload.selectedPlan);
99
+ const plans = payload.plans ?? [];
100
+ const [submittingFor, setSubmittingFor] = React.useState<string | null>(null);
101
+ const [expandedId, setExpandedId] = React.useState<string | null>(null);
102
+
103
+ const goalLabel = (() => {
104
+ const g = payload.goalAttribution;
105
+ if (!g) return null;
106
+ if (g.type === 'existing') return `${existingGoalPrefix}${g.existingGoalTitle ?? ''}`;
107
+ return `${newGoalPrefix}${g.newGoalTitle ?? ''}`;
108
+ })();
109
+
110
+ const handleSelect = (planId: string) => {
111
+ if (!onSelect || isReadOnly) return;
112
+ setSubmittingFor(planId);
113
+ try {
114
+ onSelect(planId);
115
+ } finally {
116
+ setSubmittingFor(null);
117
+ }
118
+ };
119
+
120
+ return (
121
+ <div
122
+ className={[
123
+ 'steerable-plan-selector rounded-lg border border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-card-bg,#fff)] text-sm text-[var(--agent-foreground,#111827)]',
124
+ className,
125
+ ].filter(Boolean).join(' ')}
126
+ >
127
+ <div className="space-y-3 p-3">
128
+ {renderHeader ? (
129
+ <div>{asReactNode(renderHeader(plans.length))}</div>
130
+ ) : (
131
+ <div className="flex items-center gap-2">
132
+ <TargetIcon size={14} className="text-blue-500" />
133
+ <span className="text-sm font-medium">候选方案</span>
134
+ {goalLabel && (
135
+ <span className="text-xs text-[var(--agent-muted-foreground,#6b7280)]">
136
+ · {goalLabel}
137
+ </span>
138
+ )}
139
+ </div>
140
+ )}
141
+ {renderHeader && goalLabel && (
142
+ <p className="text-xs text-[var(--agent-muted-foreground,#6b7280)]">{goalLabel}</p>
143
+ )}
144
+
145
+ <div className="space-y-2">
146
+ {plans.map((p) => {
147
+ const isSelected = payload.selectedPlan === p.id;
148
+ const effort = EFFORT_CONFIG[p.metrics?.effortLevel || 'medium'];
149
+ const risk = RISK_CONFIG[p.metrics?.riskLevel || 'medium'];
150
+ const isExpanded = expandableDetails ? expandedId === p.id : true;
151
+
152
+ return (
153
+ <div
154
+ key={p.id}
155
+ className={[
156
+ 'rounded-lg border transition-all duration-200',
157
+ isSelected
158
+ ? 'border-blue-400 dark:border-blue-600 bg-blue-50/50 dark:bg-blue-950/20 ring-1 ring-blue-200 dark:ring-blue-800'
159
+ : 'border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-background,#fff)] hover:border-[var(--agent-border,#e5e7eb)]/80',
160
+ isReadOnly && !isSelected ? 'opacity-50' : '',
161
+ ].filter(Boolean).join(' ')}
162
+ >
163
+ <div className="p-3">
164
+ <div className="flex items-start justify-between gap-2">
165
+ <div className="min-w-0 flex-1">
166
+ <div className="flex items-center gap-2">
167
+ <h4 className="truncate text-sm font-medium">{p.name}</h4>
168
+ {isSelected && (
169
+ <span className="inline-flex items-center gap-0.5 rounded-full bg-blue-100 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
170
+ <CheckIcon size={10} /> 已选择
171
+ </span>
172
+ )}
173
+ </div>
174
+ <p className="mt-1 text-xs leading-relaxed text-[var(--agent-muted-foreground,#6b7280)]">
175
+ {p.summary}
176
+ </p>
177
+ </div>
178
+ </div>
179
+
180
+ <div className="mt-2 flex flex-wrap items-center gap-2">
181
+ {p.metrics?.duration && (
182
+ <span className="inline-flex items-center gap-1 rounded-full bg-[var(--agent-muted,#f3f4f6)] px-2 py-0.5 text-[10px] font-medium text-[var(--agent-muted-foreground,#6b7280)]">
183
+ <ClockIcon size={11} />
184
+ {p.metrics.duration}
185
+ </span>
186
+ )}
187
+ <span
188
+ className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium ${effort.bg} ${effort.text}`}
189
+ >
190
+ <FlameIcon size={11} />
191
+ {effort.label}
192
+ </span>
193
+ <span
194
+ className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium ${risk.bg} ${risk.text}`}
195
+ >
196
+ <ShieldIcon size={11} />
197
+ {risk.label}
198
+ </span>
199
+ </div>
200
+
201
+ <div className="mt-2 flex items-center justify-between">
202
+ {expandableDetails ? (
203
+ <button
204
+ type="button"
205
+ onClick={() => setExpandedId(isExpanded ? null : p.id)}
206
+ className="inline-flex items-center gap-1 text-[11px] text-[var(--agent-muted-foreground,#6b7280)] transition-colors duration-200 hover:text-[var(--agent-foreground,#111827)]"
207
+ >
208
+ {isExpanded ? (
209
+ <>
210
+ <ChevronUpIcon size={11} /> 收起详情
211
+ </>
212
+ ) : (
213
+ <>
214
+ <ChevronDownIcon size={11} /> 查看详情
215
+ </>
216
+ )}
217
+ </button>
218
+ ) : (
219
+ <span />
220
+ )}
221
+
222
+ {!isReadOnly && onSelect && (
223
+ <button
224
+ type="button"
225
+ onClick={() => handleSelect(p.id)}
226
+ disabled={submittingFor !== null}
227
+ className="inline-flex items-center gap-1 rounded-full bg-[var(--agent-primary,#18181b)] px-3 py-1 text-xs font-medium text-[var(--agent-primary-foreground,#fff)] shadow-sm transition-all duration-200 hover:opacity-90 disabled:opacity-50"
228
+ >
229
+ {submittingFor === p.id && <LoaderIcon size={10} />}
230
+ 选择此方案
231
+ </button>
232
+ )}
233
+ </div>
234
+ </div>
235
+
236
+ {isExpanded && (
237
+ <div className="space-y-2 border-t border-[var(--agent-border,#e5e7eb)]/50 px-3 pb-3 pt-2">
238
+ {p.approach && (
239
+ <p className="text-xs leading-relaxed">{p.approach}</p>
240
+ )}
241
+ <div className="grid grid-cols-2 gap-2">
242
+ {(p.pros?.length ?? 0) > 0 && (
243
+ <div className="space-y-1">
244
+ <div className="flex items-center gap-1 text-[10px] font-medium text-green-600 dark:text-green-400">
245
+ <ThumbsUpIcon size={11} />
246
+ 优势
247
+ </div>
248
+ {p.pros!.map((pro, i) => (
249
+ <p
250
+ key={`pro-${i}`}
251
+ className="pl-4 text-[11px] text-[var(--agent-muted-foreground,#6b7280)]"
252
+ >
253
+ {pro}
254
+ </p>
255
+ ))}
256
+ </div>
257
+ )}
258
+ {(p.cons?.length ?? 0) > 0 && (
259
+ <div className="space-y-1">
260
+ <div className="flex items-center gap-1 text-[10px] font-medium text-amber-600 dark:text-amber-400">
261
+ <AlertIcon size={11} />
262
+ 注意
263
+ </div>
264
+ {p.cons!.map((con, i) => (
265
+ <p
266
+ key={`con-${i}`}
267
+ className="pl-4 text-[11px] text-[var(--agent-muted-foreground,#6b7280)]"
268
+ >
269
+ {con}
270
+ </p>
271
+ ))}
272
+ </div>
273
+ )}
274
+ </div>
275
+ {p.bestFor && (
276
+ <p className="text-[11px] italic text-[var(--agent-muted-foreground,#6b7280)]">
277
+ 适合:{p.bestFor}
278
+ </p>
279
+ )}
280
+ </div>
281
+ )}
282
+ </div>
283
+ );
284
+ })}
285
+ </div>
286
+
287
+ {payload.comparison && (
288
+ <p className="text-xs text-[var(--agent-muted-foreground,#6b7280)]">
289
+ {payload.comparison}
290
+ </p>
291
+ )}
292
+
293
+ <div className="flex items-center justify-between pt-1">
294
+ {renderBottomHint
295
+ ? asReactNode(renderBottomHint({ submitted: isReadOnly }))
296
+ : !isReadOnly
297
+ ? <span className="text-xs text-[var(--agent-muted-foreground,#6b7280)]">
298
+ 点击选择方案
299
+ </span>
300
+ : <div className="flex items-center gap-1.5">
301
+ <CheckIcon size={12} className="text-blue-500" />
302
+ <span className="text-xs text-[var(--agent-muted-foreground,#6b7280)]">
303
+ 方案已选择
304
+ </span>
305
+ </div>}
306
+ </div>
307
+ </div>
308
+ </div>
309
+ );
310
+ };
311
+
312
+ export default PlanSelectorCard;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `<PlanStepsCard />`
3
+ *
4
+ * Compact horizontal checklist of skill names: `step1 / step2 / step3`. Used
5
+ * to show what the agent intends to do this turn. Pure render off
6
+ * `PlanStepsPayload`; the parent passes `isStreaming` if it wants the pulsing
7
+ * cursor dot at the end.
8
+ */
9
+ import * as React from 'react';
10
+ import type { PlanStepsPayload } from '@steerable/agent-protocol';
11
+ import { ListChecksIcon } from './icons.js';
12
+
13
+ export interface PlanStepsCardProps {
14
+ payload: PlanStepsPayload;
15
+ isStreaming?: boolean;
16
+ className?: string;
17
+ }
18
+
19
+ export const PlanStepsCard: React.FC<PlanStepsCardProps> = ({ payload, isStreaming, className }) => {
20
+ const steps = payload.steps ?? [];
21
+ if (steps.length === 0 && !isStreaming) return null;
22
+
23
+ return (
24
+ <div
25
+ className={[
26
+ 'steerable-plan-steps flex items-start gap-2 rounded-lg border px-3 py-2 transition-all duration-200',
27
+ isStreaming
28
+ ? 'border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-card-bg,#f9fafb)] shadow-sm'
29
+ : 'border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-muted,#f3f4f6)]',
30
+ className,
31
+ ].filter(Boolean).join(' ')}
32
+ >
33
+ <ListChecksIcon size={14} className="mt-0.5 flex-shrink-0 text-[var(--agent-muted-foreground,#6b7280)]" />
34
+ <div className="flex flex-wrap items-center gap-1 text-xs text-[var(--agent-muted-foreground,#6b7280)]">
35
+ {steps.map((step, idx) => (
36
+ <React.Fragment key={`${idx}-${step}`}>
37
+ {idx > 0 && <span aria-hidden className="select-none opacity-40">/</span>}
38
+ <span className="whitespace-nowrap">{step}</span>
39
+ </React.Fragment>
40
+ ))}
41
+ {isStreaming && (
42
+ <span
43
+ aria-hidden
44
+ className="ml-1 inline-block h-1.5 w-1.5 translate-y-[1px] animate-pulse rounded-full bg-current"
45
+ />
46
+ )}
47
+ </div>
48
+ </div>
49
+ );
50
+ };
51
+
52
+ export default PlanStepsCard;