@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,212 @@
1
+ import React from 'react';
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ import { render, screen, setupUser } from '@/test/test-utils';
4
+ import { TaskDetailPanel } from '../task-detail';
5
+ import { createMockTaskDetail } from '@/test/fixtures';
6
+ import type { TaskDetail } from '@/types';
7
+
8
+ // Mock the useTaskDetail hook
9
+ const mockUseTaskDetail = vi.fn<[], { task: TaskDetail | null; loading: boolean; error: string | null }>();
10
+
11
+ vi.mock('@/hooks/use-run-detail', () => ({
12
+ useTaskDetail: (..._args: unknown[]) => mockUseTaskDetail(),
13
+ }));
14
+
15
+ // Mock BreakpointPanel to avoid its complex dependencies
16
+ vi.mock('@/components/breakpoint/breakpoint-panel', () => ({
17
+ BreakpointPanel: ({ task: _task }: { task: unknown }) => (
18
+ <div data-testid="breakpoint-panel">Breakpoint Panel Content</div>
19
+ ),
20
+ }));
21
+
22
+ // Mock clipboard
23
+ beforeEach(() => {
24
+ mockUseTaskDetail.mockReset();
25
+ Object.defineProperty(navigator, 'clipboard', {
26
+ writable: true,
27
+ configurable: true,
28
+ value: {
29
+ writeText: vi.fn().mockResolvedValue(undefined),
30
+ readText: vi.fn().mockResolvedValue(''),
31
+ write: vi.fn().mockResolvedValue(undefined),
32
+ read: vi.fn().mockResolvedValue([]),
33
+ addEventListener: vi.fn(),
34
+ removeEventListener: vi.fn(),
35
+ dispatchEvent: vi.fn().mockReturnValue(false),
36
+ },
37
+ });
38
+ });
39
+
40
+ describe('TaskDetailPanel', () => {
41
+ it('renders "Click a task to view details" when effectId is null', () => {
42
+ mockUseTaskDetail.mockReturnValue({ task: null, loading: false, error: null });
43
+ render(<TaskDetailPanel runId="run-1" effectId={null} />);
44
+ expect(screen.getByText('Click a task to view details')).toBeInTheDocument();
45
+ });
46
+
47
+ it('renders loading spinner when loading and no task', () => {
48
+ mockUseTaskDetail.mockReturnValue({ task: null, loading: true, error: null });
49
+ const { container } = render(<TaskDetailPanel runId="run-1" effectId="eff-1" />);
50
+ // Loader2 icon is mocked as svg with data-lucide="Loader2"
51
+ const spinner = container.querySelector('[data-lucide="Loader2"]');
52
+ expect(spinner).toBeInTheDocument();
53
+ });
54
+
55
+ it('renders tab list with Agent, Timing, Logs, Data tabs', () => {
56
+ const task = createMockTaskDetail({ kind: 'node' });
57
+ mockUseTaskDetail.mockReturnValue({ task, loading: false, error: null });
58
+ render(<TaskDetailPanel runId="run-1" effectId="eff-1" />);
59
+
60
+ expect(screen.getByText('Agent')).toBeInTheDocument();
61
+ expect(screen.getByText('Timing')).toBeInTheDocument();
62
+ expect(screen.getByText('Logs')).toBeInTheDocument();
63
+ expect(screen.getByText('Data')).toBeInTheDocument();
64
+ });
65
+
66
+ it('does not render Approval tab for non-breakpoint tasks', () => {
67
+ const task = createMockTaskDetail({ kind: 'node' });
68
+ mockUseTaskDetail.mockReturnValue({ task, loading: false, error: null });
69
+ render(<TaskDetailPanel runId="run-1" effectId="eff-1" />);
70
+ expect(screen.queryByText('Approval')).not.toBeInTheDocument();
71
+ });
72
+
73
+ it('renders Approval tab for breakpoint tasks', () => {
74
+ const task = createMockTaskDetail({ kind: 'breakpoint' });
75
+ mockUseTaskDetail.mockReturnValue({ task, loading: false, error: null });
76
+ render(<TaskDetailPanel runId="run-1" effectId="eff-1" />);
77
+ expect(screen.getByText('Approval')).toBeInTheDocument();
78
+ });
79
+
80
+ it('calls onTabChange when switching tabs', async () => {
81
+ const user = setupUser();
82
+ const task = createMockTaskDetail({ kind: 'node' });
83
+ mockUseTaskDetail.mockReturnValue({ task, loading: false, error: null });
84
+ const onTabChange = vi.fn();
85
+
86
+ render(
87
+ <TaskDetailPanel
88
+ runId="run-1"
89
+ effectId="eff-1"
90
+ activeTab="agent"
91
+ onTabChange={onTabChange}
92
+ />
93
+ );
94
+
95
+ const timingTab = screen.getByText('Timing');
96
+ await user.click(timingTab);
97
+
98
+ expect(onTabChange).toHaveBeenCalledWith('timing');
99
+ });
100
+
101
+ it('renders with activeTab controlling which tab content is visible', async () => {
102
+ const task = createMockTaskDetail({
103
+ kind: 'node',
104
+ stdout: 'log output here',
105
+ stderr: undefined,
106
+ });
107
+ mockUseTaskDetail.mockReturnValue({ task, loading: false, error: null });
108
+
109
+ render(
110
+ <TaskDetailPanel
111
+ runId="run-1"
112
+ effectId="eff-1"
113
+ activeTab="logs"
114
+ onTabChange={vi.fn()}
115
+ />
116
+ );
117
+
118
+ // LogViewer is lazy-loaded via next/dynamic; wait for it to resolve
119
+ expect(await screen.findByText('stdout')).toBeInTheDocument();
120
+ });
121
+
122
+ it('renders agent panel content when agent tab is active', async () => {
123
+ const task = createMockTaskDetail({
124
+ kind: 'agent',
125
+ title: 'My Agent Task',
126
+ });
127
+ mockUseTaskDetail.mockReturnValue({ task, loading: false, error: null });
128
+
129
+ render(
130
+ <TaskDetailPanel
131
+ runId="run-1"
132
+ effectId="eff-1"
133
+ activeTab="agent"
134
+ onTabChange={vi.fn()}
135
+ />
136
+ );
137
+
138
+ // AgentPanel is lazy-loaded via next/dynamic; wait for it to resolve
139
+ expect(await screen.findByText('My Agent Task')).toBeInTheDocument();
140
+ });
141
+
142
+ it('renders timing panel content when timing tab is active', async () => {
143
+ const task = createMockTaskDetail({
144
+ kind: 'node',
145
+ duration: 5000,
146
+ });
147
+ mockUseTaskDetail.mockReturnValue({ task, loading: false, error: null });
148
+
149
+ render(
150
+ <TaskDetailPanel
151
+ runId="run-1"
152
+ effectId="eff-1"
153
+ activeTab="timing"
154
+ onTabChange={vi.fn()}
155
+ />
156
+ );
157
+
158
+ // TimingPanel is lazy-loaded via next/dynamic; wait for it to resolve
159
+ expect(await screen.findByText('Requested')).toBeInTheDocument();
160
+ expect(screen.getByText('Duration')).toBeInTheDocument();
161
+ });
162
+
163
+ it('renders data panel (JsonTree) when data tab is active', async () => {
164
+ const task = createMockTaskDetail({
165
+ kind: 'node',
166
+ input: { query: 'some-input-data' },
167
+ result: { output: 'result-data' },
168
+ });
169
+ mockUseTaskDetail.mockReturnValue({ task, loading: false, error: null });
170
+
171
+ render(
172
+ <TaskDetailPanel
173
+ runId="run-1"
174
+ effectId="eff-1"
175
+ activeTab="data"
176
+ onTabChange={vi.fn()}
177
+ />
178
+ );
179
+
180
+ // JsonTree is lazy-loaded via next/dynamic; wait for it to resolve
181
+ expect(await screen.findByText('Input')).toBeInTheDocument();
182
+ expect(screen.getByText('Output')).toBeInTheDocument();
183
+ });
184
+
185
+ it('does not show loading spinner when loading is true but task is already available', () => {
186
+ const task = createMockTaskDetail({ kind: 'node' });
187
+ mockUseTaskDetail.mockReturnValue({ task, loading: true, error: null });
188
+ const { container } = render(<TaskDetailPanel runId="run-1" effectId="eff-1" />);
189
+ // Should render tabs, not spinner
190
+ expect(screen.getByText('Agent')).toBeInTheDocument();
191
+ const _spinner = container.querySelector('[data-lucide="Loader2"]');
192
+ // The spinner should not appear (or if it does it's in the tab content, not the loading state)
193
+ expect(screen.queryByText('Click a task to view details')).not.toBeInTheDocument();
194
+ });
195
+
196
+ it('renders breakpoint panel content when breakpoint tab is active', async () => {
197
+ const task = createMockTaskDetail({ kind: 'breakpoint' });
198
+ mockUseTaskDetail.mockReturnValue({ task, loading: false, error: null });
199
+
200
+ render(
201
+ <TaskDetailPanel
202
+ runId="run-1"
203
+ effectId="eff-1"
204
+ activeTab="breakpoint"
205
+ onTabChange={vi.fn()}
206
+ />
207
+ );
208
+
209
+ // BreakpointPanel is lazy-loaded via next/dynamic; wait for it to resolve
210
+ expect(await screen.findByTestId('breakpoint-panel')).toBeInTheDocument();
211
+ });
212
+ });
@@ -0,0 +1,271 @@
1
+ import React from 'react';
2
+ import { describe, it, expect } from 'vitest';
3
+ import { render, screen } from '@/test/test-utils';
4
+ import { TimingPanel } from '../timing-panel';
5
+ import { createMockTaskDetail, createMockTaskEffect } from '@/test/fixtures';
6
+
7
+ describe('TimingPanel', () => {
8
+ it('renders null task state with placeholder message', () => {
9
+ render(<TimingPanel task={null} />);
10
+ expect(screen.getByText('Select a task to view timing')).toBeInTheDocument();
11
+ });
12
+
13
+ it('renders Requested, Resolved, and Duration rows', () => {
14
+ const task = createMockTaskDetail({ duration: 5000 });
15
+ render(<TimingPanel task={task} />);
16
+ expect(screen.getByText('Requested')).toBeInTheDocument();
17
+ expect(screen.getByText('Resolved')).toBeInTheDocument();
18
+ expect(screen.getByText('Duration')).toBeInTheDocument();
19
+ });
20
+
21
+ it('displays formatted duration for a task', () => {
22
+ const task = createMockTaskDetail({ duration: 45000 });
23
+ render(<TimingPanel task={task} />);
24
+ expect(screen.getByText('45s')).toBeInTheDocument();
25
+ });
26
+
27
+ it('displays formatted timestamp for requestedAt', () => {
28
+ const task = createMockTaskDetail({
29
+ requestedAt: '2026-02-16T10:30:45.000Z',
30
+ });
31
+ render(<TimingPanel task={task} />);
32
+ // formatTimestamp produces locale-specific time string
33
+ expect(screen.getByText('Requested')).toBeInTheDocument();
34
+ });
35
+
36
+ it('shows "em dash" for missing resolvedAt on running task', () => {
37
+ const task = createMockTaskDetail({
38
+ status: 'requested',
39
+ resolvedAt: undefined,
40
+ finishedAt: undefined,
41
+ duration: 0,
42
+ });
43
+ render(<TimingPanel task={task} />);
44
+ // formatTimestamp returns \u2014 for undefined
45
+ const dashElements = screen.getAllByText('\u2014');
46
+ expect(dashElements.length).toBeGreaterThanOrEqual(1);
47
+ });
48
+
49
+ it('shows exec timing rows when startedAt/finishedAt differ from requestedAt/resolvedAt', () => {
50
+ const task = createMockTaskDetail({
51
+ requestedAt: '2026-02-16T10:00:00.000Z',
52
+ resolvedAt: '2026-02-16T10:01:00.000Z',
53
+ startedAt: '2026-02-16T10:00:05.000Z',
54
+ finishedAt: '2026-02-16T10:00:55.000Z',
55
+ });
56
+ render(<TimingPanel task={task} />);
57
+ expect(screen.getByText('Exec Started')).toBeInTheDocument();
58
+ expect(screen.getByText('Exec Finished')).toBeInTheDocument();
59
+ });
60
+
61
+ it('does not show exec timing rows when they match requested/resolved', () => {
62
+ const ts1 = '2026-02-16T10:00:00.000Z';
63
+ const ts2 = '2026-02-16T10:01:00.000Z';
64
+ const task = createMockTaskDetail({
65
+ requestedAt: ts1,
66
+ resolvedAt: ts2,
67
+ startedAt: ts1,
68
+ finishedAt: ts2,
69
+ });
70
+ render(<TimingPanel task={task} />);
71
+ expect(screen.queryByText('Exec Started')).not.toBeInTheDocument();
72
+ expect(screen.queryByText('Exec Finished')).not.toBeInTheDocument();
73
+ });
74
+
75
+ it('renders cascading timeline bar when allTasks and runDuration are provided', () => {
76
+ const now = Date.now();
77
+ const tasks = [
78
+ createMockTaskEffect({
79
+ effectId: 'eff-1',
80
+ title: 'Step A',
81
+ requestedAt: new Date(now - 10000).toISOString(),
82
+ resolvedAt: new Date(now - 5000).toISOString(),
83
+ duration: 5000,
84
+ }),
85
+ createMockTaskEffect({
86
+ effectId: 'eff-2',
87
+ title: 'Step B',
88
+ requestedAt: new Date(now - 5000).toISOString(),
89
+ resolvedAt: new Date(now).toISOString(),
90
+ duration: 5000,
91
+ }),
92
+ ];
93
+ const task = createMockTaskDetail({
94
+ ...tasks[0],
95
+ effectId: 'eff-1',
96
+ });
97
+
98
+ render(<TimingPanel task={task} runDuration={10000} allTasks={tasks} />);
99
+
100
+ expect(screen.getByText('Run Timeline')).toBeInTheDocument();
101
+ // Step legend shows step numbers
102
+ expect(screen.getByText(/Step A/)).toBeInTheDocument();
103
+ expect(screen.getByText(/Step B/)).toBeInTheDocument();
104
+ });
105
+
106
+ it('shows step counter for current task in timeline', () => {
107
+ const now = Date.now();
108
+ const tasks = [
109
+ createMockTaskEffect({
110
+ effectId: 'eff-1',
111
+ title: 'First',
112
+ requestedAt: new Date(now - 10000).toISOString(),
113
+ resolvedAt: new Date(now - 5000).toISOString(),
114
+ duration: 5000,
115
+ }),
116
+ createMockTaskEffect({
117
+ effectId: 'eff-2',
118
+ title: 'Second',
119
+ requestedAt: new Date(now - 5000).toISOString(),
120
+ resolvedAt: new Date(now).toISOString(),
121
+ duration: 5000,
122
+ }),
123
+ ];
124
+ const task = createMockTaskDetail({
125
+ ...tasks[0],
126
+ effectId: 'eff-1',
127
+ });
128
+
129
+ render(<TimingPanel task={task} runDuration={10000} allTasks={tasks} />);
130
+
131
+ expect(screen.getByText('Step 1/2')).toBeInTheDocument();
132
+ });
133
+
134
+ it('shows "% of total" for current task segment', () => {
135
+ const now = Date.now();
136
+ const tasks = [
137
+ createMockTaskEffect({
138
+ effectId: 'eff-1',
139
+ title: 'Only Step',
140
+ requestedAt: new Date(now - 10000).toISOString(),
141
+ resolvedAt: new Date(now).toISOString(),
142
+ duration: 10000,
143
+ }),
144
+ ];
145
+ const task = createMockTaskDetail({
146
+ ...tasks[0],
147
+ effectId: 'eff-1',
148
+ });
149
+
150
+ render(<TimingPanel task={task} runDuration={10000} allTasks={tasks} />);
151
+
152
+ expect(screen.getByText(/of total/)).toBeInTheDocument();
153
+ });
154
+
155
+ it('does not render timeline when no allTasks provided', () => {
156
+ const task = createMockTaskDetail({ duration: 5000 });
157
+ render(<TimingPanel task={task} runDuration={10000} />);
158
+ expect(screen.queryByText('Run Timeline')).not.toBeInTheDocument();
159
+ });
160
+
161
+ it('does not render timeline when effectiveRunDuration is 0', () => {
162
+ const task = createMockTaskDetail({ duration: 0 });
163
+ render(<TimingPanel task={task} runDuration={0} allTasks={[]} />);
164
+ expect(screen.queryByText('Run Timeline')).not.toBeInTheDocument();
165
+ });
166
+
167
+ it('does not show step legend for single-task timeline', () => {
168
+ const now = Date.now();
169
+ const tasks = [
170
+ createMockTaskEffect({
171
+ effectId: 'eff-1',
172
+ title: 'Solo Step',
173
+ requestedAt: new Date(now - 5000).toISOString(),
174
+ resolvedAt: new Date(now).toISOString(),
175
+ duration: 5000,
176
+ }),
177
+ ];
178
+ const task = createMockTaskDetail({
179
+ ...tasks[0],
180
+ effectId: 'eff-1',
181
+ });
182
+
183
+ render(<TimingPanel task={task} runDuration={5000} allTasks={tasks} />);
184
+
185
+ // Legend only shows when segments.length > 1
186
+ // The step counter still shows, but no numbered legend list
187
+ expect(screen.queryByText(/1\. Solo Step/)).not.toBeInTheDocument();
188
+ });
189
+
190
+ it('renders step legend with numbered entries for multi-task timeline', () => {
191
+ const now = Date.now();
192
+ const tasks = [
193
+ createMockTaskEffect({
194
+ effectId: 'eff-1',
195
+ title: 'Alpha',
196
+ requestedAt: new Date(now - 10000).toISOString(),
197
+ resolvedAt: new Date(now - 5000).toISOString(),
198
+ duration: 5000,
199
+ }),
200
+ createMockTaskEffect({
201
+ effectId: 'eff-2',
202
+ title: 'Beta',
203
+ requestedAt: new Date(now - 5000).toISOString(),
204
+ resolvedAt: new Date(now).toISOString(),
205
+ duration: 5000,
206
+ }),
207
+ ];
208
+ const task = createMockTaskDetail({
209
+ ...tasks[0],
210
+ effectId: 'eff-1',
211
+ });
212
+
213
+ render(<TimingPanel task={task} runDuration={10000} allTasks={tasks} />);
214
+
215
+ expect(screen.getByText(/1\. Alpha/)).toBeInTheDocument();
216
+ expect(screen.getByText(/2\. Beta/)).toBeInTheDocument();
217
+ });
218
+
219
+ it('falls back to wall-clock duration when task.duration is 0', () => {
220
+ const now = Date.now();
221
+ const task = createMockTaskDetail({
222
+ duration: 0,
223
+ requestedAt: new Date(now - 3000).toISOString(),
224
+ resolvedAt: new Date(now).toISOString(),
225
+ });
226
+ render(<TimingPanel task={task} />);
227
+ // Should compute ~3000ms = 3s
228
+ expect(screen.getByText('3s')).toBeInTheDocument();
229
+ });
230
+
231
+ it('does not fall back to wall-clock duration when explicit exec timestamps are zero-length', () => {
232
+ const task = createMockTaskDetail({
233
+ duration: 0,
234
+ requestedAt: '2026-02-16T10:00:00.000Z',
235
+ resolvedAt: '2026-02-16T10:05:00.000Z',
236
+ startedAt: '2026-02-16T10:02:00.000Z',
237
+ finishedAt: '2026-02-16T10:02:00.000Z',
238
+ });
239
+
240
+ render(<TimingPanel task={task} />);
241
+
242
+ expect(screen.getByText('<1s')).toBeInTheDocument();
243
+ });
244
+
245
+ it('uses step colors cycling through STEP_COLORS array', () => {
246
+ const now = Date.now();
247
+ const tasks = Array.from({ length: 3 }, (_, i) =>
248
+ createMockTaskEffect({
249
+ effectId: `eff-${i}`,
250
+ title: `Step ${i}`,
251
+ requestedAt: new Date(now - (3 - i) * 3000).toISOString(),
252
+ resolvedAt: new Date(now - (2 - i) * 3000).toISOString(),
253
+ duration: 3000,
254
+ })
255
+ );
256
+ const task = createMockTaskDetail({
257
+ ...tasks[0],
258
+ effectId: 'eff-0',
259
+ });
260
+
261
+ const { container } = render(<TimingPanel task={task} runDuration={9000} allTasks={tasks} />);
262
+
263
+ // There should be timeline segment divs with different bg- color classes
264
+ const segmentDivs = container.querySelectorAll('[title]');
265
+ // Filter to only those with Step title patterns
266
+ const timelineSegments = Array.from(segmentDivs).filter(el =>
267
+ el.getAttribute('title')?.includes('Step')
268
+ );
269
+ expect(timelineSegments.length).toBe(3);
270
+ });
271
+ });