agent-nuvira 2.7.0 → 2.7.1

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 (110) hide show
  1. package/README.md +5 -2453
  2. package/dist/agents/orchestrator.d.ts.map +1 -1
  3. package/dist/agents/orchestrator.js +17 -3
  4. package/dist/agents/orchestrator.js.map +1 -1
  5. package/dist/app.js +26 -0
  6. package/dist/cli/chat.d.ts.map +1 -1
  7. package/dist/cli/chat.js +101 -2
  8. package/dist/cli/chat.js.map +1 -1
  9. package/dist/config/auth.js +18 -0
  10. package/dist/config/jwt.js +24 -0
  11. package/dist/config/keys.js +14 -0
  12. package/dist/gateway/chat-store.d.ts +32 -5
  13. package/dist/gateway/chat-store.d.ts.map +1 -1
  14. package/dist/gateway/chat-store.js +52 -6
  15. package/dist/gateway/chat-store.js.map +1 -1
  16. package/dist/gateway/registry.d.ts.map +1 -1
  17. package/dist/gateway/registry.js +42 -5
  18. package/dist/gateway/registry.js.map +1 -1
  19. package/dist/inference/model-validator.d.ts.map +1 -1
  20. package/dist/inference/model-validator.js +11 -1
  21. package/dist/inference/model-validator.js.map +1 -1
  22. package/dist/inference/tool-call-utils.d.ts +31 -0
  23. package/dist/inference/tool-call-utils.d.ts.map +1 -1
  24. package/dist/inference/tool-call-utils.js +41 -0
  25. package/dist/inference/tool-call-utils.js.map +1 -1
  26. package/dist/learning/reasoning-trace.d.ts +5 -3
  27. package/dist/learning/reasoning-trace.d.ts.map +1 -1
  28. package/dist/learning/reasoning-trace.js +2 -2
  29. package/dist/learning/reasoning-trace.js.map +1 -1
  30. package/dist/middleware/auth.js +30 -0
  31. package/dist/nlu/intent.d.ts.map +1 -1
  32. package/dist/nlu/intent.js +20 -3
  33. package/dist/nlu/intent.js.map +1 -1
  34. package/dist/passport.js +34 -0
  35. package/dist/routes/auth.js +42 -0
  36. package/dist/routes/user.js +31 -0
  37. package/dist/server.js +17 -0
  38. package/dist/tools/pipeline-tool.d.ts.map +1 -1
  39. package/dist/tools/pipeline-tool.js +14 -0
  40. package/dist/tools/pipeline-tool.js.map +1 -1
  41. package/dist/tools/release-sync.d.ts +52 -16
  42. package/dist/tools/release-sync.d.ts.map +1 -1
  43. package/dist/tools/release-sync.js +143 -79
  44. package/dist/tools/release-sync.js.map +1 -1
  45. package/dist/web-dashboard/src/App.js +85 -0
  46. package/dist/web-dashboard/src/admin-auth.test.js +186 -0
  47. package/dist/web-dashboard/src/ansi.js +23 -0
  48. package/dist/web-dashboard/src/ansi.test.js +31 -0
  49. package/dist/web-dashboard/src/api-admin-auth.test.js +172 -0
  50. package/dist/web-dashboard/src/api-admin.test.js +65 -0
  51. package/dist/web-dashboard/src/api-hub.test.js +117 -0
  52. package/dist/web-dashboard/src/api.js +1421 -0
  53. package/dist/web-dashboard/src/api.test.js +51 -0
  54. package/dist/web-dashboard/src/artifacts.js +128 -0
  55. package/dist/web-dashboard/src/artifacts.test.js +139 -0
  56. package/dist/web-dashboard/src/components/AdminPanel.js +567 -0
  57. package/dist/web-dashboard/src/components/AdminPanel.test.js +288 -0
  58. package/dist/web-dashboard/src/components/AgentHub.js +1580 -0
  59. package/dist/web-dashboard/src/components/AgentHub.test.js +343 -0
  60. package/dist/web-dashboard/src/components/BedrockOnboarding.js +320 -0
  61. package/dist/web-dashboard/src/components/BenchmarkCharts.js +228 -0
  62. package/dist/web-dashboard/src/components/ChatPage.js +1586 -0
  63. package/dist/web-dashboard/src/components/ChatPage.test.js +899 -0
  64. package/dist/web-dashboard/src/components/ContactsPage.js +141 -0
  65. package/dist/web-dashboard/src/components/CostDashboard.js +105 -0
  66. package/dist/web-dashboard/src/components/DAGView.js +477 -0
  67. package/dist/web-dashboard/src/components/DAGView.test.js +147 -0
  68. package/dist/web-dashboard/src/components/EnvVarEditor.js +138 -0
  69. package/dist/web-dashboard/src/components/EvalsPage.js +73 -0
  70. package/dist/web-dashboard/src/components/EvalsPage.test.js +120 -0
  71. package/dist/web-dashboard/src/components/ExecutionHistory.js +201 -0
  72. package/dist/web-dashboard/src/components/GatewayPage.js +40 -0
  73. package/dist/web-dashboard/src/components/GatewayPage.test.js +74 -0
  74. package/dist/web-dashboard/src/components/HealthPanel.js +68 -0
  75. package/dist/web-dashboard/src/components/HistoryBrowser.js +34 -0
  76. package/dist/web-dashboard/src/components/Layout.js +50 -0
  77. package/dist/web-dashboard/src/components/Markdown.js +152 -0
  78. package/dist/web-dashboard/src/components/Markdown.test.js +126 -0
  79. package/dist/web-dashboard/src/components/MarkdownZeroDep.js +237 -0
  80. package/dist/web-dashboard/src/components/MemoryPanel.js +81 -0
  81. package/dist/web-dashboard/src/components/ModelTimeline.js +298 -0
  82. package/dist/web-dashboard/src/components/ModelsPanel.js +1484 -0
  83. package/dist/web-dashboard/src/components/ModelsPanel.test.js +460 -0
  84. package/dist/web-dashboard/src/components/Overview.js +123 -0
  85. package/dist/web-dashboard/src/components/PhaseTimeline.js +359 -0
  86. package/dist/web-dashboard/src/components/PhaseTimeline.test.js +234 -0
  87. package/dist/web-dashboard/src/components/PlatformConfigSection.js +384 -0
  88. package/dist/web-dashboard/src/components/PlatformConfigSection.test.js +106 -0
  89. package/dist/web-dashboard/src/components/PlatformsPage.js +250 -0
  90. package/dist/web-dashboard/src/components/QuotaPanel.js +159 -0
  91. package/dist/web-dashboard/src/components/QuotaPanel.test.js +85 -0
  92. package/dist/web-dashboard/src/components/RequestsPanel.js +229 -0
  93. package/dist/web-dashboard/src/components/RequestsPanel.test.js +103 -0
  94. package/dist/web-dashboard/src/components/RoutingInsightsPanel.js +938 -0
  95. package/dist/web-dashboard/src/components/RoutingInsightsPanel.test.js +339 -0
  96. package/dist/web-dashboard/src/components/RoutingWalkthrough.js +408 -0
  97. package/dist/web-dashboard/src/components/RoutingWalkthrough.test.js +209 -0
  98. package/dist/web-dashboard/src/components/TaskConsole.js +119 -0
  99. package/dist/web-dashboard/src/components/TaskConsole.test.js +123 -0
  100. package/dist/web-dashboard/src/components/TasksPage.js +256 -0
  101. package/dist/web-dashboard/src/components/TasksPage.test.js +103 -0
  102. package/dist/web-dashboard/src/components/TracePanel.js +306 -0
  103. package/dist/web-dashboard/src/components/WhatsAppPanel.js +195 -0
  104. package/dist/web-dashboard/src/components/WhatsAppPanel.test.js +122 -0
  105. package/dist/web-dashboard/src/jsonOrNull.js +37 -0
  106. package/dist/web-dashboard/src/main.js +15 -0
  107. package/dist/web-dashboard/src/mask.js +11 -0
  108. package/dist/web-dashboard/vite.config.js +23 -0
  109. package/dist/web-dashboard/vitest.config.js +15 -0
  110. package/package.json +5 -3
@@ -0,0 +1,899 @@
1
+ "use strict";
2
+ /**
3
+ * P3 — ChatPage tests (dashboard chat console).
4
+ *
5
+ * The page threads a conversation through the mocked chat API: user bubbles,
6
+ * assistant replies with followup chips (clicking sends the prompt), the
7
+ * working state, error handling, and the auth gate. No LLM, no server.
8
+ */
9
+ var __importDefault = (this && this.__importDefault) || function (mod) {
10
+ return (mod && mod.__esModule) ? mod : { "default": mod };
11
+ };
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ const vitest_1 = require("vitest");
14
+ const react_1 = require("@testing-library/react");
15
+ const ChatPage_1 = __importDefault(require("./ChatPage"));
16
+ const api_1 = require("../api");
17
+ function mockAuthed(role = 'admin', authenticated = true) {
18
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'fetchAdminAuthStatus').mockResolvedValue({
19
+ configured: true,
20
+ authenticated,
21
+ user: authenticated ? 'admin' : null,
22
+ role: authenticated ? role : null,
23
+ });
24
+ }
25
+ /** The page subscribes to live progress via EventSource before each turn. */
26
+ function mockChatStream() {
27
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'subscribeChat').mockReturnValue(() => { });
28
+ }
29
+ function mockChatSend(result) {
30
+ return vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatSend').mockResolvedValue(result);
31
+ }
32
+ const OK_RESPONSE = {
33
+ ok: true,
34
+ content: 'I checked the repo — the build is green.',
35
+ followups: [
36
+ { prompt: 'Run the full test suite', label: 'Run tests' },
37
+ { prompt: 'Explain the routing changes', label: 'Explain routing' },
38
+ ],
39
+ provider: 'groq',
40
+ model: 'llama-3.3-70b',
41
+ generationFailed: false,
42
+ };
43
+ (0, vitest_1.afterEach)(() => {
44
+ (0, react_1.cleanup)();
45
+ vitest_1.vi.restoreAllMocks();
46
+ (0, api_1.setAdminToken)(null);
47
+ });
48
+ (0, vitest_1.describe)('ChatPage', () => {
49
+ (0, vitest_1.it)('shows the login gate when unauthenticated', async () => {
50
+ mockAuthed('admin', false);
51
+ (0, react_1.render)(<ChatPage_1.default />);
52
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText(/Log in \(admin or operator\)/)).toBeTruthy());
53
+ (0, vitest_1.expect)(react_1.screen.queryByPlaceholderText(/Message the agent/)).toBeNull();
54
+ });
55
+ (0, vitest_1.it)('sends a message and renders the assistant reply with followup chips', async () => {
56
+ mockAuthed('admin');
57
+ mockChatStream();
58
+ const send = mockChatSend(OK_RESPONSE);
59
+ (0, react_1.render)(<ChatPage_1.default />);
60
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
61
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Message the agent/), { target: { value: 'check the repo' } });
62
+ react_1.fireEvent.submit(react_1.screen.getByPlaceholderText(/Message the agent/).closest('form'));
63
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(send).toHaveBeenCalled());
64
+ (0, vitest_1.expect)(send.mock.calls[0][1]).toBe('check the repo');
65
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('I checked the repo — the build is green.')).toBeTruthy());
66
+ (0, vitest_1.expect)(react_1.screen.getByText('You')).toBeTruthy();
67
+ (0, vitest_1.expect)(react_1.screen.getByRole('button', { name: /Run tests/ })).toBeTruthy();
68
+ (0, vitest_1.expect)(react_1.screen.getByText(/groq \/ llama-3.3-70b/)).toBeTruthy();
69
+ });
70
+ (0, vitest_1.it)('clicking a followup chip sends its prompt as the next message', async () => {
71
+ mockAuthed('admin');
72
+ mockChatStream();
73
+ const send = mockChatSend(OK_RESPONSE);
74
+ (0, react_1.render)(<ChatPage_1.default />);
75
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
76
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Message the agent/), { target: { value: 'hi' } });
77
+ react_1.fireEvent.submit(react_1.screen.getByPlaceholderText(/Message the agent/).closest('form'));
78
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByRole('button', { name: /Run tests/ })).toBeTruthy());
79
+ react_1.fireEvent.click(react_1.screen.getByRole('button', { name: /Run tests/ }));
80
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(send).toHaveBeenCalledTimes(2));
81
+ (0, vitest_1.expect)(send.mock.calls[1][1]).toBe('Run the full test suite');
82
+ });
83
+ (0, vitest_1.it)('shows the error, KEEPS the user bubble, and offers Retry on failure', async () => {
84
+ mockAuthed('admin');
85
+ mockChatStream();
86
+ const send = mockChatSend({ ok: false, error: 'The agent could not answer.' });
87
+ (0, react_1.render)(<ChatPage_1.default />);
88
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
89
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Message the agent/), { target: { value: 'boom' } });
90
+ react_1.fireEvent.submit(react_1.screen.getByPlaceholderText(/Message the agent/).closest('form'));
91
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText(/The agent could not answer/)).toBeTruthy());
92
+ // P4 — the optimistic user bubble stays (the message was sent) so the
93
+ // Retry affordance can re-send it.
94
+ (0, vitest_1.expect)(react_1.screen.getByText('You')).toBeTruthy();
95
+ (0, vitest_1.expect)(react_1.screen.getByText('boom')).toBeTruthy();
96
+ (0, vitest_1.expect)(react_1.screen.getByRole('button', { name: /Retry/ })).toBeTruthy();
97
+ // Clicking Retry re-sends the SAME message through the normal flow.
98
+ send.mockResolvedValueOnce(OK_RESPONSE);
99
+ react_1.fireEvent.click(react_1.screen.getByRole('button', { name: /Retry/ }));
100
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(send.mock.calls[1][1]).toBe('boom'));
101
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('I checked the repo — the build is green.')).toBeTruthy());
102
+ // The Retry affordance cleared once the retry succeeded.
103
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.queryByRole('button', { name: /Retry/ })).toBeNull());
104
+ });
105
+ (0, vitest_1.it)('new conversation starts a fresh thread (the old one stays persisted in the sidebar)', async () => {
106
+ mockAuthed('admin');
107
+ mockChatStream();
108
+ mockChatSend(OK_RESPONSE);
109
+ // P4 — the just-abandoned session now appears in the sidebar.
110
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'listChatSessions').mockResolvedValue([
111
+ { id: 'any-id', title: 'hi', turnCount: 2, createdAt: 1, updatedAt: 2, preview: 'I checked the repo', firstUser: 'hi' },
112
+ ]);
113
+ (0, react_1.render)(<ChatPage_1.default />);
114
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
115
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Message the agent/), { target: { value: 'hi' } });
116
+ react_1.fireEvent.submit(react_1.screen.getByPlaceholderText(/Message the agent/).closest('form'));
117
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('I checked the repo — the build is green.')).toBeTruthy());
118
+ react_1.fireEvent.click(react_1.screen.getByRole('button', { name: /New conversation/ }));
119
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.queryByText('I checked the repo — the build is green.')).toBeNull());
120
+ // The sidebar refreshes with the abandoned conversation.
121
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getAllByText('hi').length).toBeGreaterThan(0));
122
+ });
123
+ (0, vitest_1.it)('disables the send button while a turn is in flight', async () => {
124
+ mockAuthed('admin');
125
+ mockChatStream();
126
+ let resolveSend = () => { };
127
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatSend').mockImplementation(() => new Promise((resolve) => { resolveSend = resolve; }));
128
+ (0, react_1.render)(<ChatPage_1.default />);
129
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
130
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Message the agent/), { target: { value: 'hi' } });
131
+ react_1.fireEvent.submit(react_1.screen.getByPlaceholderText(/Message the agent/).closest('form'));
132
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByRole('button', { name: /Working/ })).toBeTruthy());
133
+ // The send button shows the busy label while a turn is in flight.
134
+ (0, vitest_1.expect)(react_1.screen.getByRole('button', { name: /Working/ }).disabled).toBe(true);
135
+ (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/).disabled).toBe(true);
136
+ resolveSend(OK_RESPONSE);
137
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('I checked the repo — the build is green.')).toBeTruthy());
138
+ });
139
+ (0, vitest_1.it)('P4 — Cancel aborts the in-flight turn and keeps the user bubble', async () => {
140
+ mockAuthed('admin');
141
+ mockChatStream();
142
+ let signal = null;
143
+ // Mirror the real chatSend: an aborted fetch resolves ok:false (the
144
+ // internal catch) rather than rejecting — the page then recognizes the
145
+ // user-initiated cancel via the controller's signal.
146
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatSend').mockImplementation((_sid, _msg, _opts, sig) => new Promise((resolve) => {
147
+ signal = sig ?? null;
148
+ sig?.addEventListener('abort', () => resolve({ ok: false, error: 'Could not reach the dashboard server.' }));
149
+ }));
150
+ (0, react_1.render)(<ChatPage_1.default />);
151
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
152
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Message the agent/), { target: { value: 'long task' } });
153
+ react_1.fireEvent.submit(react_1.screen.getByPlaceholderText(/Message the agent/).closest('form'));
154
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByRole('button', { name: /Cancel/ })).toBeTruthy());
155
+ // The turn's abort signal reaches chatSend (busy shows first, so wait).
156
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(signal).toBeTruthy());
157
+ react_1.fireEvent.click(react_1.screen.getByRole('button', { name: /Cancel/ }));
158
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(signal.aborted).toBe(true));
159
+ // The user bubble stays (the message was sent) and the input re-enables
160
+ // (busy cleared) — the turn was abandoned, not failed.
161
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('long task')).toBeTruthy());
162
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/).disabled).toBe(false));
163
+ // No error banner and no Retry on a user-initiated cancel.
164
+ (0, vitest_1.expect)(react_1.screen.queryByText(/could not answer|could not reach/i)).toBeNull();
165
+ (0, vitest_1.expect)(react_1.screen.queryByRole('button', { name: /Retry/ })).toBeNull();
166
+ });
167
+ (0, vitest_1.it)('streams the agent working steps live and snapshots them into the reply', async () => {
168
+ mockAuthed('admin');
169
+ let progressCb = null;
170
+ let unsub = null;
171
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'subscribeChat').mockImplementation((_sid, handlers) => {
172
+ progressCb = handlers.onProgress ?? null;
173
+ unsub = vitest_1.vi.fn();
174
+ return unsub;
175
+ });
176
+ let resolveSend = () => { };
177
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatSend').mockImplementation(() => new Promise((resolve) => { resolveSend = resolve; }));
178
+ (0, react_1.render)(<ChatPage_1.default />);
179
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
180
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Message the agent/), { target: { value: 'fix the test' } });
181
+ react_1.fireEvent.submit(react_1.screen.getByPlaceholderText(/Message the agent/).closest('form'));
182
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(progressCb).toBeTruthy());
183
+ // The engine's live working steps stream in while the turn is in flight.
184
+ progressCb('→ calling tool: read_file');
185
+ progressCb('→ running tests: npm test');
186
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('→ calling tool: read_file')).toBeTruthy());
187
+ (0, vitest_1.expect)(react_1.screen.getByText('→ running tests: npm test')).toBeTruthy();
188
+ // On completion, the steps snapshot into the assistant bubble's collapsible.
189
+ resolveSend(OK_RESPONSE);
190
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('I checked the repo — the build is green.')).toBeTruthy());
191
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText(/2 steps/)).toBeTruthy());
192
+ (0, vitest_1.expect)(unsub).toHaveBeenCalled();
193
+ });
194
+ (0, vitest_1.it)('P4 — streams answer tokens into a live bubble, then the POST response replaces them', async () => {
195
+ mockAuthed('admin');
196
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatResolve').mockResolvedValue({ ok: true, matches: [] });
197
+ let tokenCb = null;
198
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'subscribeChat').mockImplementation((_sid, handlers) => {
199
+ tokenCb = handlers.onToken ?? null;
200
+ return vitest_1.vi.fn();
201
+ });
202
+ let resolveSend = () => { };
203
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatSend').mockImplementation(() => new Promise((resolve) => { resolveSend = resolve; }));
204
+ (0, react_1.render)(<ChatPage_1.default />);
205
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
206
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Message the agent/), { target: { value: 'write it' } });
207
+ react_1.fireEvent.submit(react_1.screen.getByPlaceholderText(/Message the agent/).closest('form'));
208
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(tokenCb).toBeTruthy());
209
+ // Tokens typewrite into the LIVE assistant bubble while the POST is in flight.
210
+ tokenCb('Here');
211
+ tokenCb(' is ');
212
+ tokenCb('the answer.');
213
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('Here is the answer.')).toBeTruthy());
214
+ // The POST response is AUTHORITATIVE — the final message renders it and
215
+ // the streamed preview is replaced (not merged), exactly like the engine's
216
+ // S1 longest-substantive selection.
217
+ resolveSend(OK_RESPONSE);
218
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('I checked the repo — the build is green.')).toBeTruthy());
219
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.queryByText('Here is the answer.')).toBeNull());
220
+ });
221
+ (0, vitest_1.it)('P0.6 — renders live tool-call cards and snapshots them into the reply', async () => {
222
+ mockAuthed('admin');
223
+ let toolCb = null;
224
+ let unsub = null;
225
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'subscribeChat').mockImplementation((_sid, handlers) => {
226
+ toolCb = handlers.onTool ?? null;
227
+ unsub = vitest_1.vi.fn();
228
+ return unsub;
229
+ });
230
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatResolve').mockResolvedValue({ ok: true, matches: [] });
231
+ let resolveSend = () => { };
232
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatSend').mockImplementation(() => new Promise((resolve) => { resolveSend = resolve; }));
233
+ (0, react_1.render)(<ChatPage_1.default />);
234
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
235
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Message the agent/), { target: { value: 'inspect the repo' } });
236
+ react_1.fireEvent.submit(react_1.screen.getByPlaceholderText(/Message the agent/).closest('form'));
237
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(toolCb).toBeTruthy());
238
+ // read_file starts (running card) and completes (ok + duration).
239
+ toolCb({ id: 'call_1', tool: 'read_file', phase: 'started', args: "{path: 'src/foo.ts'}" });
240
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('read_file')).toBeTruthy());
241
+ (0, vitest_1.expect)(react_1.screen.getByText("{path: 'src/foo.ts'}")).toBeTruthy();
242
+ toolCb({ id: 'call_1', tool: 'read_file', phase: 'called', ok: true, result: '1 | export const x = 1;', durationMs: 12 });
243
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('12ms')).toBeTruthy());
244
+ // run_terminal fails → the card shows the error body.
245
+ toolCb({ id: 'call_2', tool: 'run_terminal', phase: 'started', args: "{command: 'npm test'}" });
246
+ toolCb({ id: 'call_2', tool: 'run_terminal', phase: 'called', ok: false, error: 'exit 1', durationMs: 300 });
247
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('Error')).toBeTruthy());
248
+ // On completion the two cards snapshot into the assistant bubble.
249
+ resolveSend(OK_RESPONSE);
250
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('I checked the repo — the build is green.')).toBeTruthy());
251
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText(/2 tool calls/)).toBeTruthy());
252
+ (0, vitest_1.expect)(react_1.screen.getByText('read_file')).toBeTruthy();
253
+ (0, vitest_1.expect)(react_1.screen.getByText('run_terminal')).toBeTruthy();
254
+ (0, vitest_1.expect)(unsub).toHaveBeenCalled();
255
+ });
256
+ (0, vitest_1.it)('P0.7 — renders the live plan checklist and snapshots it into the reply', async () => {
257
+ mockAuthed('admin');
258
+ let planCb = null;
259
+ let unsub = null;
260
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'subscribeChat').mockImplementation((_sid, handlers) => {
261
+ planCb = handlers.onPlan ?? null;
262
+ unsub = vitest_1.vi.fn();
263
+ return unsub;
264
+ });
265
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatResolve').mockResolvedValue({ ok: true, matches: [] });
266
+ let resolveSend = () => { };
267
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatSend').mockImplementation(() => new Promise((resolve) => { resolveSend = resolve; }));
268
+ (0, react_1.render)(<ChatPage_1.default />);
269
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
270
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Message the agent/), { target: { value: 'fix the failing test' } });
271
+ react_1.fireEvent.submit(react_1.screen.getByPlaceholderText(/Message the agent/).closest('form'));
272
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(planCb).toBeTruthy());
273
+ // The plan is created — the checklist card appears live with the goal.
274
+ planCb({ goal: 'Fix the failing test', steps: [{ id: 'reproduce', description: 'Reproduce the failure', status: 'pending' }], revision: 1 });
275
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('Fix the failing test')).toBeTruthy());
276
+ (0, vitest_1.expect)(react_1.screen.getByText('0/1 done')).toBeTruthy();
277
+ (0, vitest_1.expect)(react_1.screen.getByText('Reproduce the failure')).toBeTruthy();
278
+ // A later mutation updates the card IN PLACE (revision 2, status done).
279
+ planCb({ goal: 'Fix the failing test', steps: [{ id: 'reproduce', description: 'Reproduce the failure', status: 'done' }], revision: 2 });
280
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('1/1 done')).toBeTruthy());
281
+ // On completion the plan snapshots into the assistant bubble.
282
+ resolveSend(OK_RESPONSE);
283
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('I checked the repo — the build is green.')).toBeTruthy());
284
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText(/Plan: Fix the failing test/)).toBeTruthy());
285
+ (0, vitest_1.expect)(unsub).toHaveBeenCalled();
286
+ });
287
+ (0, vitest_1.it)('P3b — renders the git diff card live and snapshots it into the reply', async () => {
288
+ mockAuthed('admin');
289
+ let diffCb = null;
290
+ let unsub = null;
291
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'subscribeChat').mockImplementation((_sid, handlers) => {
292
+ diffCb = handlers.onDiff ?? null;
293
+ unsub = vitest_1.vi.fn();
294
+ return unsub;
295
+ });
296
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatResolve').mockResolvedValue({ ok: true, matches: [] });
297
+ let resolveSend = () => { };
298
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatSend').mockImplementation(() => new Promise((resolve) => { resolveSend = resolve; }));
299
+ (0, react_1.render)(<ChatPage_1.default />);
300
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
301
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Message the agent/), { target: { value: 'show my changes' } });
302
+ react_1.fireEvent.submit(react_1.screen.getByPlaceholderText(/Message the agent/).closest('form'));
303
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(diffCb).toBeTruthy());
304
+ // The git tool emitted a diff — the card appears live with +/− lines.
305
+ diffCb({ files: [{ path: 'a.txt', body: 'diff --git a/a.txt b/a.txt\n+three\n-one' }], summary: '1 file changed' });
306
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('git diff')).toBeTruthy());
307
+ (0, vitest_1.expect)(react_1.screen.getByText(/1 file · \+1 −1/)).toBeTruthy();
308
+ (0, vitest_1.expect)(react_1.screen.getByText('a.txt')).toBeTruthy();
309
+ (0, vitest_1.expect)(react_1.screen.getByText('+three')).toBeTruthy();
310
+ (0, vitest_1.expect)(react_1.screen.getByText('-one')).toBeTruthy();
311
+ // On completion the diff snapshots into the assistant bubble.
312
+ resolveSend(OK_RESPONSE);
313
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('I checked the repo — the build is green.')).toBeTruthy());
314
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText(/Changes: 1 file changed/)).toBeTruthy());
315
+ (0, vitest_1.expect)(unsub).toHaveBeenCalled();
316
+ });
317
+ (0, vitest_1.it)('P6a — renders the skill draft preview card: accept saves, reject discards', async () => {
318
+ mockAuthed('admin');
319
+ let draftCb = null;
320
+ let unsub = null;
321
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'subscribeChat').mockImplementation((_sid, handlers) => {
322
+ draftCb = handlers.onSkillDraft ?? null;
323
+ unsub = vitest_1.vi.fn();
324
+ return unsub;
325
+ });
326
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatResolve').mockResolvedValue({ ok: true, matches: [] });
327
+ let resolveSend = () => { };
328
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatSend').mockImplementation(() => new Promise((resolve) => { resolveSend = resolve; }));
329
+ const acceptSpy = vitest_1.vi.spyOn(api_1.dashboardAPI, 'skillDraftAccept').mockResolvedValue({ ok: true });
330
+ const rejectSpy = vitest_1.vi.spyOn(api_1.dashboardAPI, 'skillDraftReject').mockResolvedValue({ ok: true });
331
+ (0, react_1.render)(<ChatPage_1.default />);
332
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
333
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Message the agent/), { target: { value: 'learn the S3 flow' } });
334
+ react_1.fireEvent.submit(react_1.screen.getByPlaceholderText(/Message the agent/).closest('form'));
335
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(draftCb).toBeTruthy());
336
+ // skill_manage create emitted the draft — the preview card appears.
337
+ draftCb({ name: 's3-upload', description: 'Upload artifacts to S3.', markdown: '---\nname: s3-upload\ndescription: Upload artifacts to S3.\n---\n# Steps\n...', updatedAt: 123 });
338
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('New skill draft: s3-upload')).toBeTruthy());
339
+ (0, vitest_1.expect)(react_1.screen.getByText('Upload artifacts to S3.')).toBeTruthy();
340
+ // ✅ Accept calls the accept endpoint and marks the card saved.
341
+ react_1.fireEvent.click(react_1.screen.getByText('✅ Accept'));
342
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(acceptSpy).toHaveBeenCalledWith('s3-upload'));
343
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText(/Saved — the skill is live/)).toBeTruthy());
344
+ (0, vitest_1.expect)(rejectSpy).not.toHaveBeenCalled();
345
+ // On completion the draft snapshots into the assistant bubble.
346
+ resolveSend(OK_RESPONSE);
347
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('I checked the repo — the build is green.')).toBeTruthy());
348
+ (0, vitest_1.expect)(unsub).toHaveBeenCalled();
349
+ });
350
+ (0, vitest_1.it)('P6a — rejecting a draft calls the reject endpoint and marks the card discarded', async () => {
351
+ mockAuthed('admin');
352
+ let draftCb = null;
353
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'subscribeChat').mockImplementation((_sid, handlers) => {
354
+ draftCb = handlers.onSkillDraft ?? null;
355
+ return vitest_1.vi.fn();
356
+ });
357
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatResolve').mockResolvedValue({ ok: true, matches: [] });
358
+ let resolveSend = () => { };
359
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatSend').mockImplementation(() => new Promise((resolve) => { resolveSend = resolve; }));
360
+ const rejectSpy = vitest_1.vi.spyOn(api_1.dashboardAPI, 'skillDraftReject').mockResolvedValue({ ok: true });
361
+ const acceptSpy = vitest_1.vi.spyOn(api_1.dashboardAPI, 'skillDraftAccept').mockResolvedValue({ ok: true });
362
+ (0, react_1.render)(<ChatPage_1.default />);
363
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
364
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Message the agent/), { target: { value: 'learn the flow' } });
365
+ react_1.fireEvent.submit(react_1.screen.getByPlaceholderText(/Message the agent/).closest('form'));
366
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(draftCb).toBeTruthy());
367
+ draftCb({ name: 'schema-check', description: 'Validate schema drift.', markdown: '---\nname: schema-check\ndescription: Validate schema drift.\n---\n# Steps\n...', updatedAt: 1 });
368
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('New skill draft: schema-check')).toBeTruthy());
369
+ react_1.fireEvent.click(react_1.screen.getByText('↩ Reject'));
370
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(rejectSpy).toHaveBeenCalledWith('schema-check'));
371
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText(/Rejected — the draft was discarded/)).toBeTruthy());
372
+ (0, vitest_1.expect)(acceptSpy).not.toHaveBeenCalled();
373
+ });
374
+ (0, vitest_1.it)('P0.1 — renders the agent question card and answers it via chatRespond', async () => {
375
+ mockAuthed('admin');
376
+ let questionCb = null;
377
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'subscribeChat').mockImplementation((_sid, handlers) => {
378
+ questionCb = handlers.onQuestion ?? null;
379
+ return vitest_1.vi.fn();
380
+ });
381
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatResolve').mockResolvedValue({ ok: true, matches: [] });
382
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatSend').mockResolvedValue(OK_RESPONSE);
383
+ const respondSpy = vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatRespond').mockResolvedValue({ ok: true });
384
+ (0, react_1.render)(<ChatPage_1.default />);
385
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
386
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Message the agent/), { target: { value: 'fix the failing test' } });
387
+ react_1.fireEvent.submit(react_1.screen.getByPlaceholderText(/Message the agent/).closest('form'));
388
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(questionCb).toBeTruthy());
389
+ // The agent asks "Should I fix it?"; the card appears with both choices.
390
+ questionCb({ questionId: 'q-1', question: 'Should I fix it?', choices: [{ label: 'Yes' }, { label: 'No' }], multiSelect: false });
391
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText(/Should I fix it\?/)).toBeTruthy());
392
+ (0, vitest_1.expect)(react_1.screen.getByText('Yes')).toBeTruthy();
393
+ (0, vitest_1.expect)(react_1.screen.getByText('No')).toBeTruthy();
394
+ // Picking a choice + submit answers the question through the API.
395
+ react_1.fireEvent.click(react_1.screen.getByText('Yes'));
396
+ react_1.fireEvent.click(react_1.screen.getByText('Choose'));
397
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(respondSpy).toHaveBeenCalledWith(vitest_1.expect.any(String), 'q-1', { index: 0 }));
398
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.queryByText(/Should I fix it\?/)).toBeNull());
399
+ });
400
+ (0, vitest_1.it)('Phase 1 — renders the assistant reply as markdown (headings, code, lists)', async () => {
401
+ mockAuthed('admin');
402
+ mockChatStream();
403
+ mockChatSend({
404
+ ...OK_RESPONSE,
405
+ content: '## Result\n\nHere is the **fix**:\n\n```ts\nconst x = 1;\n```\n\n- one\n- two',
406
+ });
407
+ (0, react_1.render)(<ChatPage_1.default />);
408
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
409
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Message the agent/), { target: { value: 'fix it' } });
410
+ react_1.fireEvent.submit(react_1.screen.getByPlaceholderText(/Message the agent/).closest('form'));
411
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('Result')).toBeTruthy());
412
+ // Bold rendered as <strong>, code fence as a block with the lang label.
413
+ (0, vitest_1.expect)(react_1.screen.getByText('fix').tagName).toBe('STRONG');
414
+ (0, vitest_1.expect)(react_1.screen.getByText('ts')).toBeTruthy();
415
+ // rehype-highlight tokenizes the body; the raw text is on the <code>.
416
+ (0, vitest_1.expect)(document.querySelector('.md-code-pre code')?.textContent?.trim()).toBe('const x = 1;');
417
+ (0, vitest_1.expect)(react_1.screen.getByText('one')).toBeTruthy();
418
+ (0, vitest_1.expect)(react_1.screen.getByText('two')).toBeTruthy();
419
+ });
420
+ (0, vitest_1.it)('Phase 5 — empty-state onboarding chips send their prompt', async () => {
421
+ mockAuthed('admin');
422
+ mockChatStream();
423
+ const send = mockChatSend(OK_RESPONSE);
424
+ (0, react_1.render)(<ChatPage_1.default />);
425
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
426
+ // P6e — the skill suggestions are part of the empty state (the shipable
427
+ // first-party batch is the onboarding entry point).
428
+ const skillChip = react_1.screen.getByRole('button', { name: /load the code-assessment skill/ });
429
+ (0, vitest_1.expect)(skillChip).toBeTruthy();
430
+ const learnChip = react_1.screen.getByRole('button', { name: /learn a workflow as a skill/ });
431
+ (0, vitest_1.expect)(learnChip).toBeTruthy();
432
+ const chip = react_1.screen.getByRole('button', { name: /assess this project/ });
433
+ (0, vitest_1.expect)(chip).toBeTruthy();
434
+ react_1.fireEvent.click(chip);
435
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(send).toHaveBeenCalled());
436
+ (0, vitest_1.expect)(send.mock.calls[0][1]).toBe("what's the state of this project?");
437
+ });
438
+ (0, vitest_1.it)('Phase 6 — Enter sends, Shift+Enter does not, ↑ recalls the last message', async () => {
439
+ mockAuthed('admin');
440
+ mockChatStream();
441
+ const send = mockChatSend(OK_RESPONSE);
442
+ (0, react_1.render)(<ChatPage_1.default />);
443
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
444
+ const box = react_1.screen.getByPlaceholderText(/Message the agent/);
445
+ // Send the first message with Enter.
446
+ react_1.fireEvent.change(box, { target: { value: 'first ask' } });
447
+ react_1.fireEvent.keyDown(box, { key: 'Enter' });
448
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(send).toHaveBeenCalledTimes(1));
449
+ (0, vitest_1.expect)(send.mock.calls[0][1]).toBe('first ask');
450
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('I checked the repo — the build is green.')).toBeTruthy());
451
+ // Shift+Enter must NOT submit (newline in the box instead).
452
+ react_1.fireEvent.change(box, { target: { value: 'multi\nline' } });
453
+ react_1.fireEvent.keyDown(box, { key: 'Enter', shiftKey: true });
454
+ (0, vitest_1.expect)(send).toHaveBeenCalledTimes(1);
455
+ // Clear the box, press ↑ → the last sent message comes back.
456
+ react_1.fireEvent.change(box, { target: { value: '' } });
457
+ react_1.fireEvent.keyDown(box, { key: 'ArrowUp' });
458
+ (0, vitest_1.expect)(box.value).toBe('first ask');
459
+ });
460
+ (0, vitest_1.it)('Phase 3 — the project bar attaches a directory and sends its path with the message', async () => {
461
+ mockAuthed('admin');
462
+ mockChatStream();
463
+ const send = mockChatSend(OK_RESPONSE);
464
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'listProjects').mockResolvedValue([
465
+ { path: '/tmp/my-app', name: 'my-app', kind: 'recent' },
466
+ ]);
467
+ const attach = vitest_1.vi.spyOn(api_1.dashboardAPI, 'attachProject').mockResolvedValue({
468
+ ok: true,
469
+ project: { path: '/tmp/my-app', name: 'my-app', fileCount: 42, symbolCount: 120, truncated: false },
470
+ });
471
+ (0, react_1.render)(<ChatPage_1.default />);
472
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
473
+ // The bar offers recent projects as one-click attach chips.
474
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByRole('button', { name: /my-app/ })).toBeTruthy());
475
+ react_1.fireEvent.click(react_1.screen.getByRole('button', { name: /my-app/ }));
476
+ // Attached state shows the project + its stats.
477
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(attach).toHaveBeenCalledWith('/tmp/my-app'));
478
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('my-app')).toBeTruthy());
479
+ (0, vitest_1.expect)(react_1.screen.getByText(/42 files · 120 symbols/)).toBeTruthy();
480
+ // The message carries the attached path so the server injects context.
481
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Message the agent/), { target: { value: 'assess this project' } });
482
+ react_1.fireEvent.submit(react_1.screen.getByPlaceholderText(/Message the agent/).closest('form'));
483
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(send).toHaveBeenCalled());
484
+ (0, vitest_1.expect)(send.mock.calls[0][2]?.projectPath).toBe('/tmp/my-app');
485
+ // Detaching clears the bar back to the picker.
486
+ react_1.fireEvent.click(react_1.screen.getByRole('button', { name: /detach/ }));
487
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText(/Select Project Folder/)).toBeTruthy());
488
+ });
489
+ (0, vitest_1.it)('Phase 4 — the session sidebar lists past conversations and resumes them', async () => {
490
+ mockAuthed('admin');
491
+ mockChatStream();
492
+ mockChatSend(OK_RESPONSE);
493
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'listChatSessions').mockResolvedValue([
494
+ { id: 'sess-1', title: 'assess the repo', turnCount: 4, createdAt: 1, updatedAt: 2, preview: 'the build is green', firstUser: 'assess the repo' },
495
+ { id: 'sess-2', title: 'fix the failing test', turnCount: 2, createdAt: 1, updatedAt: 1, preview: 'fixed it', firstUser: 'fix the failing test' },
496
+ ]);
497
+ const getSession = vitest_1.vi.spyOn(api_1.dashboardAPI, 'getChatSession').mockResolvedValue({
498
+ title: 'assess the repo',
499
+ updatedAt: 2,
500
+ turns: [
501
+ { role: 'user', content: 'assess the repo' },
502
+ { role: 'assistant', content: '**all green**' },
503
+ { role: 'user', content: 'what about tests?' },
504
+ { role: 'assistant', content: '220 pass' },
505
+ ],
506
+ });
507
+ (0, react_1.render)(<ChatPage_1.default />);
508
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getAllByText('assess the repo').length).toBeGreaterThan(0));
509
+ (0, vitest_1.expect)(react_1.screen.getAllByText('fix the failing test').length).toBeGreaterThan(0);
510
+ (0, vitest_1.expect)(react_1.screen.getByText(/4 msgs · /)).toBeTruthy();
511
+ // Click the session → its transcript loads into the thread (markdown-rendered).
512
+ react_1.fireEvent.click(react_1.screen.getAllByText('assess the repo')[0]);
513
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(getSession).toHaveBeenCalledWith('sess-1'));
514
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('all green')).toBeTruthy());
515
+ (0, vitest_1.expect)(react_1.screen.getByText('what about tests?')).toBeTruthy();
516
+ (0, vitest_1.expect)(react_1.screen.getByText('220 pass')).toBeTruthy();
517
+ });
518
+ (0, vitest_1.it)('P0.1 — skip lets the agent proceed on best judgment (index -1)', async () => {
519
+ mockAuthed('admin');
520
+ let questionCb = null;
521
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'subscribeChat').mockImplementation((_sid, handlers) => {
522
+ questionCb = handlers.onQuestion ?? null;
523
+ return vitest_1.vi.fn();
524
+ });
525
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatResolve').mockResolvedValue({ ok: true, matches: [] });
526
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatSend').mockResolvedValue(OK_RESPONSE);
527
+ const respondSpy = vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatRespond').mockResolvedValue({ ok: true });
528
+ (0, react_1.render)(<ChatPage_1.default />);
529
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
530
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Message the agent/), { target: { value: 'add rahul to whatsapp' } });
531
+ react_1.fireEvent.submit(react_1.screen.getByPlaceholderText(/Message the agent/).closest('form'));
532
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(questionCb).toBeTruthy());
533
+ questionCb({ questionId: 'q-2', question: 'Verified list or send-by-name?', choices: [{ label: 'Verified list' }, { label: 'Send-by-name' }], multiSelect: false });
534
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText(/Verified list or send-by-name\?/)).toBeTruthy());
535
+ react_1.fireEvent.click(react_1.screen.getByText(/Skip/));
536
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(respondSpy).toHaveBeenCalledWith(vitest_1.expect.any(String), 'q-2', { index: -1 }));
537
+ });
538
+ (0, vitest_1.it)('P8 — large pasted text is offered as an attachment and becomes a chip', async () => {
539
+ mockAuthed('admin');
540
+ mockChatStream();
541
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatResolve').mockResolvedValue({ ok: true, matches: [] });
542
+ mockChatSend(OK_RESPONSE);
543
+ (0, react_1.render)(<ChatPage_1.default />);
544
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
545
+ const longText = 'x'.repeat(2500);
546
+ react_1.fireEvent.paste(react_1.screen.getByPlaceholderText(/Message the agent/), {
547
+ clipboardData: { getData: () => longText },
548
+ });
549
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText(/2,500 characters/)).toBeTruthy());
550
+ react_1.fireEvent.click(react_1.screen.getByText(/Attach as text/));
551
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText(/pasted-text.txt/)).toBeTruthy());
552
+ });
553
+ (0, vitest_1.it)('P8 — smart rail: sidebar collapses while working and returns when done', async () => {
554
+ mockAuthed('admin');
555
+ mockChatStream();
556
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatResolve').mockResolvedValue({ ok: true, matches: [] });
557
+ let resolveTurn = () => { };
558
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatSend').mockImplementation(() => new Promise((resolve) => { resolveTurn = resolve; }));
559
+ (0, react_1.render)(<ChatPage_1.default />);
560
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('📁 Sessions')).toBeTruthy());
561
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Message the agent/), { target: { value: 'analyze' } });
562
+ react_1.fireEvent.submit(react_1.screen.getByPlaceholderText(/Message the agent/).closest('form'));
563
+ // While the turn is in flight the sidebar collapses to the rail.
564
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.queryByText('📁 Sessions')).toBeNull());
565
+ (0, vitest_1.expect)(react_1.screen.getByTitle('Show history')).toBeTruthy();
566
+ resolveTurn(OK_RESPONSE);
567
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('📁 Sessions')).toBeTruthy());
568
+ });
569
+ (0, vitest_1.it)('P8 — sidebar search filters sessions and date groups render', async () => {
570
+ mockAuthed('admin');
571
+ mockChatStream();
572
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'listChatSessions').mockResolvedValue([
573
+ { id: 's-a', title: 'assess the repo', turnCount: 4, createdAt: Date.now(), updatedAt: Date.now(), preview: 'the build is green', firstUser: 'assess the repo' },
574
+ { id: 's-b', title: 'deploy the site', turnCount: 2, createdAt: Date.now() - 86_400_000, updatedAt: Date.now() - 86_400_000, preview: 'deployed', firstUser: 'deploy the site' },
575
+ ]);
576
+ (0, react_1.render)(<ChatPage_1.default />);
577
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getAllByText('assess the repo').length).toBeGreaterThan(0));
578
+ // Date groups: Today + Yesterday.
579
+ (0, vitest_1.expect)(react_1.screen.getByText('Today')).toBeTruthy();
580
+ (0, vitest_1.expect)(react_1.screen.getByText('Yesterday')).toBeTruthy();
581
+ // Search narrows the list.
582
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Search conversations/), { target: { value: 'deploy' } });
583
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.queryByText('assess the repo')).toBeNull());
584
+ (0, vitest_1.expect)(react_1.screen.getAllByText('deploy the site').length).toBeGreaterThan(0);
585
+ });
586
+ (0, vitest_1.it)('P8 — sidebar rename and delete manage the session list', async () => {
587
+ mockAuthed('admin');
588
+ mockChatStream();
589
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'listChatSessions').mockResolvedValue([
590
+ { id: 's-1', title: 'old title', turnCount: 1, createdAt: 1, updatedAt: 2, preview: 'x', firstUser: 'old title' },
591
+ ]);
592
+ const delSpy = vitest_1.vi.spyOn(api_1.dashboardAPI, 'deleteChatSession').mockResolvedValue({ ok: true });
593
+ const renSpy = vitest_1.vi.spyOn(api_1.dashboardAPI, 'renameChatSession').mockResolvedValue({ ok: true });
594
+ (0, react_1.render)(<ChatPage_1.default />);
595
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getAllByText('old title').length).toBeGreaterThan(0));
596
+ // Rename: pencil → inline input → Enter commits.
597
+ react_1.fireEvent.click(react_1.screen.getByTitle('Rename'));
598
+ const renameInput = await react_1.screen.findByPlaceholderText('Session title');
599
+ react_1.fireEvent.change(renameInput, { target: { value: 'new title' } });
600
+ react_1.fireEvent.keyDown(renameInput, { key: 'Enter' });
601
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(renSpy).toHaveBeenCalledWith('s-1', 'new title'));
602
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getAllByText('new title').length).toBeGreaterThan(0));
603
+ // Delete: trash → API called + session removed from the list.
604
+ react_1.fireEvent.click(react_1.screen.getByTitle('Delete'));
605
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(delSpy).toHaveBeenCalledWith('s-1'));
606
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.queryByText('new title')).toBeNull());
607
+ });
608
+ (0, vitest_1.it)('P8 — New chat button resets the thread', async () => {
609
+ mockAuthed('admin');
610
+ mockChatStream();
611
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatResolve').mockResolvedValue({ ok: true, matches: [] });
612
+ mockChatSend(OK_RESPONSE);
613
+ (0, react_1.render)(<ChatPage_1.default />);
614
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
615
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Message the agent/), { target: { value: 'hello' } });
616
+ react_1.fireEvent.submit(react_1.screen.getByPlaceholderText(/Message the agent/).closest('form'));
617
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('You')).toBeTruthy());
618
+ react_1.fireEvent.click(react_1.screen.getByText('+ New'));
619
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.queryByText('You')).toBeNull());
620
+ (0, vitest_1.expect)(react_1.screen.getByText(/Say anything/)).toBeTruthy();
621
+ });
622
+ (0, vitest_1.it)('P2 — extracts artifact cards (diff/result/deploy) from the answer text', async () => {
623
+ mockAuthed('admin');
624
+ mockChatStream();
625
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatResolve').mockResolvedValue({ ok: true, matches: [] });
626
+ mockChatSend({
627
+ ...OK_RESPONSE,
628
+ content: [
629
+ 'I fixed the bug:',
630
+ '```diff',
631
+ 'diff --git a/src/fix.ts b/src/fix.ts',
632
+ '--- a/src/fix.ts',
633
+ '+++ b/src/fix.ts',
634
+ '@@ -1 +1 @@',
635
+ '-broken',
636
+ '+fixed',
637
+ '```',
638
+ '```',
639
+ 'PASS src/fix.test.ts',
640
+ '```',
641
+ '🚀 Preview: https://preview.example.com',
642
+ ].join('\n'),
643
+ });
644
+ (0, react_1.render)(<ChatPage_1.default />);
645
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
646
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Message the agent/), { target: { value: 'fix the bug' } });
647
+ react_1.fireEvent.submit(react_1.screen.getByPlaceholderText(/Message the agent/).closest('form'));
648
+ // The ```diff block becomes a diff card, the test output a result card,
649
+ // and the deploy line a 🚀 card — NOT just raw markdown.
650
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('git diff')).toBeTruthy());
651
+ (0, vitest_1.expect)(react_1.screen.getByText('src/fix.ts')).toBeTruthy();
652
+ // The result card's verdict badge (the same block also renders as a
653
+ // markdown code fence — both are expected, scope to the card's meta).
654
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(document.querySelector('.chat-result-meta')?.textContent).toBe('pass'));
655
+ // The card link (the same URL also autolinks in the markdown text —
656
+ // scope to the deploy card's dedicated anchor).
657
+ const deployLink = document.querySelector('.chat-deploy-url');
658
+ (0, vitest_1.expect)(deployLink).toBeTruthy();
659
+ (0, vitest_1.expect)(deployLink.getAttribute('href')).toBe('https://preview.example.com');
660
+ });
661
+ (0, vitest_1.it)('P2 — extracted text diffs stay READ-ONLY without an attached project (prose cannot trigger commits)', async () => {
662
+ mockAuthed('admin');
663
+ mockChatStream();
664
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatResolve').mockResolvedValue({ ok: true, matches: [] });
665
+ mockChatSend({
666
+ ...OK_RESPONSE,
667
+ content: ['```diff', 'diff --git a/src/prose.ts b/src/prose.ts', '--- a/src/prose.ts', '+++ b/src/prose.ts', '@@ -1 +1 @@', '-x', '+y', '```'].join('\n'),
668
+ });
669
+ (0, react_1.render)(<ChatPage_1.default />);
670
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
671
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Message the agent/), { target: { value: 'show a diff' } });
672
+ react_1.fireEvent.submit(react_1.screen.getByPlaceholderText(/Message the agent/).closest('form'));
673
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('git diff')).toBeTruthy());
674
+ // No attached project → no accept/reject toggles, no commit button, and
675
+ // a hint explains why.
676
+ (0, vitest_1.expect)(document.querySelectorAll('.chat-diff-toggle')).toHaveLength(0);
677
+ (0, vitest_1.expect)(react_1.screen.queryByText(/Commit accepted/)).toBeNull();
678
+ (0, vitest_1.expect)(react_1.screen.getByText(/Attach a project to review and commit these changes/)).toBeTruthy();
679
+ });
680
+ (0, vitest_1.it)('P2 — extracted text diffs become selectable WITH an attached project and commit there', async () => {
681
+ mockAuthed('admin');
682
+ mockChatStream();
683
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatResolve').mockResolvedValue({ ok: true, matches: [] });
684
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'listProjects').mockResolvedValue([{ path: '/tmp/my-app', name: 'my-app', kind: 'recent' }]);
685
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'attachProject').mockResolvedValue({
686
+ ok: true,
687
+ project: { path: '/tmp/my-app', name: 'my-app', fileCount: 10, symbolCount: 20, truncated: false },
688
+ });
689
+ (0, react_1.render)(<ChatPage_1.default />);
690
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByRole('button', { name: /my-app/ })).toBeTruthy());
691
+ react_1.fireEvent.click(react_1.screen.getByRole('button', { name: /my-app/ }));
692
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('my-app')).toBeTruthy());
693
+ // Now send a message whose answer contains a ```diff block.
694
+ const send = mockChatSend({
695
+ ...OK_RESPONSE,
696
+ content: ['```diff', 'diff --git a/src/a.ts b/src/a.ts', '--- a/src/a.ts', '+++ b/src/a.ts', '@@ -1 +1 @@', '-old', '+new', '```'].join('\n'),
697
+ });
698
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Message the agent/), { target: { value: 'make the change' } });
699
+ react_1.fireEvent.submit(react_1.screen.getByPlaceholderText(/Message the agent/).closest('form'));
700
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('git diff')).toBeTruthy());
701
+ // With the project attached the extracted diff IS selectable.
702
+ const toggles = react_1.screen.getAllByTitle(/Accepted — click to reject/);
703
+ (0, vitest_1.expect)(toggles.length).toBe(1);
704
+ // Commit accepted → the message names the ATTACHED PROJECT path so the
705
+ // agent commits in that working tree, and only the accepted file.
706
+ react_1.fireEvent.click(react_1.screen.getByText(/Commit accepted \(1\)/));
707
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(send).toHaveBeenCalledTimes(2));
708
+ const commitMsg = send.mock.calls[1][1];
709
+ (0, vitest_1.expect)(commitMsg).toContain('/tmp/my-app');
710
+ (0, vitest_1.expect)(commitMsg).toContain('src/a.ts');
711
+ });
712
+ (0, vitest_1.it)('P2 — the snapshotted diff card accepts/rejects files and commits the subset', async () => {
713
+ mockAuthed('admin');
714
+ let diffCb = null;
715
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'subscribeChat').mockImplementation((_sid, handlers) => {
716
+ diffCb = handlers.onDiff ?? null;
717
+ return vitest_1.vi.fn();
718
+ });
719
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatResolve').mockResolvedValue({ ok: true, matches: [] });
720
+ let resolveSend = () => { };
721
+ const sendSpy = vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatSend').mockImplementation(() => new Promise((resolve) => { resolveSend = resolve; }));
722
+ (0, react_1.render)(<ChatPage_1.default />);
723
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
724
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Message the agent/), { target: { value: 'show my changes' } });
725
+ react_1.fireEvent.submit(react_1.screen.getByPlaceholderText(/Message the agent/).closest('form'));
726
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(diffCb).toBeTruthy());
727
+ diffCb({ files: [{ path: 'a.ts', body: 'diff --git a/a.ts b/a.ts\n+new-a' }, { path: 'b.ts', body: 'diff --git a/b.ts b/b.ts\n+new-b' }], summary: '2 files changed' });
728
+ resolveSend(OK_RESPONSE);
729
+ // The snapshotted card shows per-file toggles, all accepted by default.
730
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText(/Commit accepted \(2\)/)).toBeTruthy());
731
+ const toggles = react_1.screen.getAllByTitle(/Accepted — click to reject/);
732
+ (0, vitest_1.expect)(toggles.length).toBe(2);
733
+ // Reject b.ts → only a.ts remains accepted.
734
+ react_1.fireEvent.click(toggles[1]);
735
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText(/Commit accepted \(1\)/)).toBeTruthy());
736
+ // Committing sends a chat turn naming ONLY the accepted file.
737
+ react_1.fireEvent.click(react_1.screen.getByText(/Commit accepted \(1\)/));
738
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(sendSpy.mock.calls.length).toBeGreaterThan(1));
739
+ const commitMsg = sendSpy.mock.calls[sendSpy.mock.calls.length - 1][1];
740
+ (0, vitest_1.expect)(commitMsg).toContain('a.ts');
741
+ (0, vitest_1.expect)(commitMsg).not.toContain('b.ts');
742
+ });
743
+ (0, vitest_1.it)('P2 — the ⚡ Run path renders a live execution card with logs and exit code', async () => {
744
+ mockAuthed('admin');
745
+ mockChatStream();
746
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatResolve').mockResolvedValue({
747
+ ok: true,
748
+ matches: [{ intent: 'run tests', summary: 'Run the test suite', command: 'test run', score: 0.9 }],
749
+ });
750
+ let statusCb = null;
751
+ let logCb = null;
752
+ const taskBase = {
753
+ id: 'task-1',
754
+ command: 'test run',
755
+ args: ['test', 'run'],
756
+ cwd: '.',
757
+ startedAt: 1,
758
+ finishedAt: null,
759
+ timeoutMs: 60000,
760
+ logs: [],
761
+ };
762
+ const unsub = vitest_1.vi.fn();
763
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'subscribeTask').mockImplementation((_id, handlers) => {
764
+ statusCb = handlers.onStatus ?? null;
765
+ logCb = handlers.onLog ?? null;
766
+ return unsub;
767
+ });
768
+ void statusCb;
769
+ void logCb;
770
+ // The task runs then SETTLES: the mock stays running until the test flips
771
+ // `settled` (before firing onStatus('done')), after which snapshots carry
772
+ // the exit code + final logs — exactly what the real server returns.
773
+ let settled = false;
774
+ const runningTask = { ...taskBase, status: 'running', exitCode: null, durationMs: null };
775
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'getTask').mockImplementation(async () => ({
776
+ status: 200,
777
+ task: settled
778
+ ? { ...taskBase, status: 'done', exitCode: 0, finishedAt: 2, durationMs: 100, logs: [{ stream: 'stdout', text: '1 test passed', at: 1 }] }
779
+ : runningTask,
780
+ }));
781
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'startTask').mockResolvedValue({ ok: true, task: runningTask });
782
+ const cancelSpy = vitest_1.vi.spyOn(api_1.dashboardAPI, 'cancelTask').mockResolvedValue({ ok: true });
783
+ (0, react_1.render)(<ChatPage_1.default />);
784
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
785
+ // A confident command match shows the ⚡ confirm card.
786
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Message the agent/), { target: { value: 'run the tests' } });
787
+ react_1.fireEvent.submit(react_1.screen.getByPlaceholderText(/Message the agent/).closest('form'));
788
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText(/Run this command/)).toBeTruthy());
789
+ // ▶ Run starts the task and inserts a LIVE execution card.
790
+ react_1.fireEvent.click(react_1.screen.getByText('▶ Run'));
791
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('test run')).toBeTruthy());
792
+ (0, vitest_1.expect)(react_1.screen.getByText(/⏳ running/)).toBeTruthy();
793
+ // Streamed logs land in the card.
794
+ logCb({ stream: 'stdout', text: '1 test passed', at: 1 });
795
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('1 test passed')).toBeTruthy());
796
+ // ANSI escapes are stripped, and a stream switch (stdout → stderr) shows
797
+ // a separator + the error line is marked by stream.
798
+ logCb({ stream: 'stdout', text: '\u001b[32mstarting\u001b[0m', at: 2 });
799
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('starting')).toBeTruthy());
800
+ (0, vitest_1.expect)(react_1.screen.queryByText('\u001b[32mstarting\u001b[0m')).toBeNull();
801
+ logCb({ stream: 'stderr', text: '\u001b[31mboom\u001b[0m', at: 3 });
802
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText('boom')).toBeTruthy());
803
+ (0, vitest_1.expect)(react_1.screen.getByText('stderr')).toBeTruthy();
804
+ (0, vitest_1.expect)(document.querySelector('.chat-task-log-stderr')?.textContent).toBe('boom');
805
+ // The copy button copies the FULL output, ANSI-stripped (clean text, no
806
+ // color codes — matching what the card renders).
807
+ const writeText = vitest_1.vi.fn().mockResolvedValue(undefined);
808
+ Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
809
+ const copyBtn = document.querySelector('.chat-task-card .chat-card-copy');
810
+ (0, vitest_1.expect)(copyBtn).toBeTruthy();
811
+ react_1.fireEvent.click(copyBtn);
812
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(writeText).toHaveBeenCalledWith(['1 test passed', 'starting', 'boom'].join('\n')));
813
+ // Cancel is available while running (scope to the TASK card — the busy
814
+ // composer also renders a ⏹ Cancel for the agent turn).
815
+ const taskCancel = document.querySelector('.chat-task-card .admin-mini-btn');
816
+ (0, vitest_1.expect)(taskCancel).toBeTruthy();
817
+ react_1.fireEvent.click(taskCancel);
818
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(cancelSpy).toHaveBeenCalledWith('task-1'));
819
+ // Settling the status shows the exit code and releases busy (the status
820
+ // event itself carries no exit code — the final snapshot provides it).
821
+ settled = true;
822
+ statusCb('done');
823
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByText(/exit 0/)).toBeTruthy());
824
+ (0, vitest_1.expect)(unsub).toHaveBeenCalled();
825
+ });
826
+ (0, vitest_1.it)('P2 — result and deploy cards have copy buttons that copy their content', async () => {
827
+ mockAuthed('admin');
828
+ mockChatStream();
829
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatResolve').mockResolvedValue({ ok: true, matches: [] });
830
+ const writeText = vitest_1.vi.fn().mockResolvedValue(undefined);
831
+ Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
832
+ mockChatSend({
833
+ ...OK_RESPONSE,
834
+ content: [
835
+ '```',
836
+ 'PASS src/a.test.ts',
837
+ '```',
838
+ '🚀 Preview: https://preview.example.com',
839
+ ].join('\n'),
840
+ });
841
+ (0, react_1.render)(<ChatPage_1.default />);
842
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
843
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Message the agent/), { target: { value: 'run it' } });
844
+ react_1.fireEvent.submit(react_1.screen.getByPlaceholderText(/Message the agent/).closest('form'));
845
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(document.querySelectorAll('.chat-card-copy').length).toBe(2));
846
+ // The result card's copy button copies the OUTPUT body; the deploy card's
847
+ // copies the URL.
848
+ const resultCard = document.querySelector('.chat-result-card');
849
+ const resultCopy = resultCard.querySelector('.chat-card-copy');
850
+ react_1.fireEvent.click(resultCopy);
851
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(writeText).toHaveBeenCalledWith('PASS src/a.test.ts\n'));
852
+ const deployCopy = document.querySelector('.chat-deploy-card .chat-card-copy');
853
+ react_1.fireEvent.click(deployCopy);
854
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(writeText).toHaveBeenCalledWith('https://preview.example.com'));
855
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getAllByText(/Copied/).length).toBeGreaterThan(0));
856
+ });
857
+ (0, vitest_1.it)('P2 — artifact cards are keyboard-navigable: ↑/↓ moves focus between them', async () => {
858
+ mockAuthed('admin');
859
+ mockChatStream();
860
+ vitest_1.vi.spyOn(api_1.dashboardAPI, 'chatResolve').mockResolvedValue({ ok: true, matches: [] });
861
+ mockChatSend({
862
+ ...OK_RESPONSE,
863
+ content: [
864
+ '```diff',
865
+ 'diff --git a/a.ts b/a.ts',
866
+ '--- a/a.ts',
867
+ '+++ b/a.ts',
868
+ '@@ -1 +1 @@',
869
+ '-x',
870
+ '+y',
871
+ '```',
872
+ '```',
873
+ 'PASS src/a.test.ts',
874
+ '```',
875
+ '🚀 Preview: https://preview.example.com',
876
+ ].join('\n'),
877
+ });
878
+ (0, react_1.render)(<ChatPage_1.default />);
879
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(react_1.screen.getByPlaceholderText(/Message the agent/)).toBeTruthy());
880
+ react_1.fireEvent.change(react_1.screen.getByPlaceholderText(/Message the agent/), { target: { value: 'summarize' } });
881
+ react_1.fireEvent.submit(react_1.screen.getByPlaceholderText(/Message the agent/).closest('form'));
882
+ await (0, react_1.waitFor)(() => (0, vitest_1.expect)(document.querySelectorAll('[data-artifact-card]').length).toBe(3));
883
+ // Focus the first card, then ↓ moves to the second, ↑ back to the first,
884
+ // and End jumps to the last.
885
+ const cards = document.querySelectorAll('[data-artifact-card]');
886
+ cards[0].focus();
887
+ (0, vitest_1.expect)(document.activeElement).toBe(cards[0]);
888
+ react_1.fireEvent.keyDown(cards[0], { key: 'ArrowDown' });
889
+ (0, vitest_1.expect)(document.activeElement).toBe(cards[1]);
890
+ react_1.fireEvent.keyDown(cards[1], { key: 'ArrowDown' });
891
+ (0, vitest_1.expect)(document.activeElement).toBe(cards[2]);
892
+ react_1.fireEvent.keyDown(cards[2], { key: 'ArrowUp' });
893
+ (0, vitest_1.expect)(document.activeElement).toBe(cards[1]);
894
+ react_1.fireEvent.keyDown(cards[1], { key: 'Home' });
895
+ (0, vitest_1.expect)(document.activeElement).toBe(cards[0]);
896
+ react_1.fireEvent.keyDown(cards[0], { key: 'End' });
897
+ (0, vitest_1.expect)(document.activeElement).toBe(cards[2]);
898
+ });
899
+ });