@matt82198/aesop 0.1.0-beta.4 → 0.1.0-beta.5

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 (174) hide show
  1. package/CHANGELOG.md +46 -5
  2. package/README.md +57 -1
  3. package/aesop.config.example.json +8 -1
  4. package/bin/CLAUDE.md +30 -2
  5. package/bin/cli.js +286 -73
  6. package/daemons/CLAUDE.md +4 -1
  7. package/daemons/run-watchdog.sh +5 -2
  8. package/docs/RELEASING.md +159 -0
  9. package/docs/archive/README.md +3 -0
  10. package/docs/{spikes → archive/spikes}/tiered-cognition/README.md +1 -3
  11. package/docs/case-study-portfolio.md +61 -0
  12. package/docs/self-stats-data.json +11 -0
  13. package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
  14. package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
  15. package/docs/templates/PROPOSALS-LOG.example.md +64 -0
  16. package/hooks/CLAUDE.md +23 -0
  17. package/mcp/CLAUDE.md +213 -0
  18. package/mcp/package.json +26 -0
  19. package/mcp/server.mjs +543 -0
  20. package/monitor/CHARTER.md +76 -110
  21. package/monitor/collect-signals.mjs +85 -0
  22. package/package.json +4 -1
  23. package/scan/fleet-scan.example.mjs +292 -0
  24. package/skills/healthcheck/SKILL.md +44 -0
  25. package/state_store/CLAUDE.md +39 -0
  26. package/state_store/__init__.py +24 -0
  27. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  28. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  29. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  30. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  31. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  32. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  33. package/state_store/api.py +35 -0
  34. package/state_store/export.py +19 -0
  35. package/state_store/ingest.py +24 -0
  36. package/state_store/projections.py +52 -0
  37. package/state_store/store.py +102 -0
  38. package/tools/CLAUDE.md +30 -149
  39. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  40. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  41. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  42. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  43. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  44. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  45. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  46. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  47. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  48. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  49. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  50. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  51. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  52. package/tools/alert_bridge.py +449 -0
  53. package/tools/ci_merge_wait.py +121 -8
  54. package/tools/fleet_prompt_extractor.py +134 -0
  55. package/tools/healthcheck.py +296 -0
  56. package/tools/secret_scan.py +8 -1
  57. package/tools/self_stats.py +509 -0
  58. package/tools/session_usage_summary.py +198 -0
  59. package/tools/svg_to_png.mjs +50 -0
  60. package/tools/transcript_replay.py +236 -0
  61. package/tools/transcript_timeline.py +184 -0
  62. package/tools/verify_dash.py +361 -542
  63. package/tools/verify_submit_encoding.py +12 -4
  64. package/ui/CLAUDE.md +57 -41
  65. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  66. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  67. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  68. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  69. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  70. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  71. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  72. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  73. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  74. package/ui/agents.py +9 -5
  75. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  76. package/ui/api/submit.py +44 -5
  77. package/ui/collectors.py +184 -35
  78. package/ui/config.py +9 -0
  79. package/ui/cost.py +260 -0
  80. package/ui/csrf.py +7 -3
  81. package/ui/handler.py +254 -4
  82. package/ui/render.py +26 -6
  83. package/ui/sse.py +16 -1
  84. package/ui/web/.gitattributes +13 -0
  85. package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
  86. package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
  87. package/ui/web/dist/index.html +14 -0
  88. package/ui/web/index.html +13 -0
  89. package/ui/web/package-lock.json +2225 -0
  90. package/ui/web/package.json +26 -0
  91. package/ui/web/src/App.test.tsx +74 -0
  92. package/ui/web/src/App.tsx +142 -0
  93. package/ui/web/src/CONTRIBUTING-UI.md +49 -0
  94. package/ui/web/src/components/AgentRow.css +187 -0
  95. package/ui/web/src/components/AgentRow.test.tsx +209 -0
  96. package/ui/web/src/components/AgentRow.tsx +207 -0
  97. package/ui/web/src/components/AgentsPanel.css +108 -0
  98. package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
  99. package/ui/web/src/components/AgentsPanel.tsx +58 -0
  100. package/ui/web/src/components/AlertsPanel.css +88 -0
  101. package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
  102. package/ui/web/src/components/AlertsPanel.tsx +67 -0
  103. package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
  104. package/ui/web/src/components/BacklogPanel.tsx +122 -0
  105. package/ui/web/src/components/CostChart.css +110 -0
  106. package/ui/web/src/components/CostChart.test.tsx +144 -0
  107. package/ui/web/src/components/CostChart.tsx +152 -0
  108. package/ui/web/src/components/CostTable.css +93 -0
  109. package/ui/web/src/components/CostTable.test.tsx +165 -0
  110. package/ui/web/src/components/CostTable.tsx +94 -0
  111. package/ui/web/src/components/EventsFeed.css +68 -0
  112. package/ui/web/src/components/EventsFeed.test.tsx +36 -0
  113. package/ui/web/src/components/EventsFeed.tsx +31 -0
  114. package/ui/web/src/components/HealthHeader.css +137 -0
  115. package/ui/web/src/components/HealthHeader.test.tsx +278 -0
  116. package/ui/web/src/components/HealthHeader.tsx +281 -0
  117. package/ui/web/src/components/InboxForm.css +135 -0
  118. package/ui/web/src/components/InboxForm.test.tsx +208 -0
  119. package/ui/web/src/components/InboxForm.tsx +116 -0
  120. package/ui/web/src/components/MessagesTail.module.css +144 -0
  121. package/ui/web/src/components/MessagesTail.test.tsx +176 -0
  122. package/ui/web/src/components/MessagesTail.tsx +94 -0
  123. package/ui/web/src/components/ReposPanel.css +90 -0
  124. package/ui/web/src/components/ReposPanel.test.tsx +45 -0
  125. package/ui/web/src/components/ReposPanel.tsx +67 -0
  126. package/ui/web/src/components/Scorecard.css +106 -0
  127. package/ui/web/src/components/Scorecard.test.tsx +117 -0
  128. package/ui/web/src/components/Scorecard.tsx +85 -0
  129. package/ui/web/src/components/Timeline.module.css +151 -0
  130. package/ui/web/src/components/Timeline.test.tsx +215 -0
  131. package/ui/web/src/components/Timeline.tsx +99 -0
  132. package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
  133. package/ui/web/src/components/TrackerBoard.tsx +107 -0
  134. package/ui/web/src/components/TrackerCard.test.tsx +180 -0
  135. package/ui/web/src/components/TrackerCard.tsx +160 -0
  136. package/ui/web/src/components/TrackerForm.test.tsx +189 -0
  137. package/ui/web/src/components/TrackerForm.tsx +144 -0
  138. package/ui/web/src/lib/api.ts +218 -0
  139. package/ui/web/src/lib/format.test.ts +89 -0
  140. package/ui/web/src/lib/format.ts +103 -0
  141. package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
  142. package/ui/web/src/lib/sanitizeUrl.ts +38 -0
  143. package/ui/web/src/lib/types.ts +230 -0
  144. package/ui/web/src/lib/useHashRoute.test.ts +60 -0
  145. package/ui/web/src/lib/useHashRoute.ts +23 -0
  146. package/ui/web/src/lib/useSSE.ts +175 -0
  147. package/ui/web/src/main.tsx +10 -0
  148. package/ui/web/src/styles/global.css +179 -0
  149. package/ui/web/src/styles/theme.css +184 -0
  150. package/ui/web/src/styles/work.css +572 -0
  151. package/ui/web/src/test/fixtures.ts +385 -0
  152. package/ui/web/src/test/setup.ts +49 -0
  153. package/ui/web/src/views/Activity.module.css +43 -0
  154. package/ui/web/src/views/Activity.test.tsx +89 -0
  155. package/ui/web/src/views/Activity.tsx +31 -0
  156. package/ui/web/src/views/Cost.css +87 -0
  157. package/ui/web/src/views/Cost.test.tsx +142 -0
  158. package/ui/web/src/views/Cost.tsx +54 -0
  159. package/ui/web/src/views/Overview.css +51 -0
  160. package/ui/web/src/views/Overview.test.tsx +76 -0
  161. package/ui/web/src/views/Overview.tsx +46 -0
  162. package/ui/web/src/views/Work.test.tsx +82 -0
  163. package/ui/web/src/views/Work.tsx +79 -0
  164. package/ui/web/src/vite-env.d.ts +10 -0
  165. package/ui/web/tsconfig.json +22 -0
  166. package/ui/web/vite.config.ts +25 -0
  167. package/ui/web/vitest.config.ts +12 -0
  168. package/ui/templates/dashboard.html +0 -1202
  169. /package/docs/{spikes → archive/spikes}/tiered-cognition/ACTIVATION.md +0 -0
  170. /package/docs/{spikes → archive/spikes}/tiered-cognition/DESIGN.md +0 -0
  171. /package/docs/{spikes → archive/spikes}/tiered-cognition/FINDINGS.md +0 -0
  172. /package/docs/{spikes → archive/spikes}/tiered-cognition/aesop-cognition.example.md +0 -0
  173. /package/docs/{spikes → archive/spikes}/tiered-cognition/force-model-policy.merged.mjs +0 -0
  174. /package/docs/{spikes → archive/spikes}/tiered-cognition/strip-tools-hook.mjs +0 -0
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "aesop-ui-web",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "vite",
7
+ "build": "vite build",
8
+ "test": "vitest"
9
+ },
10
+ "dependencies": {
11
+ "react": "19.2.7",
12
+ "react-dom": "19.2.7"
13
+ },
14
+ "devDependencies": {
15
+ "@testing-library/jest-dom": "6.9.1",
16
+ "@testing-library/react": "16.3.2",
17
+ "@testing-library/user-event": "^14.6.1",
18
+ "@types/react": "19.2.17",
19
+ "@types/react-dom": "19.2.3",
20
+ "@vitejs/plugin-react": "6.0.3",
21
+ "jsdom": "29.1.1",
22
+ "typescript": "6.0.2",
23
+ "vite": "8.1.1",
24
+ "vitest": "4.1.10"
25
+ }
26
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { render, screen, act, fireEvent } from '@testing-library/react';
3
+ import App from './App';
4
+ import { TESTIDS } from './test/fixtures';
5
+
6
+ function setHash(hash: string) {
7
+ window.location.hash = hash;
8
+ window.dispatchEvent(new HashChangeEvent('hashchange'));
9
+ }
10
+
11
+ afterEach(() => {
12
+ window.location.hash = '';
13
+ document.documentElement.removeAttribute('data-theme');
14
+ localStorage.clear();
15
+ });
16
+
17
+ describe('App shell', () => {
18
+ it('renders the health header and nav', () => {
19
+ render(<App />);
20
+ expect(screen.getByTestId(TESTIDS.healthHeader)).toBeInTheDocument();
21
+ expect(screen.getByRole('link', { name: 'Overview' })).toHaveAttribute('href', '#/');
22
+ expect(screen.getByRole('link', { name: 'Work' })).toHaveAttribute('href', '#/work');
23
+ expect(screen.getByRole('link', { name: 'Activity' })).toHaveAttribute('href', '#/activity');
24
+ expect(screen.getByRole('link', { name: 'Cost' })).toHaveAttribute('href', '#/cost');
25
+ });
26
+
27
+ it('renders the overview placeholder by default', () => {
28
+ render(<App />);
29
+ expect(screen.getByTestId(TESTIDS.viewOverview)).toBeInTheDocument();
30
+ });
31
+
32
+ it('switches views on hash change', () => {
33
+ render(<App />);
34
+ act(() => setHash('#/work'));
35
+ expect(screen.getByTestId(TESTIDS.viewWork)).toBeInTheDocument();
36
+ expect(screen.queryByTestId(TESTIDS.viewOverview)).not.toBeInTheDocument();
37
+
38
+ act(() => setHash('#/cost'));
39
+ expect(screen.getByTestId(TESTIDS.viewCost)).toBeInTheDocument();
40
+
41
+ act(() => setHash('#/activity'));
42
+ expect(screen.getByTestId(TESTIDS.viewActivity)).toBeInTheDocument();
43
+ });
44
+
45
+ it('marks the active tab with aria-current', () => {
46
+ render(<App />);
47
+ act(() => setHash('#/work'));
48
+ expect(screen.getByRole('link', { name: 'Work' })).toHaveAttribute('aria-current', 'page');
49
+ expect(screen.getByRole('link', { name: 'Overview' })).not.toHaveAttribute('aria-current');
50
+ });
51
+
52
+ it('exposes the SSE connection status as a live region', () => {
53
+ render(<App />);
54
+ const status = screen.getByTestId(TESTIDS.sseStatus);
55
+ expect(status).toHaveAttribute('role', 'status');
56
+ expect(status).toHaveAttribute('aria-live', 'polite');
57
+ });
58
+
59
+ it('theme toggle is a real button and persists to localStorage', () => {
60
+ render(<App />);
61
+ const toggle = screen.getByTestId(TESTIDS.themeToggle);
62
+ expect(toggle.tagName).toBe('BUTTON');
63
+
64
+ fireEvent.click(toggle);
65
+ const stored = localStorage.getItem('aesop-theme');
66
+ expect(stored === 'light' || stored === 'dark').toBe(true);
67
+ expect(document.documentElement.getAttribute('data-theme')).toBe(stored);
68
+
69
+ // Toggling again flips the theme
70
+ fireEvent.click(toggle);
71
+ const flipped = localStorage.getItem('aesop-theme');
72
+ expect(flipped).not.toBe(stored);
73
+ });
74
+ });
@@ -0,0 +1,142 @@
1
+ /**
2
+ * App shell — sticky HealthHeader slot + hash-tab nav + view slots (D4).
3
+ *
4
+ * U1 renders placeholder views; U4–U7 replace the placeholders with real
5
+ * components. The header slot, nav, theme toggle, and SSE plumbing are the
6
+ * stable shell contract.
7
+ *
8
+ * A11y baseline (D5): all interactive elements are real <button>/<a>,
9
+ * nav uses aria-current="page", the SSE status is a role="status" live region.
10
+ */
11
+ import { useCallback, useEffect, useState } from 'react';
12
+ import { useHashRoute, type Route } from './lib/useHashRoute';
13
+ import { useSSE } from './lib/useSSE';
14
+ import { HealthHeader } from './components/HealthHeader';
15
+ import { Overview } from './views/Overview';
16
+ import { Work } from './views/Work';
17
+ import Activity from './views/Activity';
18
+ import { Cost } from './views/Cost';
19
+ import { TESTIDS } from './test/fixtures';
20
+
21
+ const THEME_STORAGE_KEY = 'aesop-theme';
22
+ type Theme = 'light' | 'dark' | null; // null = follow OS preference
23
+
24
+ function readStoredTheme(): Theme {
25
+ try {
26
+ const v = localStorage.getItem(THEME_STORAGE_KEY);
27
+ return v === 'light' || v === 'dark' ? v : null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function applyTheme(theme: Theme) {
34
+ const root = document.documentElement;
35
+ if (theme === null) {
36
+ root.removeAttribute('data-theme');
37
+ } else {
38
+ root.setAttribute('data-theme', theme);
39
+ }
40
+ }
41
+
42
+ function useTheme() {
43
+ const [theme, setTheme] = useState<Theme>(readStoredTheme);
44
+
45
+ useEffect(() => {
46
+ applyTheme(theme);
47
+ try {
48
+ if (theme === null) {
49
+ localStorage.removeItem(THEME_STORAGE_KEY);
50
+ } else {
51
+ localStorage.setItem(THEME_STORAGE_KEY, theme);
52
+ }
53
+ } catch {
54
+ // localStorage unavailable (private mode) — theme still applies for this session
55
+ }
56
+ }, [theme]);
57
+
58
+ const toggle = useCallback(() => {
59
+ setTheme((prev) => {
60
+ const osPrefersDark =
61
+ typeof matchMedia !== 'undefined' &&
62
+ matchMedia('(prefers-color-scheme: dark)').matches;
63
+ const effective = prev ?? (osPrefersDark ? 'dark' : 'light');
64
+ return effective === 'dark' ? 'light' : 'dark';
65
+ });
66
+ }, []);
67
+
68
+ return { theme, toggle };
69
+ }
70
+
71
+ const NAV_ITEMS: Array<{ hash: Route; label: string }> = [
72
+ { hash: '#/', label: 'Overview' },
73
+ { hash: '#/work', label: 'Work' },
74
+ { hash: '#/activity', label: 'Activity' },
75
+ { hash: '#/cost', label: 'Cost' },
76
+ ];
77
+
78
+ function Placeholder({ name, testid }: { name: string; testid: string }) {
79
+ return (
80
+ <section className="view-placeholder" data-testid={testid} aria-label={`${name} view`}>
81
+ <h2>{name}</h2>
82
+ <p>{name} view lands in a later unit of wave 14.</p>
83
+ </section>
84
+ );
85
+ }
86
+
87
+ export default function App() {
88
+ const route = useHashRoute();
89
+ const sseState = useSSE();
90
+ const { toggle } = useTheme();
91
+ const [dataTimestamp, setDataTimestamp] = useState<number | null>(null);
92
+
93
+ const connection = sseState.connectionStatus;
94
+
95
+ // Update timestamp whenever any SSE section updates
96
+ useEffect(() => {
97
+ if (sseState.data || sseState.agents || sseState.tracker || sseState.cost) {
98
+ setDataTimestamp(Date.now());
99
+ }
100
+ }, [sseState.data, sseState.agents, sseState.tracker, sseState.cost]);
101
+
102
+ const handleRefresh = useCallback(() => {
103
+ window.location.reload();
104
+ }, []);
105
+
106
+ return (
107
+ <>
108
+ <HealthHeader
109
+ watchdog={sseState.data?.watchdog ?? null}
110
+ monitor={sseState.data?.monitor ?? null}
111
+ orchestrator={sseState.status ?? null}
112
+ agents={sseState.agents ?? null}
113
+ alerts={sseState.data?.alerts ?? null}
114
+ connectionStatus={connection}
115
+ dataTimestamp={dataTimestamp}
116
+ onThemeToggle={toggle}
117
+ onRefresh={handleRefresh}
118
+ />
119
+ <nav className="app-nav" aria-label="Views">
120
+ {NAV_ITEMS.map(({ hash, label }) => (
121
+ <a key={hash} href={hash} aria-current={route === hash ? 'page' : undefined} className="app-nav__link">
122
+ {label}
123
+ </a>
124
+ ))}
125
+ </nav>
126
+ <main className="app-main">
127
+ {route === '#/' && (
128
+ <Overview
129
+ agents={sseState.agents ?? null}
130
+ alerts={sseState.data?.alerts ?? null}
131
+ events={sseState.data?.events ?? null}
132
+ repos={sseState.data?.repos ?? null}
133
+ />
134
+ )}
135
+ {route === '#/work' && <Work />}
136
+ {route === '#/activity' && <Activity state={sseState} />}
137
+ {route === '#/cost' &&
138
+ (sseState.cost ? <Cost cost={sseState.cost} /> : <Placeholder name="Cost" testid={TESTIDS.viewCost} />)}
139
+ </main>
140
+ </>
141
+ );
142
+ }
@@ -0,0 +1,49 @@
1
+ # Contributing to the Aesop dashboard UI — the rules (one page)
2
+
3
+ These are the baseline rules every component in `ui/web/src/` must follow.
4
+ They are enforced by the wave-14 proofs (`tools/verify_dash.py` + vitest); a
5
+ violation is a CI failure, not a style nit.
6
+
7
+ ## Interactive elements are real elements
8
+
9
+ - Anything clickable is a `<button type="button">` (actions) or an `<a href>`
10
+ (navigation). **Never** a `div`/`span` with `onClick`/`tabIndex` — the old
11
+ dashboard's div-with-tabIndex pattern is banned (plan D5).
12
+ - Anything focusable must show the focus ring. Do not add `outline: none`
13
+ anywhere; `global.css` already provides `:focus-visible` rings.
14
+ - Form controls get a `<label>` (visually hidden with `.sr-only` if needed).
15
+
16
+ ## data-testid contract
17
+
18
+ - Every testid comes from the `TESTIDS` map in `src/test/fixtures.ts`.
19
+ Add new ids there first; never inline a bare string. U8's Playwright proofs
20
+ assert only via these hooks — renaming one breaks CI.
21
+
22
+ ## Types and fixtures
23
+
24
+ - All API payload types live in `src/lib/types.ts` — import them, don't
25
+ redeclare shapes. New endpoint? Add the type there in the same PR.
26
+ - Component tests render from the fixtures in `src/test/fixtures.ts`.
27
+ Extend the fixtures rather than hand-rolling payload literals per test.
28
+
29
+ ## Styling
30
+
31
+ - Colors/spacing/type come from the custom properties in
32
+ `src/styles/theme.css` (the design-tokens file — named `theme.css`, not
33
+ `tokens.css`, because the pre-push secret-scan gate blocks any filename
34
+ matching `*token*`). No hex literals in components — both themes must
35
+ work, and tokens are the only way that stays true.
36
+ - Honor `prefers-reduced-motion`: the global block handles CSS transitions;
37
+ don't add JS-driven animation without a reduced-motion check.
38
+
39
+ ## Safety
40
+
41
+ - Any URL that ends up in an `href` goes through `src/lib/sanitizeUrl.ts`.
42
+ When it returns `null`, render the text without an href (inert).
43
+ - All mutations go through `src/lib/api.ts` (CSRF header handled there).
44
+ Never call `fetch` with `method: 'POST'` directly from a component.
45
+
46
+ ## Build discipline (D2)
47
+
48
+ - If you change `ui/web/src/**`, run `npm run build` and commit `ui/web/dist/`
49
+ in the same PR — CI's drift gate rebuilds and compares.
@@ -0,0 +1,187 @@
1
+ .agent-row {
2
+ list-style: none;
3
+ border-bottom: 1px solid var(--color-border-subtle);
4
+ padding: var(--space-3) 0;
5
+ }
6
+
7
+ .agent-row:last-child {
8
+ border-bottom: none;
9
+ }
10
+
11
+ .agent-row__toggle {
12
+ display: inline-flex;
13
+ align-items: center;
14
+ justify-content: center;
15
+ width: var(--space-5);
16
+ height: var(--space-5);
17
+ padding: 0;
18
+ margin-right: var(--space-2);
19
+ background: transparent;
20
+ border: none;
21
+ cursor: pointer;
22
+ color: var(--color-text-muted);
23
+ transition: color var(--transition-fast);
24
+ }
25
+
26
+ .agent-row__toggle:hover {
27
+ color: var(--color-accent);
28
+ }
29
+
30
+ .agent-row__toggle:focus-visible {
31
+ outline: 2px solid var(--color-focus-ring);
32
+ outline-offset: 2px;
33
+ border-radius: var(--radius-sm);
34
+ }
35
+
36
+ .agent-row__toggle-icon {
37
+ display: inline-block;
38
+ font-size: var(--font-size-xs);
39
+ line-height: 1;
40
+ }
41
+
42
+ .agent-row__header {
43
+ display: flex;
44
+ align-items: center;
45
+ gap: var(--space-3);
46
+ font-size: var(--font-size-sm);
47
+ line-height: var(--line-height-normal);
48
+ }
49
+
50
+ .agent-row__status-icon {
51
+ display: inline-block;
52
+ font-size: var(--font-size-lg);
53
+ flex-shrink: 0;
54
+ }
55
+
56
+ .agent-row__id {
57
+ font-family: var(--font-mono);
58
+ font-weight: var(--font-weight-bold);
59
+ color: var(--color-accent);
60
+ flex-shrink: 0;
61
+ min-width: 100px;
62
+ }
63
+
64
+ .agent-row__age {
65
+ font-family: var(--font-mono);
66
+ font-size: var(--font-size-xs);
67
+ color: var(--color-text-muted);
68
+ flex-shrink: 0;
69
+ min-width: 50px;
70
+ }
71
+
72
+ .agent-row__hint {
73
+ color: var(--color-text);
74
+ flex-grow: 1;
75
+ word-break: break-word;
76
+ }
77
+
78
+ .agent-row__detail {
79
+ margin-top: var(--space-3);
80
+ margin-left: calc(var(--space-5) + var(--space-2));
81
+ padding: var(--space-3);
82
+ background: var(--color-bg-subtle);
83
+ border-left: 2px solid var(--color-accent);
84
+ border-radius: var(--radius-md);
85
+ animation: expand-in 150ms ease;
86
+ }
87
+
88
+ @keyframes expand-in {
89
+ from {
90
+ opacity: 0;
91
+ max-height: 0;
92
+ }
93
+ to {
94
+ opacity: 1;
95
+ max-height: 100vh;
96
+ }
97
+ }
98
+
99
+ @media (prefers-reduced-motion: reduce) {
100
+ .agent-row__detail {
101
+ animation: none;
102
+ }
103
+ }
104
+
105
+ .agent-row__detail-row {
106
+ display: grid;
107
+ grid-template-columns: 120px 1fr;
108
+ gap: var(--space-3);
109
+ align-items: baseline;
110
+ margin-bottom: var(--space-2);
111
+ font-size: var(--font-size-sm);
112
+ line-height: var(--line-height-normal);
113
+ }
114
+
115
+ .agent-row__detail-label {
116
+ font-weight: var(--font-weight-bold);
117
+ color: var(--color-text-muted);
118
+ text-align: right;
119
+ }
120
+
121
+ .agent-row__detail-value {
122
+ color: var(--color-text);
123
+ font-family: var(--font-mono);
124
+ word-break: break-word;
125
+ }
126
+
127
+ .agent-row__prompt {
128
+ margin-top: var(--space-4);
129
+ padding-top: var(--space-3);
130
+ border-top: 1px solid var(--color-border-subtle);
131
+ }
132
+
133
+ .agent-row__prompt-label {
134
+ display: block;
135
+ margin-bottom: var(--space-2);
136
+ font-weight: var(--font-weight-bold);
137
+ color: var(--color-text-muted);
138
+ font-size: var(--font-size-xs);
139
+ text-transform: uppercase;
140
+ letter-spacing: 0.05em;
141
+ }
142
+
143
+ .agent-row__prompt-text {
144
+ background: var(--color-bg-inset);
145
+ border: 1px solid var(--color-border);
146
+ border-radius: var(--radius-md);
147
+ padding: var(--space-3);
148
+ margin: 0;
149
+ font-size: var(--font-size-xs);
150
+ line-height: var(--line-height-normal);
151
+ color: var(--color-text);
152
+ font-family: var(--font-mono);
153
+ white-space: pre-wrap;
154
+ word-break: break-word;
155
+ max-height: 300px;
156
+ overflow-y: auto;
157
+ }
158
+
159
+ .agent-row__prompt-loading,
160
+ .agent-row__prompt-error {
161
+ background: var(--color-bg-inset);
162
+ border: 1px solid var(--color-border);
163
+ border-radius: var(--radius-md);
164
+ padding: var(--space-3);
165
+ font-family: var(--font-mono);
166
+ font-size: var(--font-size-sm);
167
+ }
168
+
169
+ .agent-row__prompt-loading {
170
+ color: var(--color-text-muted);
171
+ }
172
+
173
+ .agent-row__prompt-error {
174
+ color: var(--color-status-error);
175
+ }
176
+
177
+ @media (max-width: 768px) {
178
+ .agent-row__detail-row {
179
+ grid-template-columns: 1fr;
180
+ gap: var(--space-2);
181
+ }
182
+
183
+ .agent-row__detail-label {
184
+ text-align: left;
185
+ font-weight: var(--font-weight-bold);
186
+ }
187
+ }
@@ -0,0 +1,209 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import { userEvent } from '@testing-library/user-event';
4
+ import { AgentRow } from './AgentRow';
5
+ import { fixtureAgents, fixtureAgentDetail, TESTIDS } from '../test/fixtures';
6
+ import * as api from '../lib/api';
7
+
8
+ vi.mock('../lib/api', () => ({
9
+ fetchAgent: vi.fn(),
10
+ }));
11
+
12
+ describe('AgentRow', () => {
13
+ afterEach(() => {
14
+ vi.clearAllMocks();
15
+ });
16
+
17
+ it('renders agent row with basic info', () => {
18
+ render(<AgentRow agent={fixtureAgents[0]} />);
19
+
20
+ expect(screen.getByTestId(TESTIDS.agentRow)).toBeInTheDocument();
21
+ expect(screen.getByText(fixtureAgents[0].id)).toBeInTheDocument();
22
+ expect(screen.getByText(fixtureAgents[0].hint)).toBeInTheDocument();
23
+ });
24
+
25
+ it('displays status indicator with correct color mapping', () => {
26
+ const runningAgent = fixtureAgents[0]; // status: 'running'
27
+ const { container } = render(<AgentRow agent={runningAgent} />);
28
+
29
+ const statusIcon = container.querySelector('.agent-row__status-icon');
30
+ expect(statusIcon).toBeInTheDocument();
31
+ expect(statusIcon).toHaveTextContent('●');
32
+ });
33
+
34
+ it('displays age in appropriate format (seconds/minutes)', () => {
35
+ const agent = { ...fixtureAgents[0], age_s: 45 };
36
+ render(<AgentRow agent={agent} />);
37
+ expect(screen.getByText('45s')).toBeInTheDocument();
38
+
39
+ const { unmount } = render(<AgentRow agent={{ ...fixtureAgents[1], age_s: 341 }} />);
40
+ expect(screen.getByText('5m')).toBeInTheDocument();
41
+ unmount();
42
+ });
43
+
44
+ it('expands and collapses on toggle click', async () => {
45
+ const user = userEvent.setup();
46
+ const mockFetch = vi.mocked(api.fetchAgent);
47
+ mockFetch.mockResolvedValueOnce(fixtureAgentDetail);
48
+
49
+ render(<AgentRow agent={fixtureAgents[0]} />);
50
+
51
+ const toggleButton = screen.getByLabelText(/expand/i);
52
+ expect(toggleButton).toHaveAttribute('aria-expanded', 'false');
53
+
54
+ await user.click(toggleButton);
55
+ expect(toggleButton).toHaveAttribute('aria-expanded', 'true');
56
+
57
+ await waitFor(() => {
58
+ expect(screen.getByTestId(TESTIDS.agentRowDetail)).toBeInTheDocument();
59
+ });
60
+
61
+ await user.click(toggleButton);
62
+ expect(toggleButton).toHaveAttribute('aria-expanded', 'false');
63
+ });
64
+
65
+ it('fetches agent detail on expansion', async () => {
66
+ const mockFetch = vi.mocked(api.fetchAgent);
67
+ mockFetch.mockResolvedValue(fixtureAgentDetail);
68
+
69
+ render(<AgentRow agent={fixtureAgents[0]} />);
70
+
71
+ const toggleButton = screen.getByLabelText(/expand/i);
72
+ await userEvent.click(toggleButton);
73
+
74
+ const prompt = await screen.findByText(fixtureAgentDetail.dispatch_prompt);
75
+ expect(prompt).toBeInTheDocument();
76
+ });
77
+
78
+ it('displays dispatch prompt after fetch completes', async () => {
79
+ const mockFetch = vi.mocked(api.fetchAgent);
80
+ mockFetch.mockResolvedValue(fixtureAgentDetail);
81
+
82
+ render(<AgentRow agent={fixtureAgents[0]} />);
83
+
84
+ const toggleButton = screen.getByLabelText(/expand/i);
85
+ await userEvent.click(toggleButton);
86
+
87
+ const prompt = await screen.findByText(fixtureAgentDetail.dispatch_prompt);
88
+ expect(prompt).toBeInTheDocument();
89
+ });
90
+
91
+ it('caches agent detail on subsequent expansions', async () => {
92
+ const mockFetch = vi.mocked(api.fetchAgent);
93
+ mockFetch.mockResolvedValueOnce(fixtureAgentDetail);
94
+
95
+ const { rerender } = render(<AgentRow agent={fixtureAgents[0]} />);
96
+
97
+ // First expansion
98
+ let toggleButton = screen.getByLabelText(/expand/i);
99
+ await userEvent.click(toggleButton);
100
+
101
+ await waitFor(() => {
102
+ expect(screen.getByText(fixtureAgentDetail.dispatch_prompt)).toBeInTheDocument();
103
+ });
104
+
105
+ const callCountAfterFirstExpand = mockFetch.mock.calls.length;
106
+
107
+ // Collapse
108
+ await userEvent.click(toggleButton);
109
+
110
+ // Re-render with same agent
111
+ rerender(<AgentRow agent={fixtureAgents[0]} />);
112
+
113
+ // Second expansion (should use cache)
114
+ toggleButton = screen.getByLabelText(/expand/i);
115
+ await userEvent.click(toggleButton);
116
+
117
+ // Should still only have been called once (from cache on second expansion)
118
+ expect(mockFetch.mock.calls.length).toBe(callCountAfterFirstExpand);
119
+ });
120
+
121
+ it('is keyboard accessible (toggle button can be focused and activated)', async () => {
122
+ const mockFetch = vi.mocked(api.fetchAgent);
123
+ mockFetch.mockResolvedValueOnce(fixtureAgentDetail);
124
+
125
+ render(<AgentRow agent={fixtureAgents[0]} />);
126
+
127
+ const toggleButton = screen.getByLabelText(/expand/i);
128
+
129
+ // Should be focusable
130
+ toggleButton.focus();
131
+ expect(document.activeElement).toBe(toggleButton);
132
+
133
+ // Should be activatable with Enter
134
+ await userEvent.keyboard('{Enter}');
135
+ expect(toggleButton).toHaveAttribute('aria-expanded', 'true');
136
+
137
+ await waitFor(() => {
138
+ expect(screen.getByTestId(TESTIDS.agentRowDetail)).toBeInTheDocument();
139
+ });
140
+ });
141
+
142
+ it('expansion survives prop updates with same agent (keyed row identity)', async () => {
143
+ const mockFetch = vi.mocked(api.fetchAgent);
144
+ mockFetch.mockResolvedValueOnce(fixtureAgentDetail);
145
+
146
+ const { rerender } = render(<AgentRow agent={fixtureAgents[0]} />);
147
+
148
+ // Expand
149
+ const toggleButton = screen.getByLabelText(/expand/i);
150
+ await userEvent.click(toggleButton);
151
+
152
+ await waitFor(() => {
153
+ expect(screen.getByTestId(TESTIDS.agentRowDetail)).toBeInTheDocument();
154
+ });
155
+
156
+ // Re-render with same agent (simulating parent re-render)
157
+ rerender(<AgentRow agent={fixtureAgents[0]} />);
158
+
159
+ // Expanded detail should still be visible
160
+ expect(screen.getByTestId(TESTIDS.agentRowDetail)).toBeInTheDocument();
161
+ expect(toggleButton).toHaveAttribute('aria-expanded', 'true');
162
+ });
163
+
164
+ it('displays all detail fields when expanded', async () => {
165
+ const mockFetch = vi.mocked(api.fetchAgent);
166
+ mockFetch.mockResolvedValueOnce(fixtureAgentDetail);
167
+
168
+ const agent = fixtureAgents[0];
169
+ render(<AgentRow agent={agent} />);
170
+
171
+ const toggleButton = screen.getByLabelText(/expand/i);
172
+ await userEvent.click(toggleButton);
173
+
174
+ await waitFor(() => {
175
+ expect(screen.getByText(agent.project)).toBeInTheDocument();
176
+ expect(screen.getByText(agent.taskLabel)).toBeInTheDocument();
177
+ });
178
+ });
179
+
180
+ it('formats runtime duration correctly', async () => {
181
+ const mockFetch = vi.mocked(api.fetchAgent);
182
+ mockFetch.mockResolvedValueOnce(fixtureAgentDetail);
183
+
184
+ const agent = { ...fixtureAgents[0], runtimeSeconds: 125 }; // 2m 5s
185
+ render(<AgentRow agent={agent} />);
186
+
187
+ const toggleButton = screen.getByLabelText(/expand/i);
188
+ await userEvent.click(toggleButton);
189
+
190
+ await waitFor(() => {
191
+ expect(screen.getByText('2m 5s')).toBeInTheDocument();
192
+ });
193
+ });
194
+
195
+ it('formats token count with commas', async () => {
196
+ const mockFetch = vi.mocked(api.fetchAgent);
197
+ mockFetch.mockResolvedValueOnce(fixtureAgentDetail);
198
+
199
+ const agent = { ...fixtureAgents[0], tokensUsed: 48213 };
200
+ render(<AgentRow agent={agent} />);
201
+
202
+ const toggleButton = screen.getByLabelText(/expand/i);
203
+ await userEvent.click(toggleButton);
204
+
205
+ await waitFor(() => {
206
+ expect(screen.getByText('48,213')).toBeInTheDocument();
207
+ });
208
+ });
209
+ });