@a5c-ai/babysitter-observer-dashboard 1.0.0

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 (205) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +490 -0
  3. package/next.config.mjs +25 -0
  4. package/package.json +104 -0
  5. package/postcss.config.mjs +8 -0
  6. package/src/app/actions/__tests__/approve-breakpoint.test.ts +246 -0
  7. package/src/app/actions/approve-breakpoint.ts +145 -0
  8. package/src/app/api/config/route.ts +137 -0
  9. package/src/app/api/digest/route.ts +45 -0
  10. package/src/app/api/runs/[runId]/events/route.ts +56 -0
  11. package/src/app/api/runs/[runId]/route.ts +84 -0
  12. package/src/app/api/runs/[runId]/tasks/[effectId]/route.ts +44 -0
  13. package/src/app/api/runs/route.ts +48 -0
  14. package/src/app/api/stream/route.ts +136 -0
  15. package/src/app/api/test/route.ts +1 -0
  16. package/src/app/api/version/route.ts +57 -0
  17. package/src/app/globals.css +555 -0
  18. package/src/app/icon.svg +20 -0
  19. package/src/app/layout.tsx +39 -0
  20. package/src/app/not-found.tsx +16 -0
  21. package/src/app/page.tsx +120 -0
  22. package/src/app/runs/[runId]/page.tsx +279 -0
  23. package/src/cli.ts +271 -0
  24. package/src/components/breakpoint/__tests__/breakpoint-approval.test.tsx +212 -0
  25. package/src/components/breakpoint/__tests__/breakpoint-panel.test.tsx +130 -0
  26. package/src/components/breakpoint/__tests__/file-preview.test.tsx +313 -0
  27. package/src/components/breakpoint/breakpoint-approval.tsx +138 -0
  28. package/src/components/breakpoint/breakpoint-panel.tsx +95 -0
  29. package/src/components/breakpoint/file-preview.tsx +215 -0
  30. package/src/components/dashboard/.gitkeep +0 -0
  31. package/src/components/dashboard/__tests__/breakpoint-banner.test.tsx +177 -0
  32. package/src/components/dashboard/__tests__/catch-up-banner.test.tsx +141 -0
  33. package/src/components/dashboard/__tests__/executive-summary-banner.test.tsx +164 -0
  34. package/src/components/dashboard/__tests__/kpi-grid.test.tsx +101 -0
  35. package/src/components/dashboard/__tests__/pagination-controls.test.tsx +125 -0
  36. package/src/components/dashboard/__tests__/project-accordion.test.tsx +97 -0
  37. package/src/components/dashboard/__tests__/project-list-view.test.tsx +174 -0
  38. package/src/components/dashboard/__tests__/project-search-input.test.tsx +110 -0
  39. package/src/components/dashboard/__tests__/project-section-header.test.tsx +91 -0
  40. package/src/components/dashboard/__tests__/project-section.test.tsx +151 -0
  41. package/src/components/dashboard/__tests__/run-card.test.tsx +164 -0
  42. package/src/components/dashboard/__tests__/run-filter-bar.test.tsx +109 -0
  43. package/src/components/dashboard/__tests__/run-list.test.tsx +123 -0
  44. package/src/components/dashboard/__tests__/search-filter.test.tsx +150 -0
  45. package/src/components/dashboard/__tests__/virtualized-run-list.test.tsx +179 -0
  46. package/src/components/dashboard/breakpoint-banner.tsx +301 -0
  47. package/src/components/dashboard/catch-up-banner.tsx +88 -0
  48. package/src/components/dashboard/executive-summary-banner.tsx +174 -0
  49. package/src/components/dashboard/global-search.tsx +323 -0
  50. package/src/components/dashboard/kpi-grid.tsx +140 -0
  51. package/src/components/dashboard/pagination-controls.tsx +100 -0
  52. package/src/components/dashboard/project-accordion.tsx +72 -0
  53. package/src/components/dashboard/project-health-card.tsx +536 -0
  54. package/src/components/dashboard/project-list-view.tsx +246 -0
  55. package/src/components/dashboard/project-search-input.tsx +41 -0
  56. package/src/components/dashboard/project-section-header.tsx +73 -0
  57. package/src/components/dashboard/project-section.tsx +89 -0
  58. package/src/components/dashboard/run-card.tsx +218 -0
  59. package/src/components/dashboard/run-filter-bar.tsx +100 -0
  60. package/src/components/dashboard/run-list.tsx +77 -0
  61. package/src/components/dashboard/search-filter.tsx +69 -0
  62. package/src/components/dashboard/virtualized-run-list.tsx +130 -0
  63. package/src/components/details/.gitkeep +0 -0
  64. package/src/components/details/__tests__/agent-panel.test.tsx +236 -0
  65. package/src/components/details/__tests__/json-tree.test.tsx +347 -0
  66. package/src/components/details/__tests__/log-viewer.test.tsx +168 -0
  67. package/src/components/details/__tests__/task-detail.test.tsx +212 -0
  68. package/src/components/details/__tests__/timing-panel.test.tsx +271 -0
  69. package/src/components/details/agent-panel.tsx +234 -0
  70. package/src/components/details/json-tree/categorize.ts +131 -0
  71. package/src/components/details/json-tree/index.tsx +120 -0
  72. package/src/components/details/json-tree/json-node.tsx +223 -0
  73. package/src/components/details/json-tree/smart-summary.tsx +596 -0
  74. package/src/components/details/json-tree/tree-controls.tsx +47 -0
  75. package/src/components/details/json-tree.tsx +9 -0
  76. package/src/components/details/log-viewer.tsx +140 -0
  77. package/src/components/details/task-detail.tsx +114 -0
  78. package/src/components/details/timing-panel.tsx +247 -0
  79. package/src/components/events/.gitkeep +0 -0
  80. package/src/components/events/__tests__/event-item.test.tsx +211 -0
  81. package/src/components/events/__tests__/event-stream.test.tsx +225 -0
  82. package/src/components/events/event-item.tsx +121 -0
  83. package/src/components/events/event-stream.tsx +260 -0
  84. package/src/components/notifications/.gitkeep +0 -0
  85. package/src/components/notifications/__tests__/notification-panel.test.tsx +287 -0
  86. package/src/components/notifications/__tests__/notification-provider.test.tsx +585 -0
  87. package/src/components/notifications/__tests__/toast-stack.test.tsx +217 -0
  88. package/src/components/notifications/notification-panel.tsx +124 -0
  89. package/src/components/notifications/notification-provider.tsx +175 -0
  90. package/src/components/notifications/toast-stack.tsx +75 -0
  91. package/src/components/pipeline/.gitkeep +0 -0
  92. package/src/components/pipeline/__tests__/parallel-group.test.tsx +88 -0
  93. package/src/components/pipeline/__tests__/pipeline-view.test.tsx +345 -0
  94. package/src/components/pipeline/__tests__/step-card.test.tsx +330 -0
  95. package/src/components/pipeline/parallel-group.tsx +39 -0
  96. package/src/components/pipeline/pipeline-view.tsx +197 -0
  97. package/src/components/pipeline/step-card.tsx +166 -0
  98. package/src/components/providers/event-stream-provider.tsx +29 -0
  99. package/src/components/providers.tsx +24 -0
  100. package/src/components/shared/.gitkeep +0 -0
  101. package/src/components/shared/__tests__/empty-state.test.tsx +49 -0
  102. package/src/components/shared/__tests__/friendly-id.test.tsx +47 -0
  103. package/src/components/shared/__tests__/kbd.test.tsx +45 -0
  104. package/src/components/shared/__tests__/kind-badge.test.tsx +71 -0
  105. package/src/components/shared/__tests__/metrics-row.test.tsx +74 -0
  106. package/src/components/shared/__tests__/outcome-banner.test.tsx +71 -0
  107. package/src/components/shared/__tests__/progress-bar.test.tsx +89 -0
  108. package/src/components/shared/__tests__/session-pill.test.tsx +62 -0
  109. package/src/components/shared/__tests__/settings-modal.test.tsx +201 -0
  110. package/src/components/shared/__tests__/shortcuts-help.test.tsx +103 -0
  111. package/src/components/shared/__tests__/status-badge.test.tsx +98 -0
  112. package/src/components/shared/__tests__/theme-provider.test.tsx +100 -0
  113. package/src/components/shared/__tests__/truncated-id.test.tsx +53 -0
  114. package/src/components/shared/app-footer.tsx +80 -0
  115. package/src/components/shared/app-header.tsx +160 -0
  116. package/src/components/shared/empty-state.tsx +18 -0
  117. package/src/components/shared/error-boundary.tsx +81 -0
  118. package/src/components/shared/friendly-id.tsx +48 -0
  119. package/src/components/shared/kbd.tsx +15 -0
  120. package/src/components/shared/kind-badge.tsx +51 -0
  121. package/src/components/shared/metrics-row.tsx +106 -0
  122. package/src/components/shared/outcome-banner.tsx +56 -0
  123. package/src/components/shared/progress-bar.tsx +42 -0
  124. package/src/components/shared/session-pill.tsx +69 -0
  125. package/src/components/shared/settings-modal.tsx +509 -0
  126. package/src/components/shared/shortcuts-help.tsx +113 -0
  127. package/src/components/shared/status-badge.tsx +110 -0
  128. package/src/components/shared/theme-provider.tsx +46 -0
  129. package/src/components/shared/truncated-id.tsx +51 -0
  130. package/src/components/ui/.gitkeep +0 -0
  131. package/src/components/ui/__tests__/accordion.test.tsx +96 -0
  132. package/src/components/ui/__tests__/badge.test.tsx +69 -0
  133. package/src/components/ui/__tests__/button.test.tsx +113 -0
  134. package/src/components/ui/__tests__/tabs.test.tsx +75 -0
  135. package/src/components/ui/__tests__/tooltip.test.tsx +90 -0
  136. package/src/components/ui/accordion.tsx +61 -0
  137. package/src/components/ui/badge.tsx +25 -0
  138. package/src/components/ui/button.tsx +40 -0
  139. package/src/components/ui/card.tsx +21 -0
  140. package/src/components/ui/scroll-area.tsx +35 -0
  141. package/src/components/ui/separator.tsx +24 -0
  142. package/src/components/ui/tabs.tsx +64 -0
  143. package/src/components/ui/tooltip.tsx +37 -0
  144. package/src/hooks/.gitkeep +0 -0
  145. package/src/hooks/__tests__/use-animated-number.test.ts +184 -0
  146. package/src/hooks/__tests__/use-batched-updates.test.ts +315 -0
  147. package/src/hooks/__tests__/use-event-stream.test.ts +243 -0
  148. package/src/hooks/__tests__/use-keyboard.test.ts +217 -0
  149. package/src/hooks/__tests__/use-notifications.test.ts +230 -0
  150. package/src/hooks/__tests__/use-polling.test.ts +274 -0
  151. package/src/hooks/__tests__/use-project-runs.test.ts +163 -0
  152. package/src/hooks/__tests__/use-projects.test.ts +248 -0
  153. package/src/hooks/__tests__/use-run-dashboard.test.ts +168 -0
  154. package/src/hooks/__tests__/use-run-detail.test.ts +273 -0
  155. package/src/hooks/__tests__/use-smart-polling.test.ts +305 -0
  156. package/src/hooks/use-animated-number.ts +87 -0
  157. package/src/hooks/use-batched-updates.ts +150 -0
  158. package/src/hooks/use-event-stream.ts +150 -0
  159. package/src/hooks/use-keyboard.ts +45 -0
  160. package/src/hooks/use-notifications.ts +82 -0
  161. package/src/hooks/use-persisted-state.ts +60 -0
  162. package/src/hooks/use-polling.ts +60 -0
  163. package/src/hooks/use-project-runs.ts +51 -0
  164. package/src/hooks/use-projects.ts +26 -0
  165. package/src/hooks/use-run-dashboard.ts +207 -0
  166. package/src/hooks/use-run-detail.ts +77 -0
  167. package/src/hooks/use-smart-polling.ts +144 -0
  168. package/src/lib/.gitkeep +0 -0
  169. package/src/lib/__tests__/cn.test.ts +69 -0
  170. package/src/lib/__tests__/config-loader.test.ts +210 -0
  171. package/src/lib/__tests__/config.test.ts +561 -0
  172. package/src/lib/__tests__/error-handler.test.ts +143 -0
  173. package/src/lib/__tests__/fetcher.test.ts +517 -0
  174. package/src/lib/__tests__/global-registry.test.ts +214 -0
  175. package/src/lib/__tests__/parser.test.ts +1532 -0
  176. package/src/lib/__tests__/path-resolver.test.ts +112 -0
  177. package/src/lib/__tests__/run-cache.test.ts +591 -0
  178. package/src/lib/__tests__/server-init.test.ts +512 -0
  179. package/src/lib/__tests__/source-discovery.test.ts +246 -0
  180. package/src/lib/__tests__/utils.test.ts +160 -0
  181. package/src/lib/__tests__/watcher.test.ts +227 -0
  182. package/src/lib/cn.ts +6 -0
  183. package/src/lib/config-loader.ts +195 -0
  184. package/src/lib/config.ts +20 -0
  185. package/src/lib/error-handler.ts +76 -0
  186. package/src/lib/fetcher.ts +394 -0
  187. package/src/lib/global-registry.ts +117 -0
  188. package/src/lib/parser.ts +794 -0
  189. package/src/lib/path-resolver.ts +16 -0
  190. package/src/lib/run-cache.ts +404 -0
  191. package/src/lib/server-init.ts +226 -0
  192. package/src/lib/services/__tests__/run-query-service.test.ts +819 -0
  193. package/src/lib/services/run-query-service.ts +286 -0
  194. package/src/lib/source-discovery.ts +216 -0
  195. package/src/lib/utils.ts +103 -0
  196. package/src/lib/watcher.ts +265 -0
  197. package/src/test/fixtures.ts +269 -0
  198. package/src/test/mocks/handlers.ts +110 -0
  199. package/src/test/mocks/server.ts +17 -0
  200. package/src/test/setup.ts +200 -0
  201. package/src/test/test-utils.tsx +36 -0
  202. package/src/types/.gitkeep +0 -0
  203. package/src/types/breakpoint.ts +17 -0
  204. package/src/types/index.ts +214 -0
  205. package/tsconfig.json +50 -0
@@ -0,0 +1,211 @@
1
+ import { render, screen, setupUser } from '@/test/test-utils';
2
+ import { EventItem } from '../event-item';
3
+ import { createMockJournalEvent } from '@/test/fixtures';
4
+
5
+ describe('EventItem', () => {
6
+ // -----------------------------------------------------------------------
7
+ // RUN_CREATED
8
+ // -----------------------------------------------------------------------
9
+ describe('RUN_CREATED event', () => {
10
+ it('renders the "Created" badge', () => {
11
+ const event = createMockJournalEvent({
12
+ type: 'RUN_CREATED',
13
+ payload: { processId: 'data-pipeline/ingest' },
14
+ });
15
+
16
+ render(<EventItem event={event} />);
17
+
18
+ expect(screen.getByText('Created')).toBeInTheDocument();
19
+ });
20
+
21
+ it('displays the processId from the payload', () => {
22
+ const event = createMockJournalEvent({
23
+ type: 'RUN_CREATED',
24
+ payload: { processId: 'data-pipeline/ingest' },
25
+ });
26
+
27
+ render(<EventItem event={event} />);
28
+
29
+ expect(screen.getByText('data-pipeline/ingest')).toBeInTheDocument();
30
+ });
31
+ });
32
+
33
+ // -----------------------------------------------------------------------
34
+ // EFFECT_REQUESTED
35
+ // -----------------------------------------------------------------------
36
+ describe('EFFECT_REQUESTED event', () => {
37
+ it('renders the "Requested" badge', () => {
38
+ const event = createMockJournalEvent({
39
+ type: 'EFFECT_REQUESTED',
40
+ payload: { label: 'run-task-abc', kind: 'agent', effectId: 'eff-1', stepId: 'step-1', taskId: 'task-1' },
41
+ });
42
+
43
+ render(<EventItem event={event} />);
44
+
45
+ expect(screen.getByText('Requested')).toBeInTheDocument();
46
+ });
47
+
48
+ it('displays the label from the payload', () => {
49
+ const event = createMockJournalEvent({
50
+ type: 'EFFECT_REQUESTED',
51
+ payload: { label: 'my-task-label', kind: 'shell', effectId: 'eff-1' },
52
+ });
53
+
54
+ render(<EventItem event={event} />);
55
+
56
+ expect(screen.getByText('my-task-label')).toBeInTheDocument();
57
+ });
58
+
59
+ it('shows the kind badge when present', () => {
60
+ const event = createMockJournalEvent({
61
+ type: 'EFFECT_REQUESTED',
62
+ payload: { label: 'my-task', kind: 'agent', effectId: 'eff-1' },
63
+ });
64
+
65
+ render(<EventItem event={event} />);
66
+
67
+ expect(screen.getByText('agent')).toBeInTheDocument();
68
+ });
69
+
70
+ it('falls back to "Task" when label is empty', () => {
71
+ const event = createMockJournalEvent({
72
+ type: 'EFFECT_REQUESTED',
73
+ payload: { label: '', kind: 'node', effectId: 'eff-1' },
74
+ });
75
+
76
+ render(<EventItem event={event} />);
77
+
78
+ expect(screen.getByText('Task')).toBeInTheDocument();
79
+ });
80
+
81
+ it('renders step and task metadata when present', () => {
82
+ const event = createMockJournalEvent({
83
+ type: 'EFFECT_REQUESTED',
84
+ payload: { label: 'x', kind: 'node', effectId: 'eff-1', stepId: 'step-42', taskId: 'task-99' },
85
+ });
86
+
87
+ render(<EventItem event={event} />);
88
+
89
+ expect(screen.getByText(/step: step-42/)).toBeInTheDocument();
90
+ expect(screen.getByText(/task: task-99/)).toBeInTheDocument();
91
+ });
92
+ });
93
+
94
+ // -----------------------------------------------------------------------
95
+ // EFFECT_RESOLVED
96
+ // -----------------------------------------------------------------------
97
+ describe('EFFECT_RESOLVED event', () => {
98
+ it('renders the "Resolved" badge', () => {
99
+ const event = createMockJournalEvent({
100
+ type: 'EFFECT_RESOLVED',
101
+ payload: { effectId: 'eff-1', status: 'ok' },
102
+ });
103
+
104
+ render(<EventItem event={event} />);
105
+
106
+ expect(screen.getByText('Resolved')).toBeInTheDocument();
107
+ });
108
+
109
+ it('displays label when available', () => {
110
+ const event = createMockJournalEvent({
111
+ type: 'EFFECT_RESOLVED',
112
+ payload: { label: 'completed-task', effectId: 'eff-1', status: 'ok' },
113
+ });
114
+
115
+ render(<EventItem event={event} />);
116
+
117
+ expect(screen.getByText('completed-task')).toBeInTheDocument();
118
+ });
119
+
120
+ it('displays duration when startedAt and finishedAt are present', () => {
121
+ const start = '2025-01-01T00:00:00.000Z';
122
+ const finish = '2025-01-01T00:00:03.000Z'; // 3 seconds later
123
+ const event = createMockJournalEvent({
124
+ type: 'EFFECT_RESOLVED',
125
+ payload: { effectId: 'eff-1', status: 'ok', startedAt: start, finishedAt: finish },
126
+ });
127
+
128
+ render(<EventItem event={event} />);
129
+
130
+ expect(screen.getByText('3s')).toBeInTheDocument();
131
+ });
132
+ });
133
+
134
+ // -----------------------------------------------------------------------
135
+ // RUN_COMPLETED
136
+ // -----------------------------------------------------------------------
137
+ describe('RUN_COMPLETED event', () => {
138
+ it('renders the "Completed" badge and success message', () => {
139
+ const event = createMockJournalEvent({
140
+ type: 'RUN_COMPLETED',
141
+ payload: {},
142
+ });
143
+
144
+ render(<EventItem event={event} />);
145
+
146
+ expect(screen.getByText('Completed')).toBeInTheDocument();
147
+ expect(screen.getByText('Run finished successfully')).toBeInTheDocument();
148
+ });
149
+ });
150
+
151
+ // -----------------------------------------------------------------------
152
+ // RUN_FAILED
153
+ // -----------------------------------------------------------------------
154
+ describe('RUN_FAILED event', () => {
155
+ it('renders the "Failed" badge and failure message', () => {
156
+ const event = createMockJournalEvent({
157
+ type: 'RUN_FAILED',
158
+ payload: {},
159
+ });
160
+
161
+ render(<EventItem event={event} />);
162
+
163
+ expect(screen.getByText('Failed')).toBeInTheDocument();
164
+ expect(screen.getByText('Run failed')).toBeInTheDocument();
165
+ });
166
+ });
167
+
168
+ // -----------------------------------------------------------------------
169
+ // Timestamp display
170
+ // -----------------------------------------------------------------------
171
+ it('displays a relative timestamp', () => {
172
+ // Create an event with a recent timestamp
173
+ const event = createMockJournalEvent({
174
+ ts: new Date(Date.now() - 30000).toISOString(), // 30 seconds ago
175
+ type: 'RUN_CREATED',
176
+ payload: { processId: 'p1' },
177
+ });
178
+
179
+ render(<EventItem event={event} />);
180
+
181
+ // formatRelativeTime for 30s ago returns "30s ago"
182
+ expect(screen.getByText('30s ago')).toBeInTheDocument();
183
+ });
184
+
185
+ // -----------------------------------------------------------------------
186
+ // Click handler
187
+ // -----------------------------------------------------------------------
188
+ it('calls onClick when the item is clicked', async () => {
189
+ const user = setupUser();
190
+ const handleClick = vi.fn();
191
+ const event = createMockJournalEvent({
192
+ type: 'RUN_CREATED',
193
+ payload: { processId: 'p1' },
194
+ });
195
+
196
+ render(<EventItem event={event} onClick={handleClick} />);
197
+
198
+ await user.click(screen.getByRole('button'));
199
+ expect(handleClick).toHaveBeenCalledTimes(1);
200
+ });
201
+
202
+ it('renders without crashing when onClick is not provided', () => {
203
+ const event = createMockJournalEvent({
204
+ type: 'RUN_CREATED',
205
+ payload: { processId: 'p1' },
206
+ });
207
+
208
+ const { container } = render(<EventItem event={event} />);
209
+ expect(container.querySelector('button')).toBeInTheDocument();
210
+ });
211
+ });
@@ -0,0 +1,225 @@
1
+ import { render, screen, setupUser } from '@/test/test-utils';
2
+ import { EventStream } from '../event-stream';
3
+ import { createMockJournalEvent } from '@/test/fixtures';
4
+ import type { JournalEvent } from '@/types';
5
+
6
+ describe('EventStream', () => {
7
+ function makeEvents(count: number, type: JournalEvent['type'] = 'EFFECT_REQUESTED'): JournalEvent[] {
8
+ return Array.from({ length: count }, (_, i) =>
9
+ createMockJournalEvent({
10
+ seq: i,
11
+ id: `evt-${i}`,
12
+ ts: new Date(Date.now() - (count - i) * 1000).toISOString(),
13
+ type,
14
+ payload: { effectId: `eff-${i}`, label: `task-${i}`, kind: 'node' },
15
+ }),
16
+ );
17
+ }
18
+
19
+ // -----------------------------------------------------------------------
20
+ // Empty state
21
+ // -----------------------------------------------------------------------
22
+ it('shows "No events yet" when the events array is empty', () => {
23
+ render(<EventStream events={[]} />);
24
+
25
+ expect(screen.getByText('No events yet')).toBeInTheDocument();
26
+ });
27
+
28
+ // -----------------------------------------------------------------------
29
+ // Renders event list
30
+ // -----------------------------------------------------------------------
31
+ it('renders a list of events', () => {
32
+ const events = [
33
+ createMockJournalEvent({
34
+ seq: 0,
35
+ type: 'RUN_CREATED',
36
+ payload: { processId: 'pipeline/ingest' },
37
+ }),
38
+ createMockJournalEvent({
39
+ seq: 1,
40
+ type: 'EFFECT_REQUESTED',
41
+ payload: { label: 'task-alpha', kind: 'agent', effectId: 'e1' },
42
+ }),
43
+ ];
44
+
45
+ render(<EventStream events={events} />);
46
+
47
+ expect(screen.getByText('task-alpha')).toBeInTheDocument();
48
+ expect(screen.getByText('pipeline/ingest')).toBeInTheDocument();
49
+ });
50
+
51
+ // -----------------------------------------------------------------------
52
+ // Event count display
53
+ // -----------------------------------------------------------------------
54
+ it('shows the total event count', () => {
55
+ const events = makeEvents(5);
56
+
57
+ render(<EventStream events={events} />);
58
+
59
+ expect(screen.getByText('5 events')).toBeInTheDocument();
60
+ });
61
+
62
+ // -----------------------------------------------------------------------
63
+ // Filter buttons
64
+ // -----------------------------------------------------------------------
65
+ it('renders filter buttons: All, Tasks, Results, Errors', () => {
66
+ render(<EventStream events={[]} />);
67
+
68
+ expect(screen.getByText('All')).toBeInTheDocument();
69
+ expect(screen.getByText('Tasks')).toBeInTheDocument();
70
+ expect(screen.getByText('Results')).toBeInTheDocument();
71
+ expect(screen.getByText('Errors')).toBeInTheDocument();
72
+ });
73
+
74
+ it('filters events by type when a filter button is clicked', async () => {
75
+ const user = setupUser();
76
+ const events = [
77
+ createMockJournalEvent({
78
+ seq: 0,
79
+ type: 'EFFECT_REQUESTED',
80
+ payload: { label: 'requested-task', kind: 'node', effectId: 'e1' },
81
+ }),
82
+ createMockJournalEvent({
83
+ seq: 1,
84
+ type: 'EFFECT_RESOLVED',
85
+ payload: { label: 'resolved-task', effectId: 'e2', status: 'ok' },
86
+ }),
87
+ ];
88
+
89
+ render(<EventStream events={events} />);
90
+
91
+ // Both visible initially
92
+ expect(screen.getByText('requested-task')).toBeInTheDocument();
93
+ expect(screen.getByText('resolved-task')).toBeInTheDocument();
94
+
95
+ // Click "Tasks" filter (EFFECT_REQUESTED only)
96
+ await user.click(screen.getByText('Tasks'));
97
+
98
+ expect(screen.getByText('requested-task')).toBeInTheDocument();
99
+ expect(screen.queryByText('resolved-task')).not.toBeInTheDocument();
100
+
101
+ // Click "Results" filter (EFFECT_RESOLVED only)
102
+ await user.click(screen.getByText('Results'));
103
+
104
+ expect(screen.queryByText('requested-task')).not.toBeInTheDocument();
105
+ expect(screen.getByText('resolved-task')).toBeInTheDocument();
106
+ });
107
+
108
+ // -----------------------------------------------------------------------
109
+ // Stats bar
110
+ // -----------------------------------------------------------------------
111
+ it('displays summary stats when events are present', () => {
112
+ const events = [
113
+ createMockJournalEvent({ seq: 0, type: 'EFFECT_REQUESTED', payload: { effectId: 'e1', label: 'l1', kind: 'node' } }),
114
+ createMockJournalEvent({ seq: 1, type: 'EFFECT_RESOLVED', payload: { effectId: 'e2', status: 'ok' } }),
115
+ createMockJournalEvent({ seq: 2, type: 'RUN_FAILED', payload: {} }),
116
+ ];
117
+
118
+ render(<EventStream events={events} />);
119
+
120
+ // Check stats are rendered
121
+ expect(screen.getByText('Tasks:')).toBeInTheDocument();
122
+ expect(screen.getByText('Completed:')).toBeInTheDocument();
123
+ expect(screen.getByText('Errors:')).toBeInTheDocument();
124
+ });
125
+
126
+ it('does not display summary stats bar when no events', () => {
127
+ render(<EventStream events={[]} />);
128
+
129
+ expect(screen.queryByText('Tasks:')).not.toBeInTheDocument();
130
+ });
131
+
132
+ // -----------------------------------------------------------------------
133
+ // Event click handler
134
+ // -----------------------------------------------------------------------
135
+ it('calls onEventClick when an event is clicked', async () => {
136
+ const user = setupUser();
137
+ const handleClick = vi.fn();
138
+ const events = [
139
+ createMockJournalEvent({
140
+ seq: 0,
141
+ type: 'RUN_COMPLETED',
142
+ payload: {},
143
+ }),
144
+ ];
145
+
146
+ render(<EventStream events={events} onEventClick={handleClick} />);
147
+
148
+ await user.click(screen.getByText('Run finished successfully'));
149
+ expect(handleClick).toHaveBeenCalledTimes(1);
150
+ expect(handleClick).toHaveBeenCalledWith(events[0]);
151
+ });
152
+
153
+ // -----------------------------------------------------------------------
154
+ // "Show more" pagination
155
+ // -----------------------------------------------------------------------
156
+ it('shows a "Show more" button when there are more than 20 events', () => {
157
+ // Create 25 events (page size is 20)
158
+ const events = makeEvents(25);
159
+
160
+ render(<EventStream events={events} />);
161
+
162
+ expect(screen.getByText(/Show .* more/)).toBeInTheDocument();
163
+ });
164
+
165
+ it('does not show "Show more" when there are 20 or fewer events', () => {
166
+ const events = makeEvents(10);
167
+
168
+ render(<EventStream events={events} />);
169
+
170
+ expect(screen.queryByText(/Show .* more/)).not.toBeInTheDocument();
171
+ });
172
+
173
+ it('loads more events when "Show more" is clicked', async () => {
174
+ const user = setupUser();
175
+ const events = makeEvents(25);
176
+
177
+ render(<EventStream events={events} />);
178
+
179
+ const showMoreBtn = screen.getByText(/Show .* more/);
180
+ await user.click(showMoreBtn);
181
+
182
+ // After loading more, the "Show more" button should be gone (all 25 visible)
183
+ expect(screen.queryByText(/Show .* more/)).not.toBeInTheDocument();
184
+ });
185
+
186
+ // -----------------------------------------------------------------------
187
+ // Event grouping (3+ consecutive same-type events are collapsed)
188
+ // -----------------------------------------------------------------------
189
+ it('groups 3+ consecutive same-type events into a collapsed row', () => {
190
+ // 4 consecutive EFFECT_REQUESTED events
191
+ const events = makeEvents(4, 'EFFECT_REQUESTED');
192
+
193
+ render(<EventStream events={events} />);
194
+
195
+ // The group summary should show "4x" and "Requested"
196
+ expect(screen.getByText('4x')).toBeInTheDocument();
197
+ expect(screen.getByText('Requested')).toBeInTheDocument();
198
+ });
199
+
200
+ it('expands a collapsed group when clicked', async () => {
201
+ const user = setupUser();
202
+ const events = makeEvents(4, 'EFFECT_REQUESTED');
203
+
204
+ render(<EventStream events={events} />);
205
+
206
+ // Initially, individual labels should not be visible (collapsed)
207
+ expect(screen.queryByText('task-0')).not.toBeInTheDocument();
208
+
209
+ // Click the group header to expand
210
+ await user.click(screen.getByText('4x'));
211
+
212
+ // After expansion, individual items should be visible
213
+ expect(screen.getByText('task-0')).toBeInTheDocument();
214
+ expect(screen.getByText('task-1')).toBeInTheDocument();
215
+ });
216
+
217
+ // -----------------------------------------------------------------------
218
+ // "Event Stream" heading
219
+ // -----------------------------------------------------------------------
220
+ it('renders the "Event Stream" heading', () => {
221
+ render(<EventStream events={[]} />);
222
+
223
+ expect(screen.getByText('Event Stream')).toBeInTheDocument();
224
+ });
225
+ });
@@ -0,0 +1,121 @@
1
+ import { memo } from "react";
2
+ import { cn } from "@/lib/cn";
3
+ import { Badge } from "@/components/ui/badge";
4
+ import { formatRelativeTime, formatDuration } from "@/lib/utils";
5
+ import { TruncatedId } from "@/components/shared/truncated-id";
6
+ import type { JournalEvent } from "@/types";
7
+
8
+ const typeConfig: Record<string, { variant: "success" | "error" | "info" | "warning" | "default"; label: string }> = {
9
+ RUN_CREATED: { variant: "info", label: "Created" },
10
+ EFFECT_REQUESTED: { variant: "default", label: "Requested" },
11
+ EFFECT_RESOLVED: { variant: "success", label: "Resolved" },
12
+ RUN_COMPLETED: { variant: "success", label: "Completed" },
13
+ RUN_FAILED: { variant: "error", label: "Failed" },
14
+ };
15
+
16
+ const kindColors: Record<string, string> = {
17
+ agent: "bg-primary/15 text-primary",
18
+ shell: "bg-secondary/15 text-secondary",
19
+ breakpoint: "bg-warning/15 text-warning",
20
+ node: "bg-info/15 text-info",
21
+ skill: "bg-success/15 text-success",
22
+ sleep: "bg-foreground-muted/15 text-foreground-muted",
23
+ };
24
+
25
+ interface EventItemProps {
26
+ event: JournalEvent;
27
+ onClick?: () => void;
28
+ }
29
+
30
+ export const EventItem = memo(function EventItem({ event, onClick }: EventItemProps) {
31
+ const config = typeConfig[event.type] || typeConfig.EFFECT_REQUESTED;
32
+ const payload = event.payload as Record<string, unknown>;
33
+ const label = (payload.label as string) || "";
34
+ const effectId = (payload.effectId as string) || "";
35
+ const kind = (payload.kind as string) || "";
36
+ const status = (payload.status as string) || "";
37
+ const processId = (payload.processId as string) || "";
38
+ const stepId = (payload.stepId as string) || "";
39
+ const taskId = (payload.taskId as string) || "";
40
+
41
+ // Compute duration from startedAt/finishedAt in resolved payloads
42
+ const startedAt = payload.startedAt as string | undefined;
43
+ const finishedAt = payload.finishedAt as string | undefined;
44
+ const resolvedDuration =
45
+ startedAt && finishedAt
46
+ ? new Date(finishedAt).getTime() - new Date(startedAt).getTime()
47
+ : undefined;
48
+
49
+ return (
50
+ <button
51
+ data-testid={`event-item-${event.seq}`}
52
+ data-event-type={event.type}
53
+ onClick={onClick}
54
+ className="w-full text-left px-3 py-2 hover:bg-primary-muted/40 rounded transition-all duration-150"
55
+ >
56
+ {/* Primary line */}
57
+ <div className="flex items-center gap-2">
58
+ <span className="font-mono text-xs leading-tight text-secondary shrink-0 tabular-nums w-12 text-right">
59
+ {formatRelativeTime(event.ts)}
60
+ </span>
61
+ <Badge variant={config.variant} className="text-xs leading-tight shrink-0">
62
+ {config.label}
63
+ </Badge>
64
+
65
+ {event.type === "EFFECT_REQUESTED" && (
66
+ <>
67
+ <span className="text-xs text-foreground truncate font-medium">{label || "Task"}</span>
68
+ {kind && (
69
+ <span className={cn("rounded px-1.5 py-0.5 text-xs leading-tight font-medium shrink-0", kindColors[kind] || "bg-muted text-foreground-muted")}>
70
+ {kind}
71
+ </span>
72
+ )}
73
+ </>
74
+ )}
75
+
76
+ {event.type === "EFFECT_RESOLVED" && (
77
+ <>
78
+ {label && <span className="text-xs text-foreground truncate font-medium">{label}</span>}
79
+ {!label && effectId && <TruncatedId id={effectId} chars={4} className="text-foreground-secondary" />}
80
+ <span className={cn(
81
+ "inline-block h-2 w-2 rounded-full shrink-0",
82
+ status === "ok" ? "bg-success shadow-[0_0_4px_var(--success)]" : status === "error" ? "bg-error shadow-[0_0_4px_var(--error)]" : "bg-foreground-muted"
83
+ )} />
84
+ {resolvedDuration != null && resolvedDuration > 0 && (
85
+ <span className="text-xs leading-tight font-mono text-foreground-muted shrink-0">{formatDuration(resolvedDuration)}</span>
86
+ )}
87
+ </>
88
+ )}
89
+
90
+ {event.type === "RUN_CREATED" && processId && (
91
+ <span className="text-xs text-foreground-secondary truncate">{processId}</span>
92
+ )}
93
+
94
+ {event.type === "RUN_COMPLETED" && (
95
+ <span className="text-xs text-success truncate">Run finished successfully</span>
96
+ )}
97
+
98
+ {event.type === "RUN_FAILED" && (
99
+ <span className="text-xs text-error truncate">Run failed</span>
100
+ )}
101
+
102
+ {/* Fallback for unknown types */}
103
+ {!["EFFECT_REQUESTED", "EFFECT_RESOLVED", "RUN_CREATED", "RUN_COMPLETED", "RUN_FAILED"].includes(event.type) && (
104
+ label ? (
105
+ <span className="text-xs text-foreground-secondary truncate">{label}</span>
106
+ ) : effectId ? (
107
+ <TruncatedId id={effectId} chars={4} className="text-foreground-secondary" />
108
+ ) : null
109
+ )}
110
+ </div>
111
+
112
+ {/* Secondary metadata line */}
113
+ {(stepId || taskId) && (
114
+ <div className="flex items-center gap-2 mt-0.5 ml-14 text-xs leading-tight text-foreground-muted">
115
+ {stepId && <span className="font-mono truncate">step: {stepId}</span>}
116
+ {taskId && <span className="font-mono truncate">task: {taskId}</span>}
117
+ </div>
118
+ )}
119
+ </button>
120
+ );
121
+ });