@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,152 @@
1
+ /**
2
+ * `<ToolExecutionCard />`
3
+ *
4
+ * Unified renderer for a single tool / action invocation. Replaces both
5
+ * deeppath's ActionSegment inline strip and deeppath-agent's
6
+ * ExecutedActionsCard: one row per call with status / duration / expandable
7
+ * args + output JSON. The optional `renderArgs` / `renderOutput` slots let an
8
+ * app substitute a custom JSON viewer or rich payload renderer.
9
+ */
10
+ import * as React from 'react';
11
+ import type { ToolExecutionPayload } from '@steerable/agent-protocol';
12
+ import {
13
+ CheckIcon,
14
+ LoaderIcon,
15
+ AlertIcon,
16
+ ZapIcon,
17
+ ChevronDownIcon,
18
+ ChevronRightIcon,
19
+ PlayIcon,
20
+ StopIcon,
21
+ } from './icons.js';
22
+
23
+ type Status = 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled';
24
+
25
+ const STATUS_LABEL: Record<Status, string> = {
26
+ pending: '待执行',
27
+ running: '执行中',
28
+ succeeded: '已完成',
29
+ failed: '失败',
30
+ cancelled: '已取消',
31
+ };
32
+
33
+ const STATUS_TONE: Record<Status, string> = {
34
+ pending: 'text-[var(--agent-muted-foreground,#6b7280)]',
35
+ running: 'text-amber-600',
36
+ succeeded: 'text-emerald-600',
37
+ failed: 'text-rose-600',
38
+ cancelled: 'text-[var(--agent-muted-foreground,#9ca3af)]',
39
+ };
40
+
41
+ export interface ToolExecutionCardProps {
42
+ payload: ToolExecutionPayload;
43
+ className?: string;
44
+ defaultExpanded?: boolean;
45
+ renderArgs?: (args: unknown) => React.ReactNode;
46
+ renderOutput?: (output: unknown) => React.ReactNode;
47
+ }
48
+
49
+ function StatusIcon({ status }: { status: Status }) {
50
+ const cls = STATUS_TONE[status];
51
+ switch (status) {
52
+ case 'running':
53
+ return <LoaderIcon size={12} className={cls} />;
54
+ case 'succeeded':
55
+ return <CheckIcon size={12} className={cls} />;
56
+ case 'failed':
57
+ return <AlertIcon size={12} className={cls} />;
58
+ case 'cancelled':
59
+ return <StopIcon size={12} className={cls} />;
60
+ default:
61
+ return <PlayIcon size={12} className={cls} />;
62
+ }
63
+ }
64
+
65
+ function defaultRender(value: unknown): React.ReactNode {
66
+ if (value === undefined || value === null) return null;
67
+ let text: string;
68
+ try {
69
+ text = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
70
+ } catch {
71
+ text = String(value);
72
+ }
73
+ return (
74
+ <pre className="whitespace-pre-wrap break-words rounded bg-[var(--agent-muted,#f3f4f6)] px-2 py-1.5 text-[11px] text-[var(--agent-foreground,#111827)]">
75
+ {text}
76
+ </pre>
77
+ );
78
+ }
79
+
80
+ export const ToolExecutionCard: React.FC<ToolExecutionCardProps> = ({
81
+ payload,
82
+ className,
83
+ defaultExpanded = false,
84
+ renderArgs,
85
+ renderOutput,
86
+ }) => {
87
+ const expandable = payload.expandable !== false;
88
+ const [expanded, setExpanded] = React.useState(defaultExpanded);
89
+ const status = (payload.status ?? 'pending') as Status;
90
+
91
+ return (
92
+ <div
93
+ className={[
94
+ 'steerable-tool-execution rounded-lg border border-[var(--agent-border,#e5e7eb)] bg-[var(--agent-card-bg,#fff)] text-xs',
95
+ className,
96
+ ].filter(Boolean).join(' ')}
97
+ >
98
+ <button
99
+ type="button"
100
+ onClick={() => expandable && setExpanded((v) => !v)}
101
+ className="flex w-full items-center gap-2 px-3 py-2 text-left"
102
+ aria-expanded={expanded}
103
+ aria-disabled={!expandable}
104
+ >
105
+ {expandable &&
106
+ (expanded ? <ChevronDownIcon size={12} /> : <ChevronRightIcon size={12} />)}
107
+ <ZapIcon size={12} className="text-[var(--agent-muted-foreground,#6b7280)]" />
108
+ <span className="font-medium text-[var(--agent-foreground,#111827)]">{payload.name}</span>
109
+ {payload.summary && (
110
+ <span className="truncate text-[var(--agent-muted-foreground,#6b7280)]">{payload.summary}</span>
111
+ )}
112
+ <span className="ml-auto inline-flex items-center gap-1">
113
+ <StatusIcon status={status} />
114
+ <span className={STATUS_TONE[status]}>{STATUS_LABEL[status]}</span>
115
+ {typeof payload.durationMs === 'number' && (
116
+ <span className="text-[var(--agent-muted-foreground,#9ca3af)]">· {payload.durationMs}ms</span>
117
+ )}
118
+ </span>
119
+ </button>
120
+ {expandable && expanded && (
121
+ <div className="space-y-2 border-t border-[var(--agent-border,#e5e7eb)] px-3 py-2">
122
+ {payload.args !== undefined && (
123
+ <section>
124
+ <div className="mb-1 text-[10px] uppercase tracking-wider text-[var(--agent-muted-foreground,#6b7280)]">
125
+ 输入
126
+ </div>
127
+ {renderArgs ? renderArgs(payload.args) : defaultRender(payload.args)}
128
+ </section>
129
+ )}
130
+ {payload.output !== undefined && (
131
+ <section>
132
+ <div className="mb-1 text-[10px] uppercase tracking-wider text-[var(--agent-muted-foreground,#6b7280)]">
133
+ 输出
134
+ </div>
135
+ {renderOutput ? renderOutput(payload.output) : defaultRender(payload.output)}
136
+ </section>
137
+ )}
138
+ {payload.error && (
139
+ <section>
140
+ <div className="mb-1 text-[10px] uppercase tracking-wider text-rose-600">错误</div>
141
+ <pre className="whitespace-pre-wrap break-words rounded bg-rose-50/60 px-2 py-1.5 text-[11px] text-rose-700">
142
+ {payload.error}
143
+ </pre>
144
+ </section>
145
+ )}
146
+ </div>
147
+ )}
148
+ </div>
149
+ );
150
+ };
151
+
152
+ export default ToolExecutionCard;
@@ -0,0 +1,492 @@
1
+ /**
2
+ * Smoke tests for the 14 cards. We focus on render-without-crash + the
3
+ * payload-driven branching that's most likely to regress (read-only vs
4
+ * interactive, expand toggle, callbacks). Full visual coverage lives in
5
+ * Storybook VRT.
6
+ */
7
+ import * as React from 'react';
8
+ import { render, screen, fireEvent, cleanup } from '@testing-library/react';
9
+ import { afterEach, describe, expect, it, vi } from 'vitest';
10
+
11
+ import {
12
+ OrchestrationPlanCard,
13
+ QuizCard,
14
+ CoverageReportCard,
15
+ AnalysisDocumentCard,
16
+ ResearchPlanCard,
17
+ SuggestedRepliesCard,
18
+ AskUserQuestionsCard,
19
+ ThinkingProcessCard,
20
+ PlanStepsCard,
21
+ PlanSelectorCard,
22
+ SearchSourcesCard,
23
+ SummaryMessageCard,
24
+ ActionSegmentCard,
25
+ ToolExecutionCard,
26
+ } from './index.js';
27
+
28
+ afterEach(() => cleanup());
29
+
30
+ describe('SuggestedRepliesCard', () => {
31
+ it('calls onSelect when a chip is clicked', () => {
32
+ const onSelect = vi.fn();
33
+ render(
34
+ <SuggestedRepliesCard
35
+ payload={{ suggestions: ['继续', '换个角度'] }}
36
+ onSelect={onSelect}
37
+ />,
38
+ );
39
+ fireEvent.click(screen.getByText('换个角度'));
40
+ expect(onSelect).toHaveBeenCalledWith('换个角度');
41
+ });
42
+
43
+ it('renders nothing when suggestions is empty', () => {
44
+ const { container } = render(
45
+ <SuggestedRepliesCard payload={{ suggestions: [] }} onSelect={() => {}} />,
46
+ );
47
+ expect(container.firstChild).toBeNull();
48
+ });
49
+ });
50
+
51
+ describe('PlanStepsCard', () => {
52
+ it('renders steps separated by /', () => {
53
+ render(<PlanStepsCard payload={{ steps: ['搜索', '汇总', '回复'] }} />);
54
+ expect(screen.getByText('搜索')).toBeTruthy();
55
+ expect(screen.getByText('汇总')).toBeTruthy();
56
+ expect(screen.getByText('回复')).toBeTruthy();
57
+ });
58
+
59
+ it('hides itself with no steps and not streaming', () => {
60
+ const { container } = render(<PlanStepsCard payload={{ steps: [] }} />);
61
+ expect(container.firstChild).toBeNull();
62
+ });
63
+ });
64
+
65
+ describe('OrchestrationPlanCard', () => {
66
+ const payload = {
67
+ rationale: '需要并行收集 3 个领域的资料',
68
+ mode: 'parallel',
69
+ tasks: [
70
+ { id: 't1', agentId: 'researcher.a', prompt: '搜索 A' },
71
+ { id: 't2', agentId: 'researcher.b', prompt: '搜索 B' },
72
+ ],
73
+ };
74
+
75
+ it('shows task rows and counts', () => {
76
+ render(
77
+ <OrchestrationPlanCard
78
+ payload={payload}
79
+ taskStatuses={{ t1: 'ok', t2: 'running' }}
80
+ agentNameFor={(id) => id.replace('researcher.', '')}
81
+ />,
82
+ );
83
+ expect(screen.getByText('a')).toBeTruthy();
84
+ expect(screen.getByText('b')).toBeTruthy();
85
+ expect(screen.getByText('1/2')).toBeTruthy();
86
+ });
87
+
88
+ it('collapses on header click', () => {
89
+ render(<OrchestrationPlanCard payload={payload} />);
90
+ fireEvent.click(screen.getByText('编排计划'));
91
+ expect(screen.queryByText('搜索 A')).toBeNull();
92
+ });
93
+
94
+ it('renders loading shell when loading=true', () => {
95
+ render(
96
+ <OrchestrationPlanCard
97
+ payload={{ tasks: [] }}
98
+ loading
99
+ loadingHint="正在规划"
100
+ headerLabel="协调员"
101
+ />,
102
+ );
103
+ expect(screen.getByText('正在规划')).toBeTruthy();
104
+ expect(screen.getByText('协调员')).toBeTruthy();
105
+ expect(screen.queryByText('编排计划')).toBeNull();
106
+ });
107
+
108
+ it('renders failed shell with failure text', () => {
109
+ render(
110
+ <OrchestrationPlanCard
111
+ payload={{ tasks: [] }}
112
+ failed
113
+ failure="超时"
114
+ headerLabel="协调员"
115
+ />,
116
+ );
117
+ expect(screen.getByText('规划失败')).toBeTruthy();
118
+ expect(screen.getByText('超时')).toBeTruthy();
119
+ });
120
+
121
+ it('uses renderTaskRow override and hides mode + counts', () => {
122
+ render(
123
+ <OrchestrationPlanCard
124
+ payload={payload}
125
+ taskStatuses={{ t1: 'ok', t2: 'running' }}
126
+ hideMode
127
+ hideHeaderCounts
128
+ headerLabel="协调员安排"
129
+ headerSecondary="2 个子任务"
130
+ renderTaskRow={(task, status) => (
131
+ <li key={task.id} data-task={task.id} data-status={status}>
132
+ {`row:${task.agentId}:${status}`}
133
+ </li>
134
+ )}
135
+ />,
136
+ );
137
+ expect(screen.getByText('row:researcher.a:ok')).toBeTruthy();
138
+ expect(screen.getByText('row:researcher.b:running')).toBeTruthy();
139
+ expect(screen.queryByText('parallel')).toBeNull();
140
+ expect(screen.getByText('2 个子任务')).toBeTruthy();
141
+ });
142
+ });
143
+
144
+ describe('AskUserQuestionsCard', () => {
145
+ it('submits selected answers', () => {
146
+ const onSubmit = vi.fn();
147
+ render(
148
+ <AskUserQuestionsCard
149
+ payload={{
150
+ intro: '请回答',
151
+ questions: [
152
+ { id: 'q1', text: '你的目标?', type: 'select', options: ['学习', '工作'] },
153
+ ],
154
+ }}
155
+ onSubmit={onSubmit}
156
+ />,
157
+ );
158
+ fireEvent.click(screen.getByText('学习'));
159
+ fireEvent.click(screen.getByText('提交'));
160
+ expect(onSubmit).toHaveBeenCalledWith({ q1: '学习' });
161
+ });
162
+
163
+ it('switches to read-only when payload.answers is filled', () => {
164
+ render(
165
+ <AskUserQuestionsCard
166
+ payload={{
167
+ intro: '请回答',
168
+ questions: [
169
+ { id: 'q1', text: '你的目标?', type: 'select', options: ['学习', '工作'] },
170
+ ],
171
+ answers: { q1: '学习' },
172
+ }}
173
+ onSubmit={vi.fn()}
174
+ />,
175
+ );
176
+ expect(screen.queryByText('提交')).toBeNull();
177
+ });
178
+
179
+ it('exposes auto-continue button when onAutoContinue provided', () => {
180
+ const onAutoContinue = vi.fn();
181
+ render(
182
+ <AskUserQuestionsCard
183
+ payload={{
184
+ intro: '请回答',
185
+ questions: [
186
+ { id: 'q1', text: '你的目标?', type: 'select', options: ['学习', '工作'] },
187
+ ],
188
+ }}
189
+ onSubmit={vi.fn()}
190
+ onAutoContinue={onAutoContinue}
191
+ autoContinueLabel="交给我决定"
192
+ />,
193
+ );
194
+ fireEvent.click(screen.getByText('交给我决定'));
195
+ expect(onAutoContinue).toHaveBeenCalled();
196
+ });
197
+
198
+ it('sends free-text answer when allowCustomText + custom pill used', () => {
199
+ const onSubmit = vi.fn();
200
+ render(
201
+ <AskUserQuestionsCard
202
+ payload={{
203
+ intro: '请回答',
204
+ questions: [
205
+ { id: 'q1', text: '其他想法?', type: 'select', options: ['学习', '工作'] },
206
+ ],
207
+ }}
208
+ allowCustomText
209
+ customLabel="自定义"
210
+ onSubmit={onSubmit}
211
+ />,
212
+ );
213
+ fireEvent.click(screen.getByText('自定义'));
214
+ const input = screen.getByPlaceholderText('输入你的回答...');
215
+ fireEvent.change(input, { target: { value: '休息' } });
216
+ fireEvent.click(screen.getByText('提交'));
217
+ expect(onSubmit).toHaveBeenCalledWith({ q1: '休息' });
218
+ });
219
+ });
220
+
221
+ describe('QuizCard', () => {
222
+ it('submits selected choice', () => {
223
+ const onSubmit = vi.fn();
224
+ render(
225
+ <QuizCard
226
+ payload={{
227
+ quizId: 'q',
228
+ title: '测验',
229
+ description: null,
230
+ submitActionLabel: '提交',
231
+ questions: [
232
+ { id: 'q1', type: 'choice', stem: '一加一', options: ['1', '2'], allowMultiple: false, placeholder: null, points: null, knowledgePointId: null, difficulty: null },
233
+ ],
234
+ }}
235
+ onSubmit={onSubmit}
236
+ />,
237
+ );
238
+ fireEvent.click(screen.getByText('2'));
239
+ fireEvent.click(screen.getByText('提交'));
240
+ expect(onSubmit).toHaveBeenCalledWith({ q1: '2' });
241
+ });
242
+ });
243
+
244
+ describe('CoverageReportCard', () => {
245
+ it('renders overall percentages', () => {
246
+ render(
247
+ <CoverageReportCard
248
+ payload={{
249
+ reportId: 'r',
250
+ title: '覆盖度报告',
251
+ overallCoverage: 0.75,
252
+ overallMastery: 0.5,
253
+ summary: null,
254
+ sections: [],
255
+ weakPoints: [],
256
+ actions: { allowRemediateQuiz: false, remediateActionLabel: '' },
257
+ }}
258
+ />,
259
+ );
260
+ expect(screen.getByText('75%')).toBeTruthy();
261
+ expect(screen.getByText('50%')).toBeTruthy();
262
+ });
263
+ });
264
+
265
+ describe('AnalysisDocumentCard', () => {
266
+ it('uses renderMarkdown slot', () => {
267
+ render(
268
+ <AnalysisDocumentCard
269
+ payload={{ title: 't', body: '# hi', createdAt: null, modelId: null }}
270
+ renderMarkdown={(body) => <span data-testid="md">{body}</span>}
271
+ />,
272
+ );
273
+ expect(screen.getByTestId('md').textContent).toBe('# hi');
274
+ });
275
+ });
276
+
277
+ describe('ResearchPlanCard', () => {
278
+ it('shows topic and decision', () => {
279
+ render(
280
+ <ResearchPlanCard
281
+ payload={{
282
+ topic: '研究 X',
283
+ round: 2,
284
+ final: false,
285
+ subQuestions: [
286
+ { id: 's1', question: '什么是 X', kind: 'fact', status: 'evidenced_strong', evidenceCount: 3, note: null },
287
+ ],
288
+ decision: { next: 'expand', reason: '需要更多证据' },
289
+ }}
290
+ />,
291
+ );
292
+ expect(screen.getByText('研究 X')).toBeTruthy();
293
+ expect(screen.getByText(/横向扩展/)).toBeTruthy();
294
+ });
295
+ });
296
+
297
+ describe('ThinkingProcessCard', () => {
298
+ it('toggles expanded', () => {
299
+ render(<ThinkingProcessCard payload={{ body: 'reasoning', defaultExpanded: false }} />);
300
+ expect(screen.queryByText('reasoning')).toBeNull();
301
+ fireEvent.click(screen.getByText('思考过程'));
302
+ expect(screen.getByText('reasoning')).toBeTruthy();
303
+ });
304
+ });
305
+
306
+ describe('PlanSelectorCard', () => {
307
+ it('emits planId when picked', () => {
308
+ const onSelect = vi.fn();
309
+ render(
310
+ <PlanSelectorCard
311
+ payload={{
312
+ comparison: '',
313
+ goalAttribution: { type: 'new', newGoalTitle: '新目标' },
314
+ plans: [
315
+ {
316
+ id: 'p1',
317
+ name: '稳健方案',
318
+ summary: '走过验证路径',
319
+ approach: 'a',
320
+ bestFor: '稳健',
321
+ metrics: { duration: '1周', effortLevel: 'low', riskLevel: 'low' },
322
+ pros: ['稳定'],
323
+ cons: ['慢'],
324
+ },
325
+ ],
326
+ }}
327
+ onSelect={onSelect}
328
+ />,
329
+ );
330
+ fireEvent.click(screen.getByText('选择此方案'));
331
+ expect(onSelect).toHaveBeenCalledWith('p1');
332
+ });
333
+ });
334
+
335
+ describe('SearchSourcesCard', () => {
336
+ it('shows source count and expand toggle', () => {
337
+ const { container } = render(
338
+ <SearchSourcesCard
339
+ payload={{
340
+ sources: [
341
+ { url: 'https://example.com', title: 'Example', snippet: 'sn', favicon: null, publishedAt: null },
342
+ ],
343
+ }}
344
+ />,
345
+ );
346
+ expect(screen.getByText(/1 个来源/)).toBeTruthy();
347
+ const trigger = container.querySelector('button');
348
+ if (!trigger) throw new Error('no toggle');
349
+ fireEvent.click(trigger);
350
+ expect(screen.getByText('Example')).toBeTruthy();
351
+ });
352
+ });
353
+
354
+ describe('SummaryMessageCard', () => {
355
+ it('reveals body when expanded', () => {
356
+ render(
357
+ <SummaryMessageCard
358
+ payload={{ body: 'summary body', summarizedCount: 3, status: 'complete', type: null }}
359
+ />,
360
+ );
361
+ fireEvent.click(screen.getByText(/历史摘要/));
362
+ expect(screen.getByText('summary body')).toBeTruthy();
363
+ });
364
+
365
+ it('honours custom renderLabel slot', () => {
366
+ render(
367
+ <SummaryMessageCard
368
+ payload={{ body: 'b', summarizedCount: 7, status: 'complete', type: null }}
369
+ renderLabel={({ count }) => `已总结 ${count} 条`}
370
+ />,
371
+ );
372
+ expect(screen.getByText('已总结 7 条')).toBeTruthy();
373
+ });
374
+
375
+ it('renders inline appearance without card chrome', () => {
376
+ const { container } = render(
377
+ <SummaryMessageCard
378
+ payload={{ body: 'b', summarizedCount: 1, status: 'complete', type: null }}
379
+ appearance="inline"
380
+ />,
381
+ );
382
+ const root = container.firstElementChild as HTMLElement;
383
+ expect(root.className.includes('rounded-lg')).toBe(false);
384
+ });
385
+ });
386
+
387
+ describe('SearchSourcesCard appearance', () => {
388
+ it('renders inline appearance with deeppath label', () => {
389
+ render(
390
+ <SearchSourcesCard
391
+ payload={{
392
+ sources: [
393
+ { url: 'https://example.com', title: 'Example', snippet: null, favicon: null, publishedAt: null },
394
+ ],
395
+ }}
396
+ appearance="inline"
397
+ />,
398
+ );
399
+ expect(screen.getByText(/搜索了 1 个网站/)).toBeTruthy();
400
+ });
401
+ });
402
+
403
+ describe('ResearchPlanCard progress', () => {
404
+ it('uses totalRounds to compute progress bar width', () => {
405
+ const { container } = render(
406
+ <ResearchPlanCard
407
+ payload={{
408
+ topic: 'topic',
409
+ round: 2,
410
+ final: false,
411
+ subQuestions: [
412
+ { id: 's', question: 'q', kind: 'fact', status: 'pending', evidenceCount: 0, note: null },
413
+ ],
414
+ decision: { next: 'continue', reason: null },
415
+ }}
416
+ totalRounds={4}
417
+ />,
418
+ );
419
+ const bar = container.querySelector('[style*="width"]') as HTMLElement | null;
420
+ expect(bar?.style.width).toBe('50%');
421
+ });
422
+ });
423
+
424
+ describe('CoverageReportCard selection mode', () => {
425
+ it('emits selected ids when allowWeakPointSelection is on', () => {
426
+ const onRemediate = vi.fn();
427
+ render(
428
+ <CoverageReportCard
429
+ payload={{
430
+ reportId: 'r',
431
+ title: 't',
432
+ overallCoverage: 0,
433
+ overallMastery: 0,
434
+ summary: null,
435
+ sections: [],
436
+ weakPoints: [
437
+ { id: 'w1', name: 'wp1', sectionName: null, accuracy: 0.3, recommendation: 'do' },
438
+ { id: 'w2', name: 'wp2', sectionName: null, accuracy: 0.5, recommendation: 'do' },
439
+ ],
440
+ actions: { allowRemediateQuiz: true, remediateActionLabel: '出题' },
441
+ }}
442
+ allowWeakPointSelection
443
+ onRemediate={onRemediate}
444
+ />,
445
+ );
446
+ const checkboxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
447
+ fireEvent.click(checkboxes[0]!);
448
+ fireEvent.click(screen.getByText('出题'));
449
+ expect(onRemediate).toHaveBeenCalledWith(['w1']);
450
+ });
451
+ });
452
+
453
+ describe('ToolExecutionCard', () => {
454
+ it('expands to show args/output and renders error', () => {
455
+ render(
456
+ <ToolExecutionCard
457
+ payload={{
458
+ id: 't',
459
+ name: 'search.web',
460
+ status: 'failed',
461
+ summary: '搜索失败',
462
+ args: { q: 'foo' },
463
+ output: null,
464
+ error: 'timeout',
465
+ durationMs: 1200,
466
+ icon: null,
467
+ expandable: true,
468
+ }}
469
+ />,
470
+ );
471
+ fireEvent.click(screen.getByText('search.web'));
472
+ expect(screen.getByText(/"q": "foo"/)).toBeTruthy();
473
+ expect(screen.getByText('timeout')).toBeTruthy();
474
+ });
475
+ });
476
+
477
+ describe('ActionSegmentCard', () => {
478
+ it('renders each segment via ToolExecutionCard', () => {
479
+ render(
480
+ <ActionSegmentCard
481
+ payload={{
482
+ segments: [
483
+ { id: 's1', kind: 'fetch', status: 'succeeded', label: '抓取', args: null, output: null, error: null, startedAt: null, finishedAt: null },
484
+ { id: 's2', kind: 'parse', status: 'running', label: '解析', args: null, output: null, error: null, startedAt: null, finishedAt: null },
485
+ ],
486
+ }}
487
+ />,
488
+ );
489
+ expect(screen.getByText('fetch')).toBeTruthy();
490
+ expect(screen.getByText('parse')).toBeTruthy();
491
+ });
492
+ });