@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.
- package/CHANGELOG.md +46 -5
- package/README.md +57 -1
- package/aesop.config.example.json +8 -1
- package/bin/CLAUDE.md +30 -2
- package/bin/cli.js +286 -73
- package/daemons/CLAUDE.md +4 -1
- package/daemons/run-watchdog.sh +5 -2
- package/docs/RELEASING.md +159 -0
- package/docs/archive/README.md +3 -0
- package/docs/{spikes → archive/spikes}/tiered-cognition/README.md +1 -3
- package/docs/case-study-portfolio.md +61 -0
- package/docs/self-stats-data.json +11 -0
- package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
- package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
- package/docs/templates/PROPOSALS-LOG.example.md +64 -0
- package/hooks/CLAUDE.md +23 -0
- package/mcp/CLAUDE.md +213 -0
- package/mcp/package.json +26 -0
- package/mcp/server.mjs +543 -0
- package/monitor/CHARTER.md +76 -110
- package/monitor/collect-signals.mjs +85 -0
- package/package.json +4 -1
- package/scan/fleet-scan.example.mjs +292 -0
- package/skills/healthcheck/SKILL.md +44 -0
- package/state_store/CLAUDE.md +39 -0
- package/state_store/__init__.py +24 -0
- package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
- package/state_store/__pycache__/api.cpython-314.pyc +0 -0
- package/state_store/__pycache__/export.cpython-314.pyc +0 -0
- package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
- package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
- package/state_store/__pycache__/store.cpython-314.pyc +0 -0
- package/state_store/api.py +35 -0
- package/state_store/export.py +19 -0
- package/state_store/ingest.py +24 -0
- package/state_store/projections.py +52 -0
- package/state_store/store.py +102 -0
- package/tools/CLAUDE.md +30 -149
- package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
- package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
- package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
- package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
- package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
- package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
- package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
- package/tools/alert_bridge.py +449 -0
- package/tools/ci_merge_wait.py +121 -8
- package/tools/fleet_prompt_extractor.py +134 -0
- package/tools/healthcheck.py +296 -0
- package/tools/secret_scan.py +8 -1
- package/tools/self_stats.py +509 -0
- package/tools/session_usage_summary.py +198 -0
- package/tools/svg_to_png.mjs +50 -0
- package/tools/transcript_replay.py +236 -0
- package/tools/transcript_timeline.py +184 -0
- package/tools/verify_dash.py +361 -542
- package/tools/verify_submit_encoding.py +12 -4
- package/ui/CLAUDE.md +57 -41
- package/ui/__pycache__/agents.cpython-314.pyc +0 -0
- package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
- package/ui/__pycache__/config.cpython-314.pyc +0 -0
- package/ui/__pycache__/cost.cpython-314.pyc +0 -0
- package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
- package/ui/__pycache__/handler.cpython-314.pyc +0 -0
- package/ui/__pycache__/render.cpython-314.pyc +0 -0
- package/ui/__pycache__/serve.cpython-314.pyc +0 -0
- package/ui/__pycache__/sse.cpython-314.pyc +0 -0
- package/ui/agents.py +9 -5
- package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
- package/ui/api/submit.py +44 -5
- package/ui/collectors.py +184 -35
- package/ui/config.py +9 -0
- package/ui/cost.py +260 -0
- package/ui/csrf.py +7 -3
- package/ui/handler.py +254 -4
- package/ui/render.py +26 -6
- package/ui/sse.py +16 -1
- package/ui/web/.gitattributes +13 -0
- package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
- package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
- package/ui/web/dist/index.html +14 -0
- package/ui/web/index.html +13 -0
- package/ui/web/package-lock.json +2225 -0
- package/ui/web/package.json +26 -0
- package/ui/web/src/App.test.tsx +74 -0
- package/ui/web/src/App.tsx +142 -0
- package/ui/web/src/CONTRIBUTING-UI.md +49 -0
- package/ui/web/src/components/AgentRow.css +187 -0
- package/ui/web/src/components/AgentRow.test.tsx +209 -0
- package/ui/web/src/components/AgentRow.tsx +207 -0
- package/ui/web/src/components/AgentsPanel.css +108 -0
- package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
- package/ui/web/src/components/AgentsPanel.tsx +58 -0
- package/ui/web/src/components/AlertsPanel.css +88 -0
- package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
- package/ui/web/src/components/AlertsPanel.tsx +67 -0
- package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
- package/ui/web/src/components/BacklogPanel.tsx +122 -0
- package/ui/web/src/components/CostChart.css +110 -0
- package/ui/web/src/components/CostChart.test.tsx +144 -0
- package/ui/web/src/components/CostChart.tsx +152 -0
- package/ui/web/src/components/CostTable.css +93 -0
- package/ui/web/src/components/CostTable.test.tsx +165 -0
- package/ui/web/src/components/CostTable.tsx +94 -0
- package/ui/web/src/components/EventsFeed.css +68 -0
- package/ui/web/src/components/EventsFeed.test.tsx +36 -0
- package/ui/web/src/components/EventsFeed.tsx +31 -0
- package/ui/web/src/components/HealthHeader.css +137 -0
- package/ui/web/src/components/HealthHeader.test.tsx +278 -0
- package/ui/web/src/components/HealthHeader.tsx +281 -0
- package/ui/web/src/components/InboxForm.css +135 -0
- package/ui/web/src/components/InboxForm.test.tsx +208 -0
- package/ui/web/src/components/InboxForm.tsx +116 -0
- package/ui/web/src/components/MessagesTail.module.css +144 -0
- package/ui/web/src/components/MessagesTail.test.tsx +176 -0
- package/ui/web/src/components/MessagesTail.tsx +94 -0
- package/ui/web/src/components/ReposPanel.css +90 -0
- package/ui/web/src/components/ReposPanel.test.tsx +45 -0
- package/ui/web/src/components/ReposPanel.tsx +67 -0
- package/ui/web/src/components/Scorecard.css +106 -0
- package/ui/web/src/components/Scorecard.test.tsx +117 -0
- package/ui/web/src/components/Scorecard.tsx +85 -0
- package/ui/web/src/components/Timeline.module.css +151 -0
- package/ui/web/src/components/Timeline.test.tsx +215 -0
- package/ui/web/src/components/Timeline.tsx +99 -0
- package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
- package/ui/web/src/components/TrackerBoard.tsx +107 -0
- package/ui/web/src/components/TrackerCard.test.tsx +180 -0
- package/ui/web/src/components/TrackerCard.tsx +160 -0
- package/ui/web/src/components/TrackerForm.test.tsx +189 -0
- package/ui/web/src/components/TrackerForm.tsx +144 -0
- package/ui/web/src/lib/api.ts +218 -0
- package/ui/web/src/lib/format.test.ts +89 -0
- package/ui/web/src/lib/format.ts +103 -0
- package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
- package/ui/web/src/lib/sanitizeUrl.ts +38 -0
- package/ui/web/src/lib/types.ts +230 -0
- package/ui/web/src/lib/useHashRoute.test.ts +60 -0
- package/ui/web/src/lib/useHashRoute.ts +23 -0
- package/ui/web/src/lib/useSSE.ts +175 -0
- package/ui/web/src/main.tsx +10 -0
- package/ui/web/src/styles/global.css +179 -0
- package/ui/web/src/styles/theme.css +184 -0
- package/ui/web/src/styles/work.css +572 -0
- package/ui/web/src/test/fixtures.ts +385 -0
- package/ui/web/src/test/setup.ts +49 -0
- package/ui/web/src/views/Activity.module.css +43 -0
- package/ui/web/src/views/Activity.test.tsx +89 -0
- package/ui/web/src/views/Activity.tsx +31 -0
- package/ui/web/src/views/Cost.css +87 -0
- package/ui/web/src/views/Cost.test.tsx +142 -0
- package/ui/web/src/views/Cost.tsx +54 -0
- package/ui/web/src/views/Overview.css +51 -0
- package/ui/web/src/views/Overview.test.tsx +76 -0
- package/ui/web/src/views/Overview.tsx +46 -0
- package/ui/web/src/views/Work.test.tsx +82 -0
- package/ui/web/src/views/Work.tsx +79 -0
- package/ui/web/src/vite-env.d.ts +10 -0
- package/ui/web/tsconfig.json +22 -0
- package/ui/web/vite.config.ts +25 -0
- package/ui/web/vitest.config.ts +12 -0
- package/ui/templates/dashboard.html +0 -1202
- /package/docs/{spikes → archive/spikes}/tiered-cognition/ACTIVATION.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/DESIGN.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/FINDINGS.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/aesop-cognition.example.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/force-model-policy.merged.mjs +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/strip-tools-hook.mjs +0 -0
|
@@ -0,0 +1,208 @@
|
|
|
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 { InboxForm } from './InboxForm';
|
|
5
|
+
import { TESTIDS } from '../test/fixtures';
|
|
6
|
+
import * as api from '../lib/api';
|
|
7
|
+
|
|
8
|
+
vi.mock('../lib/api', () => ({
|
|
9
|
+
submitInbox: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
describe('InboxForm', () => {
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.clearAllMocks();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('renders form with input and submit button', () => {
|
|
18
|
+
render(<InboxForm />);
|
|
19
|
+
|
|
20
|
+
expect(screen.getByTestId(TESTIDS.inboxForm)).toBeInTheDocument();
|
|
21
|
+
expect(screen.getByTestId(TESTIDS.inboxInput)).toBeInTheDocument();
|
|
22
|
+
expect(screen.getByTestId(TESTIDS.inboxSubmit)).toBeInTheDocument();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('displays placeholder text', () => {
|
|
26
|
+
render(<InboxForm />);
|
|
27
|
+
|
|
28
|
+
const input = screen.getByTestId(TESTIDS.inboxInput) as HTMLInputElement;
|
|
29
|
+
expect(input).toHaveAttribute('placeholder', 'Add a task or note…');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('disables submit button when input is empty', () => {
|
|
33
|
+
render(<InboxForm />);
|
|
34
|
+
|
|
35
|
+
const submitButton = screen.getByTestId(TESTIDS.inboxSubmit) as HTMLButtonElement;
|
|
36
|
+
expect(submitButton).toBeDisabled();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('enables submit button when input has text', async () => {
|
|
40
|
+
const user = userEvent.setup();
|
|
41
|
+
render(<InboxForm />);
|
|
42
|
+
|
|
43
|
+
const input = screen.getByTestId(TESTIDS.inboxInput);
|
|
44
|
+
await user.type(input, 'Test message');
|
|
45
|
+
|
|
46
|
+
const submitButton = screen.getByTestId(TESTIDS.inboxSubmit) as HTMLButtonElement;
|
|
47
|
+
expect(submitButton).not.toBeDisabled();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('submits form on button click', async () => {
|
|
51
|
+
const user = userEvent.setup();
|
|
52
|
+
const mockSubmit = vi.mocked(api.submitInbox);
|
|
53
|
+
mockSubmit.mockResolvedValueOnce({ ok: true });
|
|
54
|
+
|
|
55
|
+
render(<InboxForm />);
|
|
56
|
+
|
|
57
|
+
const input = screen.getByTestId(TESTIDS.inboxInput);
|
|
58
|
+
await user.type(input, 'Test message');
|
|
59
|
+
|
|
60
|
+
const submitButton = screen.getByTestId(TESTIDS.inboxSubmit);
|
|
61
|
+
await user.click(submitButton);
|
|
62
|
+
|
|
63
|
+
await waitFor(() => {
|
|
64
|
+
expect(mockSubmit).toHaveBeenCalledWith('Test message');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('clears input on successful submission', async () => {
|
|
69
|
+
const user = userEvent.setup();
|
|
70
|
+
const mockSubmit = vi.mocked(api.submitInbox);
|
|
71
|
+
mockSubmit.mockResolvedValueOnce({ ok: true });
|
|
72
|
+
|
|
73
|
+
render(<InboxForm />);
|
|
74
|
+
|
|
75
|
+
const input = screen.getByTestId(TESTIDS.inboxInput) as HTMLInputElement;
|
|
76
|
+
await user.type(input, 'Test message');
|
|
77
|
+
|
|
78
|
+
const submitButton = screen.getByTestId(TESTIDS.inboxSubmit);
|
|
79
|
+
await user.click(submitButton);
|
|
80
|
+
|
|
81
|
+
await waitFor(() => {
|
|
82
|
+
expect(input.value).toBe('');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('displays success message after submission', async () => {
|
|
87
|
+
const user = userEvent.setup();
|
|
88
|
+
const mockSubmit = vi.mocked(api.submitInbox);
|
|
89
|
+
mockSubmit.mockResolvedValueOnce({ ok: true });
|
|
90
|
+
|
|
91
|
+
render(<InboxForm />);
|
|
92
|
+
|
|
93
|
+
const input = screen.getByTestId(TESTIDS.inboxInput);
|
|
94
|
+
await user.type(input, 'Test message');
|
|
95
|
+
|
|
96
|
+
const submitButton = screen.getByTestId(TESTIDS.inboxSubmit);
|
|
97
|
+
await user.click(submitButton);
|
|
98
|
+
|
|
99
|
+
await waitFor(() => {
|
|
100
|
+
expect(screen.getByText('Message submitted!')).toBeInTheDocument();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('handles submission error', async () => {
|
|
105
|
+
const user = userEvent.setup();
|
|
106
|
+
const mockSubmit = vi.mocked(api.submitInbox);
|
|
107
|
+
mockSubmit.mockRejectedValueOnce(new Error('Network error'));
|
|
108
|
+
|
|
109
|
+
render(<InboxForm />);
|
|
110
|
+
|
|
111
|
+
const input = screen.getByTestId(TESTIDS.inboxInput);
|
|
112
|
+
await user.type(input, 'Test message');
|
|
113
|
+
|
|
114
|
+
const submitButton = screen.getByTestId(TESTIDS.inboxSubmit);
|
|
115
|
+
await user.click(submitButton);
|
|
116
|
+
|
|
117
|
+
await waitFor(() => {
|
|
118
|
+
expect(screen.getByText(/Error: Network error/)).toBeInTheDocument();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('disables input while submitting', async () => {
|
|
123
|
+
const user = userEvent.setup();
|
|
124
|
+
const mockSubmit = vi.mocked(api.submitInbox);
|
|
125
|
+
mockSubmit.mockImplementationOnce(
|
|
126
|
+
() =>
|
|
127
|
+
new Promise((resolve) =>
|
|
128
|
+
setTimeout(() => resolve({ ok: true }), 100)
|
|
129
|
+
)
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
render(<InboxForm />);
|
|
133
|
+
|
|
134
|
+
const input = screen.getByTestId(TESTIDS.inboxInput) as HTMLInputElement;
|
|
135
|
+
await user.type(input, 'Test message');
|
|
136
|
+
|
|
137
|
+
const submitButton = screen.getByTestId(TESTIDS.inboxSubmit);
|
|
138
|
+
await user.click(submitButton);
|
|
139
|
+
|
|
140
|
+
expect(input).toBeDisabled();
|
|
141
|
+
|
|
142
|
+
await waitFor(() => {
|
|
143
|
+
expect(input).not.toBeDisabled();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('calls onSubmitSuccess callback after successful submission', async () => {
|
|
148
|
+
const user = userEvent.setup();
|
|
149
|
+
const mockSubmit = vi.mocked(api.submitInbox);
|
|
150
|
+
const mockCallback = vi.fn();
|
|
151
|
+
mockSubmit.mockResolvedValueOnce({ ok: true });
|
|
152
|
+
|
|
153
|
+
render(<InboxForm onSubmitSuccess={mockCallback} />);
|
|
154
|
+
|
|
155
|
+
const input = screen.getByTestId(TESTIDS.inboxInput);
|
|
156
|
+
await user.type(input, 'Test message');
|
|
157
|
+
|
|
158
|
+
const submitButton = screen.getByTestId(TESTIDS.inboxSubmit);
|
|
159
|
+
await user.click(submitButton);
|
|
160
|
+
|
|
161
|
+
await waitFor(() => {
|
|
162
|
+
expect(mockCallback).toHaveBeenCalled();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('has proper accessibility attributes', () => {
|
|
167
|
+
render(<InboxForm />);
|
|
168
|
+
|
|
169
|
+
const input = screen.getByTestId(TESTIDS.inboxInput);
|
|
170
|
+
expect(input).toHaveAttribute('id', 'inbox-input');
|
|
171
|
+
|
|
172
|
+
const label = screen.getByLabelText('Message');
|
|
173
|
+
expect(label).toBe(input);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('submits on Enter key in input', async () => {
|
|
177
|
+
const user = userEvent.setup();
|
|
178
|
+
const mockSubmit = vi.mocked(api.submitInbox);
|
|
179
|
+
mockSubmit.mockResolvedValueOnce({ ok: true });
|
|
180
|
+
|
|
181
|
+
render(<InboxForm />);
|
|
182
|
+
|
|
183
|
+
const input = screen.getByTestId(TESTIDS.inboxInput);
|
|
184
|
+
await user.type(input, 'Test message{Enter}');
|
|
185
|
+
|
|
186
|
+
await waitFor(() => {
|
|
187
|
+
expect(mockSubmit).toHaveBeenCalledWith('Test message');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('trims whitespace from submission', async () => {
|
|
192
|
+
const user = userEvent.setup();
|
|
193
|
+
const mockSubmit = vi.mocked(api.submitInbox);
|
|
194
|
+
mockSubmit.mockResolvedValueOnce({ ok: true });
|
|
195
|
+
|
|
196
|
+
render(<InboxForm />);
|
|
197
|
+
|
|
198
|
+
const input = screen.getByTestId(TESTIDS.inboxInput);
|
|
199
|
+
await user.type(input, ' Test message ');
|
|
200
|
+
|
|
201
|
+
const submitButton = screen.getByTestId(TESTIDS.inboxSubmit);
|
|
202
|
+
await user.click(submitButton);
|
|
203
|
+
|
|
204
|
+
await waitFor(() => {
|
|
205
|
+
expect(mockSubmit).toHaveBeenCalledWith('Test message');
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InboxForm — Quick inbox submit form with aria-live announcement.
|
|
3
|
+
*
|
|
4
|
+
* D5: real <form> and <button> elements, labeled input, success/error
|
|
5
|
+
* announced to an aria-live region.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useRef, useCallback } from 'react';
|
|
9
|
+
import { submitInbox } from '../lib/api';
|
|
10
|
+
import { TESTIDS } from '../test/fixtures';
|
|
11
|
+
import './InboxForm.css';
|
|
12
|
+
|
|
13
|
+
interface InboxFormProps {
|
|
14
|
+
onSubmitSuccess?: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function InboxForm({ onSubmitSuccess }: InboxFormProps) {
|
|
18
|
+
const [input, setInput] = useState('');
|
|
19
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
20
|
+
const [error, setError] = useState<string | null>(null);
|
|
21
|
+
const [success, setSuccess] = useState(false);
|
|
22
|
+
const liveRegionRef = useRef<HTMLDivElement>(null);
|
|
23
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
24
|
+
|
|
25
|
+
const handleSubmit = useCallback(
|
|
26
|
+
async (e: React.FormEvent) => {
|
|
27
|
+
e.preventDefault();
|
|
28
|
+
|
|
29
|
+
if (!input.trim()) {
|
|
30
|
+
setError('Please enter a message');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
setIsSubmitting(true);
|
|
35
|
+
setError(null);
|
|
36
|
+
setSuccess(false);
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
await submitInbox(input.trim());
|
|
40
|
+
setInput('');
|
|
41
|
+
setSuccess(true);
|
|
42
|
+
// Announce to live region
|
|
43
|
+
if (liveRegionRef.current) {
|
|
44
|
+
liveRegionRef.current.textContent = `Message submitted: "${input.trim()}"`;
|
|
45
|
+
}
|
|
46
|
+
onSubmitSuccess?.();
|
|
47
|
+
// Clear success message after 3 seconds
|
|
48
|
+
setTimeout(() => setSuccess(false), 3000);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
const message = err instanceof Error ? err.message : 'Failed to submit';
|
|
51
|
+
setError(message);
|
|
52
|
+
// Announce error to live region
|
|
53
|
+
if (liveRegionRef.current) {
|
|
54
|
+
liveRegionRef.current.textContent = `Error: ${message}`;
|
|
55
|
+
}
|
|
56
|
+
} finally {
|
|
57
|
+
setIsSubmitting(false);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
[input, onSubmitSuccess]
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<section className="inbox-form" data-testid={TESTIDS.inboxForm}>
|
|
65
|
+
<h2>Quick Inbox</h2>
|
|
66
|
+
<form onSubmit={handleSubmit} className="inbox-form__form">
|
|
67
|
+
<div className="inbox-form__group">
|
|
68
|
+
<label htmlFor="inbox-input" className="sr-only">
|
|
69
|
+
Message
|
|
70
|
+
</label>
|
|
71
|
+
<input
|
|
72
|
+
ref={inputRef}
|
|
73
|
+
id="inbox-input"
|
|
74
|
+
type="text"
|
|
75
|
+
className="inbox-form__input"
|
|
76
|
+
data-testid={TESTIDS.inboxInput}
|
|
77
|
+
placeholder="Add a task or note…"
|
|
78
|
+
value={input}
|
|
79
|
+
onChange={(e) => setInput(e.currentTarget.value)}
|
|
80
|
+
disabled={isSubmitting}
|
|
81
|
+
aria-describedby={error ? 'inbox-error' : undefined}
|
|
82
|
+
/>
|
|
83
|
+
<button
|
|
84
|
+
type="submit"
|
|
85
|
+
className="inbox-form__submit"
|
|
86
|
+
data-testid={TESTIDS.inboxSubmit}
|
|
87
|
+
disabled={isSubmitting || !input.trim()}
|
|
88
|
+
aria-label={isSubmitting ? 'Submitting…' : 'Submit message'}
|
|
89
|
+
>
|
|
90
|
+
{isSubmitting ? 'Submitting…' : 'Submit'}
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{error && (
|
|
95
|
+
<div className="inbox-form__error" id="inbox-error">
|
|
96
|
+
{error}
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
{success && (
|
|
101
|
+
<div className="inbox-form__success">
|
|
102
|
+
Message submitted!
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
</form>
|
|
106
|
+
|
|
107
|
+
<div
|
|
108
|
+
ref={liveRegionRef}
|
|
109
|
+
className="sr-only"
|
|
110
|
+
role="status"
|
|
111
|
+
aria-live="polite"
|
|
112
|
+
aria-atomic="true"
|
|
113
|
+
/>
|
|
114
|
+
</section>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MessagesTail styling — role-colored messages with smooth scrolling.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
.container {
|
|
6
|
+
display: flex;
|
|
7
|
+
flex-direction: column;
|
|
8
|
+
height: 100%;
|
|
9
|
+
border: 1px solid var(--color-border);
|
|
10
|
+
border-radius: var(--radius-md);
|
|
11
|
+
background: var(--color-bg-subtle);
|
|
12
|
+
overflow: hidden;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.header {
|
|
16
|
+
display: flex;
|
|
17
|
+
align-items: center;
|
|
18
|
+
justify-content: space-between;
|
|
19
|
+
padding: var(--space-4);
|
|
20
|
+
border-bottom: 1px solid var(--color-border-subtle);
|
|
21
|
+
background: var(--color-bg);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.title {
|
|
25
|
+
margin: 0;
|
|
26
|
+
font-size: var(--font-size-md);
|
|
27
|
+
font-weight: var(--font-weight-medium);
|
|
28
|
+
color: var(--color-text);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.toggleButton {
|
|
32
|
+
padding: var(--space-2) var(--space-3);
|
|
33
|
+
border: 1px solid var(--color-border);
|
|
34
|
+
border-radius: var(--radius-sm);
|
|
35
|
+
background: var(--color-bg);
|
|
36
|
+
color: var(--color-text-muted);
|
|
37
|
+
font-size: var(--font-size-sm);
|
|
38
|
+
cursor: pointer;
|
|
39
|
+
transition: all var(--transition-fast);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.toggleButton:hover {
|
|
43
|
+
background: var(--color-bg-inset);
|
|
44
|
+
color: var(--color-text);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.toggleButton:focus-visible {
|
|
48
|
+
outline: 2px solid var(--color-focus-ring);
|
|
49
|
+
outline-offset: 2px;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.toggleButton[aria-pressed='true'] {
|
|
53
|
+
background: var(--color-status-ok-bg);
|
|
54
|
+
color: var(--color-status-ok);
|
|
55
|
+
border-color: var(--color-status-ok);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.messagesBox {
|
|
59
|
+
flex: 1;
|
|
60
|
+
overflow-y: auto;
|
|
61
|
+
padding: var(--space-4);
|
|
62
|
+
display: flex;
|
|
63
|
+
flex-direction: column;
|
|
64
|
+
gap: var(--space-3);
|
|
65
|
+
scroll-behavior: smooth;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.emptyState {
|
|
69
|
+
color: var(--color-text-muted);
|
|
70
|
+
font-size: var(--font-size-sm);
|
|
71
|
+
text-align: center;
|
|
72
|
+
padding: var(--space-8) var(--space-4);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.message {
|
|
76
|
+
padding: var(--space-3) var(--space-4);
|
|
77
|
+
border-left: 3px solid var(--color-border);
|
|
78
|
+
border-radius: var(--radius-sm);
|
|
79
|
+
background: var(--color-bg);
|
|
80
|
+
font-size: var(--font-size-sm);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* Role-based coloring */
|
|
84
|
+
.role-user {
|
|
85
|
+
border-left-color: var(--color-status-info);
|
|
86
|
+
background: rgba(9, 105, 218, 0.04); /* info with opacity */
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.role-assistant {
|
|
90
|
+
border-left-color: var(--color-status-ok);
|
|
91
|
+
background: rgba(26, 127, 55, 0.04); /* ok with opacity */
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.role {
|
|
95
|
+
display: inline-block;
|
|
96
|
+
font-weight: var(--font-weight-bold);
|
|
97
|
+
text-transform: uppercase;
|
|
98
|
+
font-size: var(--font-size-xs);
|
|
99
|
+
margin-right: var(--space-2);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.role-user .role {
|
|
103
|
+
color: var(--color-status-info);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.role-assistant .role {
|
|
107
|
+
color: var(--color-status-ok);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.timestamp {
|
|
111
|
+
display: inline-block;
|
|
112
|
+
color: var(--color-text-muted);
|
|
113
|
+
font-size: var(--font-size-xs);
|
|
114
|
+
font-family: var(--font-mono);
|
|
115
|
+
margin-left: var(--space-2);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.text {
|
|
119
|
+
margin-top: var(--space-2);
|
|
120
|
+
color: var(--color-text);
|
|
121
|
+
line-height: var(--line-height-normal);
|
|
122
|
+
word-wrap: break-word;
|
|
123
|
+
white-space: pre-wrap;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.footer {
|
|
127
|
+
padding: var(--space-2) var(--space-4);
|
|
128
|
+
border-top: 1px solid var(--color-border-subtle);
|
|
129
|
+
background: var(--color-bg);
|
|
130
|
+
text-align: right;
|
|
131
|
+
font-size: var(--font-size-xs);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.footerTimestamp {
|
|
135
|
+
color: var(--color-text-muted);
|
|
136
|
+
font-family: var(--font-mono);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* Respect reduced motion */
|
|
140
|
+
@media (prefers-reduced-motion: reduce) {
|
|
141
|
+
.messagesBox {
|
|
142
|
+
scroll-behavior: auto;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MessagesTail — Last ~12 messages with role coloring and auto-follow toggle.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
7
|
+
import MessagesTail from './MessagesTail';
|
|
8
|
+
import { fixtureMessages } from '../test/fixtures';
|
|
9
|
+
import { TESTIDS } from '../test/fixtures';
|
|
10
|
+
|
|
11
|
+
describe('MessagesTail', () => {
|
|
12
|
+
it('renders the messages tail container with testid', () => {
|
|
13
|
+
render(<MessagesTail messages={fixtureMessages} />);
|
|
14
|
+
expect(screen.getByTestId(TESTIDS.messagesTail)).toBeInTheDocument();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('renders all messages with role and text', () => {
|
|
18
|
+
render(<MessagesTail messages={fixtureMessages} />);
|
|
19
|
+
expect(screen.getByText('Run wave 14: dashboard rewrite, start with the foundation unit.')).toBeInTheDocument();
|
|
20
|
+
expect(screen.getByText('Dispatching U1 (foundation) to a worktree agent; U3 cost collector runs in parallel.')).toBeInTheDocument();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('renders messages with role-based styling', () => {
|
|
24
|
+
render(<MessagesTail messages={fixtureMessages} />);
|
|
25
|
+
const messages = screen.getAllByTestId(/message-\d+/);
|
|
26
|
+
expect(messages.length).toBe(fixtureMessages.length);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('formats timestamps using format.ts', () => {
|
|
30
|
+
render(<MessagesTail messages={fixtureMessages} />);
|
|
31
|
+
// The formatTimestamp should produce relative times (e.g., "now", "2m ago")
|
|
32
|
+
// Just verify timestamps are rendered (exact format depends on current time)
|
|
33
|
+
const container = screen.getByTestId(TESTIDS.messagesTail);
|
|
34
|
+
const textContent = container.textContent || '';
|
|
35
|
+
expect(textContent.length > 0).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('shows empty state when no messages', () => {
|
|
39
|
+
render(<MessagesTail messages={[]} />);
|
|
40
|
+
const container = screen.getByTestId(TESTIDS.messagesTail);
|
|
41
|
+
expect(container.textContent).toContain('no messages');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('renders follow toggle as a real button', () => {
|
|
45
|
+
render(<MessagesTail messages={fixtureMessages} />);
|
|
46
|
+
const toggleButton = screen.getByTestId(TESTIDS.messagesFollowToggle);
|
|
47
|
+
expect(toggleButton).toBeInstanceOf(HTMLButtonElement);
|
|
48
|
+
expect(toggleButton).toHaveTextContent(/follow|scroll/i);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('follow toggle is checked by default', () => {
|
|
52
|
+
render(<MessagesTail messages={fixtureMessages} />);
|
|
53
|
+
const toggleButton = screen.getByTestId(TESTIDS.messagesFollowToggle);
|
|
54
|
+
expect(toggleButton).toHaveAttribute('aria-pressed', 'true');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('pause follow when user scrolls up manually', async () => {
|
|
58
|
+
render(<MessagesTail messages={fixtureMessages} />);
|
|
59
|
+
const toggleButton = screen.getByTestId(TESTIDS.messagesFollowToggle);
|
|
60
|
+
|
|
61
|
+
// Toggle button starts in "following" state
|
|
62
|
+
expect(toggleButton).toHaveAttribute('aria-pressed', 'true');
|
|
63
|
+
|
|
64
|
+
// Simulate user scrolling - this would normally set isFollowing to false
|
|
65
|
+
// In real browser: scroll event triggers handleScroll which checks distance from bottom
|
|
66
|
+
// For testing, we'll just click to simulate the behavior
|
|
67
|
+
fireEvent.click(toggleButton);
|
|
68
|
+
|
|
69
|
+
await waitFor(() => {
|
|
70
|
+
expect(toggleButton).toHaveAttribute('aria-pressed', 'false');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('resume follow when toggle is clicked', async () => {
|
|
75
|
+
render(<MessagesTail messages={fixtureMessages} />);
|
|
76
|
+
const toggleButton = screen.getByTestId(TESTIDS.messagesFollowToggle);
|
|
77
|
+
|
|
78
|
+
// Start in following state
|
|
79
|
+
expect(toggleButton).toHaveAttribute('aria-pressed', 'true');
|
|
80
|
+
|
|
81
|
+
// Click to pause
|
|
82
|
+
fireEvent.click(toggleButton);
|
|
83
|
+
expect(toggleButton).toHaveAttribute('aria-pressed', 'false');
|
|
84
|
+
|
|
85
|
+
// Click again to resume
|
|
86
|
+
fireEvent.click(toggleButton);
|
|
87
|
+
|
|
88
|
+
await waitFor(() => {
|
|
89
|
+
expect(toggleButton).toHaveAttribute('aria-pressed', 'true');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('auto-scrolls when follow is enabled and new messages arrive', async () => {
|
|
94
|
+
const { rerender } = render(<MessagesTail messages={fixtureMessages} />);
|
|
95
|
+
|
|
96
|
+
const newMessages = [
|
|
97
|
+
...fixtureMessages,
|
|
98
|
+
{
|
|
99
|
+
role: 'user' as const,
|
|
100
|
+
text: 'New message',
|
|
101
|
+
timestamp: '2026-07-13T14:35:00.000Z',
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
rerender(<MessagesTail messages={newMessages} />);
|
|
106
|
+
|
|
107
|
+
// Should have auto-scrolled to bottom
|
|
108
|
+
expect(screen.getByText('New message')).toBeInTheDocument();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('does not auto-scroll when follow is disabled', async () => {
|
|
112
|
+
const { rerender } = render(<MessagesTail messages={fixtureMessages} />);
|
|
113
|
+
const toggleButton = screen.getByTestId(TESTIDS.messagesFollowToggle);
|
|
114
|
+
|
|
115
|
+
// Disable follow
|
|
116
|
+
fireEvent.click(toggleButton);
|
|
117
|
+
|
|
118
|
+
await waitFor(() => {
|
|
119
|
+
expect(toggleButton).toHaveAttribute('aria-pressed', 'false');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Add new message
|
|
123
|
+
const newMessages = [
|
|
124
|
+
...fixtureMessages,
|
|
125
|
+
{
|
|
126
|
+
role: 'assistant' as const,
|
|
127
|
+
text: 'Another message',
|
|
128
|
+
timestamp: '2026-07-13T14:36:00.000Z',
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
rerender(<MessagesTail messages={newMessages} />);
|
|
133
|
+
|
|
134
|
+
// New message should be in DOM but scroll position should not change
|
|
135
|
+
expect(screen.getByText('Another message')).toBeInTheDocument();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('renders messages with distinct role styling (user vs assistant)', () => {
|
|
139
|
+
render(<MessagesTail messages={fixtureMessages} />);
|
|
140
|
+
// First message is user, should have different styling than assistant messages
|
|
141
|
+
const userMessage = screen.getByText('Run wave 14: dashboard rewrite, start with the foundation unit.');
|
|
142
|
+
const userMessageContainer = userMessage.closest('[data-testid^="message-"]') as HTMLElement;
|
|
143
|
+
expect(userMessageContainer?.className).toContain('role-user');
|
|
144
|
+
|
|
145
|
+
// Check for an assistant message
|
|
146
|
+
const assistantMessage = screen.getByText('Dispatching U1 (foundation) to a worktree agent; U3 cost collector runs in parallel.');
|
|
147
|
+
const assistantMessageContainer = assistantMessage.closest('[data-testid^="message-"]') as HTMLElement;
|
|
148
|
+
expect(assistantMessageContainer?.className).toContain('role-assistant');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('handles null/undefined messages gracefully', () => {
|
|
152
|
+
const edgeCaseMessages = [
|
|
153
|
+
...fixtureMessages,
|
|
154
|
+
{
|
|
155
|
+
role: 'assistant' as const,
|
|
156
|
+
text: '',
|
|
157
|
+
timestamp: '2026-07-13T14:37:00.000Z',
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
render(<MessagesTail messages={edgeCaseMessages} />);
|
|
161
|
+
expect(screen.getByTestId(TESTIDS.messagesTail)).toBeInTheDocument();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('limits to last ~12 messages if more provided', () => {
|
|
165
|
+
const manyMessages: Array<typeof fixtureMessages[0]> = Array.from({ length: 20 }, (_, i) => ({
|
|
166
|
+
role: i % 2 === 0 ? ('user' as const) : ('assistant' as const),
|
|
167
|
+
text: `Message ${i}`,
|
|
168
|
+
timestamp: new Date(Date.now() - i * 60000).toISOString(),
|
|
169
|
+
}));
|
|
170
|
+
|
|
171
|
+
render(<MessagesTail messages={manyMessages} />);
|
|
172
|
+
const container = screen.getByTestId(TESTIDS.messagesTail);
|
|
173
|
+
const messageElements = container.querySelectorAll('[data-testid^="message-"]');
|
|
174
|
+
expect(messageElements.length).toBeLessThanOrEqual(12);
|
|
175
|
+
});
|
|
176
|
+
});
|