tlc-claude-code 1.4.2 → 1.4.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/dashboard/dist/App.js +28 -2
- package/dashboard/dist/api/health-diagnostics.d.ts +26 -0
- package/dashboard/dist/api/health-diagnostics.js +85 -0
- package/dashboard/dist/api/health-diagnostics.test.d.ts +1 -0
- package/dashboard/dist/api/health-diagnostics.test.js +126 -0
- package/dashboard/dist/api/index.d.ts +5 -0
- package/dashboard/dist/api/index.js +5 -0
- package/dashboard/dist/api/notes-api.d.ts +18 -0
- package/dashboard/dist/api/notes-api.js +68 -0
- package/dashboard/dist/api/notes-api.test.d.ts +1 -0
- package/dashboard/dist/api/notes-api.test.js +113 -0
- package/dashboard/dist/api/safeFetch.d.ts +50 -0
- package/dashboard/dist/api/safeFetch.js +135 -0
- package/dashboard/dist/api/safeFetch.test.d.ts +1 -0
- package/dashboard/dist/api/safeFetch.test.js +215 -0
- package/dashboard/dist/api/tasks-api.d.ts +32 -0
- package/dashboard/dist/api/tasks-api.js +98 -0
- package/dashboard/dist/api/tasks-api.test.d.ts +1 -0
- package/dashboard/dist/api/tasks-api.test.js +383 -0
- package/dashboard/dist/components/BugsPane.d.ts +20 -0
- package/dashboard/dist/components/BugsPane.js +210 -0
- package/dashboard/dist/components/BugsPane.test.d.ts +1 -0
- package/dashboard/dist/components/BugsPane.test.js +256 -0
- package/dashboard/dist/components/HealthPane.d.ts +3 -1
- package/dashboard/dist/components/HealthPane.js +44 -6
- package/dashboard/dist/components/HealthPane.test.js +105 -2
- package/dashboard/dist/components/RouterPane.d.ts +4 -3
- package/dashboard/dist/components/RouterPane.js +60 -57
- package/dashboard/dist/components/RouterPane.test.js +150 -96
- package/dashboard/dist/components/UpdateBanner.d.ts +26 -0
- package/dashboard/dist/components/UpdateBanner.js +30 -0
- package/dashboard/dist/components/UpdateBanner.test.d.ts +1 -0
- package/dashboard/dist/components/UpdateBanner.test.js +96 -0
- package/dashboard/dist/components/accessibility.test.d.ts +1 -0
- package/dashboard/dist/components/accessibility.test.js +116 -0
- package/dashboard/dist/components/layout/MobileNav.d.ts +16 -0
- package/dashboard/dist/components/layout/MobileNav.js +31 -0
- package/dashboard/dist/components/layout/MobileNav.test.d.ts +1 -0
- package/dashboard/dist/components/layout/MobileNav.test.js +111 -0
- package/dashboard/dist/components/performance.test.d.ts +1 -0
- package/dashboard/dist/components/performance.test.js +114 -0
- package/dashboard/dist/components/responsive.test.d.ts +1 -0
- package/dashboard/dist/components/responsive.test.js +114 -0
- package/dashboard/dist/components/ui/Dropdown.d.ts +22 -0
- package/dashboard/dist/components/ui/Dropdown.js +109 -0
- package/dashboard/dist/components/ui/Dropdown.test.d.ts +1 -0
- package/dashboard/dist/components/ui/Dropdown.test.js +105 -0
- package/dashboard/dist/components/ui/EmptyState.d.ts +14 -0
- package/dashboard/dist/components/ui/EmptyState.js +58 -0
- package/dashboard/dist/components/ui/EmptyState.test.d.ts +1 -0
- package/dashboard/dist/components/ui/EmptyState.test.js +97 -0
- package/dashboard/dist/components/ui/ErrorState.d.ts +17 -0
- package/dashboard/dist/components/ui/ErrorState.js +80 -0
- package/dashboard/dist/components/ui/ErrorState.test.d.ts +1 -0
- package/dashboard/dist/components/ui/ErrorState.test.js +166 -0
- package/dashboard/dist/components/ui/Modal.d.ts +13 -0
- package/dashboard/dist/components/ui/Modal.js +25 -0
- package/dashboard/dist/components/ui/Modal.test.d.ts +1 -0
- package/dashboard/dist/components/ui/Modal.test.js +91 -0
- package/dashboard/dist/components/ui/Skeleton.d.ts +32 -0
- package/dashboard/dist/components/ui/Skeleton.js +48 -0
- package/dashboard/dist/components/ui/Skeleton.test.d.ts +1 -0
- package/dashboard/dist/components/ui/Skeleton.test.js +125 -0
- package/dashboard/dist/components/ui/Toast.d.ts +32 -0
- package/dashboard/dist/components/ui/Toast.js +21 -0
- package/dashboard/dist/components/ui/Toast.test.d.ts +1 -0
- package/dashboard/dist/components/ui/Toast.test.js +118 -0
- package/dashboard/dist/hooks/useTheme.d.ts +37 -0
- package/dashboard/dist/hooks/useTheme.js +96 -0
- package/dashboard/dist/hooks/useTheme.test.d.ts +1 -0
- package/dashboard/dist/hooks/useTheme.test.js +94 -0
- package/dashboard/dist/hooks/useWebSocket.d.ts +17 -0
- package/dashboard/dist/hooks/useWebSocket.js +100 -0
- package/dashboard/dist/hooks/useWebSocket.test.d.ts +1 -0
- package/dashboard/dist/hooks/useWebSocket.test.js +115 -0
- package/dashboard/dist/stores/projectStore.d.ts +44 -0
- package/dashboard/dist/stores/projectStore.js +76 -0
- package/dashboard/dist/stores/projectStore.test.d.ts +1 -0
- package/dashboard/dist/stores/projectStore.test.js +114 -0
- package/dashboard/dist/stores/uiStore.d.ts +29 -0
- package/dashboard/dist/stores/uiStore.js +72 -0
- package/dashboard/dist/stores/uiStore.test.d.ts +1 -0
- package/dashboard/dist/stores/uiStore.test.js +93 -0
- package/dashboard/package.json +6 -3
- package/docker-compose.dev.yml +6 -1
- package/package.json +1 -1
- package/server/dashboard/index.html +1545 -791
- package/server/index.js +64 -0
- package/server/lib/api-provider.js +104 -186
- package/server/lib/api-provider.test.js +238 -336
- package/server/lib/cli-detector.js +90 -166
- package/server/lib/cli-detector.test.js +114 -269
- package/server/lib/cli-provider.js +142 -212
- package/server/lib/cli-provider.test.js +196 -349
- package/server/lib/debug.test.js +1 -1
- package/server/lib/devserver-router-api.js +54 -249
- package/server/lib/devserver-router-api.test.js +126 -426
- package/server/lib/introspect.js +309 -0
- package/server/lib/introspect.test.js +286 -0
- package/server/lib/model-router.js +107 -245
- package/server/lib/model-router.test.js +122 -313
- package/server/lib/output-schemas.js +146 -269
- package/server/lib/output-schemas.test.js +106 -307
- package/server/lib/provider-interface.js +99 -153
- package/server/lib/provider-interface.test.js +228 -394
- package/server/lib/provider-queue.js +164 -158
- package/server/lib/provider-queue.test.js +186 -315
- package/server/lib/router-config.js +99 -221
- package/server/lib/router-config.test.js +83 -237
- package/server/lib/router-setup-command.js +94 -419
- package/server/lib/router-setup-command.test.js +96 -375
- package/server/lib/router-status-api.js +93 -0
- package/server/lib/router-status-api.test.js +270 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment node
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest';
|
|
5
|
+
import { getTasks, parseTasksFromPlan } from './tasks-api.js';
|
|
6
|
+
// Helper to create a mock Dirent
|
|
7
|
+
function createDirent(name, isDir) {
|
|
8
|
+
return {
|
|
9
|
+
name,
|
|
10
|
+
isDirectory: () => isDir,
|
|
11
|
+
isFile: () => !isDir,
|
|
12
|
+
isBlockDevice: () => false,
|
|
13
|
+
isCharacterDevice: () => false,
|
|
14
|
+
isFIFO: () => false,
|
|
15
|
+
isSocket: () => false,
|
|
16
|
+
isSymbolicLink: () => false,
|
|
17
|
+
parentPath: '',
|
|
18
|
+
path: '',
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
// Helper to create a mock file system
|
|
22
|
+
function createMockFs(config) {
|
|
23
|
+
return {
|
|
24
|
+
existsSync: (path) => config.existingPaths?.has(path) ?? false,
|
|
25
|
+
readdir: async (path) => {
|
|
26
|
+
if (config.readdirError)
|
|
27
|
+
throw config.readdirError;
|
|
28
|
+
return config.dirEntries?.get(path) ?? [];
|
|
29
|
+
},
|
|
30
|
+
readFile: async (path) => {
|
|
31
|
+
if (config.readFileError)
|
|
32
|
+
throw config.readFileError;
|
|
33
|
+
const content = config.fileContents?.get(path);
|
|
34
|
+
if (content === undefined)
|
|
35
|
+
throw new Error(`File not found: ${path}`);
|
|
36
|
+
return content;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
describe('tasks-api', () => {
|
|
41
|
+
describe('getTasks', () => {
|
|
42
|
+
it('returns empty array when no PLAN.md exists', async () => {
|
|
43
|
+
const mockFs = createMockFs({
|
|
44
|
+
existingPaths: new Set(), // No paths exist
|
|
45
|
+
});
|
|
46
|
+
const tasks = await getTasks('/project', mockFs);
|
|
47
|
+
expect(tasks).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
it('returns empty array when phases directory is empty', async () => {
|
|
50
|
+
const mockFs = createMockFs({
|
|
51
|
+
existingPaths: new Set(['/project/.planning/phases']),
|
|
52
|
+
dirEntries: new Map([['/project/.planning/phases', []]]),
|
|
53
|
+
});
|
|
54
|
+
const tasks = await getTasks('/project', mockFs);
|
|
55
|
+
expect(tasks).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
it('parses tasks from PLAN.md correctly', async () => {
|
|
58
|
+
const planContent = `# Phase 39: Fix API
|
|
59
|
+
|
|
60
|
+
### Task 1: Create tasks-api.js [ ]
|
|
61
|
+
|
|
62
|
+
Description here.
|
|
63
|
+
|
|
64
|
+
### Task 2: Add tests [ ]
|
|
65
|
+
|
|
66
|
+
More description.
|
|
67
|
+
`;
|
|
68
|
+
const mockFs = createMockFs({
|
|
69
|
+
existingPaths: new Set([
|
|
70
|
+
'/project/.planning/phases',
|
|
71
|
+
'/project/.planning/phases/39-fix-api/39-PLAN.md',
|
|
72
|
+
]),
|
|
73
|
+
dirEntries: new Map([
|
|
74
|
+
['/project/.planning/phases', [createDirent('39-fix-api', true)]],
|
|
75
|
+
]),
|
|
76
|
+
fileContents: new Map([
|
|
77
|
+
['/project/.planning/phases/39-fix-api/39-PLAN.md', planContent],
|
|
78
|
+
]),
|
|
79
|
+
});
|
|
80
|
+
const tasks = await getTasks('/project', mockFs);
|
|
81
|
+
expect(tasks).toHaveLength(2);
|
|
82
|
+
expect(tasks[0]).toEqual({
|
|
83
|
+
id: '39-1',
|
|
84
|
+
title: 'Create tasks-api.js',
|
|
85
|
+
status: 'pending',
|
|
86
|
+
owner: null,
|
|
87
|
+
phase: 39,
|
|
88
|
+
});
|
|
89
|
+
expect(tasks[1]).toEqual({
|
|
90
|
+
id: '39-2',
|
|
91
|
+
title: 'Add tests',
|
|
92
|
+
status: 'pending',
|
|
93
|
+
owner: null,
|
|
94
|
+
phase: 39,
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
it('extracts status from markers', async () => {
|
|
98
|
+
const planContent = `# Phase 1: Setup
|
|
99
|
+
|
|
100
|
+
### Task 1: Pending task [ ]
|
|
101
|
+
|
|
102
|
+
### Task 2: In progress task [>]
|
|
103
|
+
|
|
104
|
+
### Task 3: Completed task [x]
|
|
105
|
+
`;
|
|
106
|
+
const mockFs = createMockFs({
|
|
107
|
+
existingPaths: new Set([
|
|
108
|
+
'/project/.planning/phases',
|
|
109
|
+
'/project/.planning/phases/01-setup/01-PLAN.md',
|
|
110
|
+
]),
|
|
111
|
+
dirEntries: new Map([
|
|
112
|
+
['/project/.planning/phases', [createDirent('01-setup', true)]],
|
|
113
|
+
]),
|
|
114
|
+
fileContents: new Map([
|
|
115
|
+
['/project/.planning/phases/01-setup/01-PLAN.md', planContent],
|
|
116
|
+
]),
|
|
117
|
+
});
|
|
118
|
+
const tasks = await getTasks('/project', mockFs);
|
|
119
|
+
expect(tasks).toHaveLength(3);
|
|
120
|
+
expect(tasks[0].status).toBe('pending');
|
|
121
|
+
expect(tasks[1].status).toBe('in_progress');
|
|
122
|
+
expect(tasks[2].status).toBe('completed');
|
|
123
|
+
});
|
|
124
|
+
it('extracts owner from @username', async () => {
|
|
125
|
+
const planContent = `# Phase 5: Auth
|
|
126
|
+
|
|
127
|
+
### Task 1: No owner [ ]
|
|
128
|
+
|
|
129
|
+
### Task 2: Has owner [>@alice]
|
|
130
|
+
|
|
131
|
+
### Task 3: Completed with owner [x@bob]
|
|
132
|
+
`;
|
|
133
|
+
const mockFs = createMockFs({
|
|
134
|
+
existingPaths: new Set([
|
|
135
|
+
'/project/.planning/phases',
|
|
136
|
+
'/project/.planning/phases/05-auth/05-PLAN.md',
|
|
137
|
+
]),
|
|
138
|
+
dirEntries: new Map([
|
|
139
|
+
['/project/.planning/phases', [createDirent('05-auth', true)]],
|
|
140
|
+
]),
|
|
141
|
+
fileContents: new Map([
|
|
142
|
+
['/project/.planning/phases/05-auth/05-PLAN.md', planContent],
|
|
143
|
+
]),
|
|
144
|
+
});
|
|
145
|
+
const tasks = await getTasks('/project', mockFs);
|
|
146
|
+
expect(tasks).toHaveLength(3);
|
|
147
|
+
expect(tasks[0].owner).toBeNull();
|
|
148
|
+
expect(tasks[1].owner).toBe('alice');
|
|
149
|
+
expect(tasks[2].owner).toBe('bob');
|
|
150
|
+
});
|
|
151
|
+
it('handles multiple phases (scans all *-PLAN.md files)', async () => {
|
|
152
|
+
const phase1Content = `### Task 1: Init [ ]`;
|
|
153
|
+
const phase2Content = `### Task 1: Build API [>@alice]
|
|
154
|
+
### Task 2: Add routes [x]`;
|
|
155
|
+
const phase3Content = `### Task 1: Unit tests [ ]`;
|
|
156
|
+
const mockFs = createMockFs({
|
|
157
|
+
existingPaths: new Set([
|
|
158
|
+
'/project/.planning/phases',
|
|
159
|
+
'/project/.planning/phases/01-setup/01-PLAN.md',
|
|
160
|
+
'/project/.planning/phases/02-core/02-PLAN.md',
|
|
161
|
+
'/project/.planning/phases/03-testing/03-PLAN.md',
|
|
162
|
+
]),
|
|
163
|
+
dirEntries: new Map([
|
|
164
|
+
['/project/.planning/phases', [
|
|
165
|
+
createDirent('01-setup', true),
|
|
166
|
+
createDirent('02-core', true),
|
|
167
|
+
createDirent('03-testing', true),
|
|
168
|
+
]],
|
|
169
|
+
]),
|
|
170
|
+
fileContents: new Map([
|
|
171
|
+
['/project/.planning/phases/01-setup/01-PLAN.md', phase1Content],
|
|
172
|
+
['/project/.planning/phases/02-core/02-PLAN.md', phase2Content],
|
|
173
|
+
['/project/.planning/phases/03-testing/03-PLAN.md', phase3Content],
|
|
174
|
+
]),
|
|
175
|
+
});
|
|
176
|
+
const tasks = await getTasks('/project', mockFs);
|
|
177
|
+
expect(tasks).toHaveLength(4);
|
|
178
|
+
// Check that tasks from different phases have correct phase numbers
|
|
179
|
+
const phase1Tasks = tasks.filter((t) => t.phase === 1);
|
|
180
|
+
const phase2Tasks = tasks.filter((t) => t.phase === 2);
|
|
181
|
+
const phase3Tasks = tasks.filter((t) => t.phase === 3);
|
|
182
|
+
expect(phase1Tasks).toHaveLength(1);
|
|
183
|
+
expect(phase2Tasks).toHaveLength(2);
|
|
184
|
+
expect(phase3Tasks).toHaveLength(1);
|
|
185
|
+
// Verify task IDs include phase
|
|
186
|
+
expect(tasks.map((t) => t.id).sort()).toEqual(['1-1', '2-1', '2-2', '3-1']);
|
|
187
|
+
});
|
|
188
|
+
it('handles malformed PLAN.md gracefully', async () => {
|
|
189
|
+
const planContent = `# Some random content
|
|
190
|
+
|
|
191
|
+
Not a task at all
|
|
192
|
+
|
|
193
|
+
### Task without status marker
|
|
194
|
+
|
|
195
|
+
### Task 1: Valid task [ ]
|
|
196
|
+
|
|
197
|
+
### Task: Missing number [ ]
|
|
198
|
+
|
|
199
|
+
### Task 2 No colon [x]
|
|
200
|
+
|
|
201
|
+
Random text between
|
|
202
|
+
|
|
203
|
+
### Task 3: Another valid [>@dev]
|
|
204
|
+
`;
|
|
205
|
+
const mockFs = createMockFs({
|
|
206
|
+
existingPaths: new Set([
|
|
207
|
+
'/project/.planning/phases',
|
|
208
|
+
'/project/.planning/phases/10-broken/10-PLAN.md',
|
|
209
|
+
]),
|
|
210
|
+
dirEntries: new Map([
|
|
211
|
+
['/project/.planning/phases', [createDirent('10-broken', true)]],
|
|
212
|
+
]),
|
|
213
|
+
fileContents: new Map([
|
|
214
|
+
['/project/.planning/phases/10-broken/10-PLAN.md', planContent],
|
|
215
|
+
]),
|
|
216
|
+
});
|
|
217
|
+
const tasks = await getTasks('/project', mockFs);
|
|
218
|
+
// Should only parse the valid tasks
|
|
219
|
+
expect(tasks).toHaveLength(2);
|
|
220
|
+
expect(tasks[0]).toEqual({
|
|
221
|
+
id: '10-1',
|
|
222
|
+
title: 'Valid task',
|
|
223
|
+
status: 'pending',
|
|
224
|
+
owner: null,
|
|
225
|
+
phase: 10,
|
|
226
|
+
});
|
|
227
|
+
expect(tasks[1]).toEqual({
|
|
228
|
+
id: '10-3',
|
|
229
|
+
title: 'Another valid',
|
|
230
|
+
status: 'in_progress',
|
|
231
|
+
owner: 'dev',
|
|
232
|
+
phase: 10,
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
it('handles tasks without explicit markers (defaults to pending)', async () => {
|
|
236
|
+
const planContent = `# Phase 7: Feature
|
|
237
|
+
|
|
238
|
+
### Task 1: Has marker [x]
|
|
239
|
+
|
|
240
|
+
### Task 2: No marker
|
|
241
|
+
`;
|
|
242
|
+
const mockFs = createMockFs({
|
|
243
|
+
existingPaths: new Set([
|
|
244
|
+
'/project/.planning/phases',
|
|
245
|
+
'/project/.planning/phases/07-feature/07-PLAN.md',
|
|
246
|
+
]),
|
|
247
|
+
dirEntries: new Map([
|
|
248
|
+
['/project/.planning/phases', [createDirent('07-feature', true)]],
|
|
249
|
+
]),
|
|
250
|
+
fileContents: new Map([
|
|
251
|
+
['/project/.planning/phases/07-feature/07-PLAN.md', planContent],
|
|
252
|
+
]),
|
|
253
|
+
});
|
|
254
|
+
const tasks = await getTasks('/project', mockFs);
|
|
255
|
+
// Task 2 without marker should not be parsed as a task
|
|
256
|
+
// Only properly formatted tasks with markers are valid
|
|
257
|
+
expect(tasks).toHaveLength(1);
|
|
258
|
+
expect(tasks[0].title).toBe('Has marker');
|
|
259
|
+
});
|
|
260
|
+
it('handles empty PLAN.md files', async () => {
|
|
261
|
+
const mockFs = createMockFs({
|
|
262
|
+
existingPaths: new Set([
|
|
263
|
+
'/project/.planning/phases',
|
|
264
|
+
'/project/.planning/phases/99-empty/99-PLAN.md',
|
|
265
|
+
]),
|
|
266
|
+
dirEntries: new Map([
|
|
267
|
+
['/project/.planning/phases', [createDirent('99-empty', true)]],
|
|
268
|
+
]),
|
|
269
|
+
fileContents: new Map([
|
|
270
|
+
['/project/.planning/phases/99-empty/99-PLAN.md', ''],
|
|
271
|
+
]),
|
|
272
|
+
});
|
|
273
|
+
const tasks = await getTasks('/project', mockFs);
|
|
274
|
+
expect(tasks).toEqual([]);
|
|
275
|
+
});
|
|
276
|
+
it('ignores non-PLAN.md files in phases directory', async () => {
|
|
277
|
+
const mockFs = createMockFs({
|
|
278
|
+
existingPaths: new Set([
|
|
279
|
+
'/project/.planning/phases',
|
|
280
|
+
'/project/.planning/phases/01-setup/01-PLAN.md',
|
|
281
|
+
// These should not be read (different files, not PLAN.md)
|
|
282
|
+
]),
|
|
283
|
+
dirEntries: new Map([
|
|
284
|
+
['/project/.planning/phases', [createDirent('01-setup', true)]],
|
|
285
|
+
]),
|
|
286
|
+
fileContents: new Map([
|
|
287
|
+
['/project/.planning/phases/01-setup/01-PLAN.md', `### Task 1: Real task [ ]`],
|
|
288
|
+
]),
|
|
289
|
+
});
|
|
290
|
+
const tasks = await getTasks('/project', mockFs);
|
|
291
|
+
expect(tasks).toHaveLength(1);
|
|
292
|
+
expect(tasks[0].title).toBe('Real task');
|
|
293
|
+
});
|
|
294
|
+
it('sorts tasks by phase number then task number', async () => {
|
|
295
|
+
const phase10Content = `### Task 2: Ten-Two [ ]
|
|
296
|
+
### Task 1: Ten-One [ ]`;
|
|
297
|
+
const phase2Content = `### Task 1: Two-One [ ]`;
|
|
298
|
+
const mockFs = createMockFs({
|
|
299
|
+
existingPaths: new Set([
|
|
300
|
+
'/project/.planning/phases',
|
|
301
|
+
'/project/.planning/phases/10-later/10-PLAN.md',
|
|
302
|
+
'/project/.planning/phases/02-early/02-PLAN.md',
|
|
303
|
+
]),
|
|
304
|
+
dirEntries: new Map([
|
|
305
|
+
['/project/.planning/phases', [
|
|
306
|
+
createDirent('10-later', true),
|
|
307
|
+
createDirent('02-early', true),
|
|
308
|
+
]],
|
|
309
|
+
]),
|
|
310
|
+
fileContents: new Map([
|
|
311
|
+
['/project/.planning/phases/10-later/10-PLAN.md', phase10Content],
|
|
312
|
+
['/project/.planning/phases/02-early/02-PLAN.md', phase2Content],
|
|
313
|
+
]),
|
|
314
|
+
});
|
|
315
|
+
const tasks = await getTasks('/project', mockFs);
|
|
316
|
+
expect(tasks.map((t) => t.id)).toEqual(['2-1', '10-1', '10-2']);
|
|
317
|
+
});
|
|
318
|
+
it('handles readdir errors gracefully', async () => {
|
|
319
|
+
const mockFs = createMockFs({
|
|
320
|
+
existingPaths: new Set(['/project/.planning/phases']),
|
|
321
|
+
readdirError: new Error('Permission denied'),
|
|
322
|
+
});
|
|
323
|
+
const tasks = await getTasks('/project', mockFs);
|
|
324
|
+
expect(tasks).toEqual([]);
|
|
325
|
+
});
|
|
326
|
+
it('handles readFile errors gracefully', async () => {
|
|
327
|
+
const mockFs = createMockFs({
|
|
328
|
+
existingPaths: new Set([
|
|
329
|
+
'/project/.planning/phases',
|
|
330
|
+
'/project/.planning/phases/01-setup/01-PLAN.md',
|
|
331
|
+
]),
|
|
332
|
+
dirEntries: new Map([
|
|
333
|
+
['/project/.planning/phases', [createDirent('01-setup', true)]],
|
|
334
|
+
]),
|
|
335
|
+
readFileError: new Error('File not readable'),
|
|
336
|
+
});
|
|
337
|
+
const tasks = await getTasks('/project', mockFs);
|
|
338
|
+
expect(tasks).toEqual([]);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
describe('parseTasksFromPlan', () => {
|
|
342
|
+
it('returns empty array for empty content', () => {
|
|
343
|
+
expect(parseTasksFromPlan('', 1)).toEqual([]);
|
|
344
|
+
});
|
|
345
|
+
it('parses valid task format', () => {
|
|
346
|
+
const content = `### Task 1: My task [ ]`;
|
|
347
|
+
const tasks = parseTasksFromPlan(content, 5);
|
|
348
|
+
expect(tasks).toHaveLength(1);
|
|
349
|
+
expect(tasks[0]).toEqual({
|
|
350
|
+
id: '5-1',
|
|
351
|
+
title: 'My task',
|
|
352
|
+
status: 'pending',
|
|
353
|
+
owner: null,
|
|
354
|
+
phase: 5,
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
it('handles all status markers', () => {
|
|
358
|
+
const content = `### Task 1: Pending [ ]
|
|
359
|
+
### Task 2: In Progress [>]
|
|
360
|
+
### Task 3: Completed [x]`;
|
|
361
|
+
const tasks = parseTasksFromPlan(content, 1);
|
|
362
|
+
expect(tasks[0].status).toBe('pending');
|
|
363
|
+
expect(tasks[1].status).toBe('in_progress');
|
|
364
|
+
expect(tasks[2].status).toBe('completed');
|
|
365
|
+
});
|
|
366
|
+
it('extracts owner from status marker', () => {
|
|
367
|
+
const content = `### Task 1: With owner [>@alice]
|
|
368
|
+
### Task 2: Completed owner [x@bob]`;
|
|
369
|
+
const tasks = parseTasksFromPlan(content, 1);
|
|
370
|
+
expect(tasks[0].owner).toBe('alice');
|
|
371
|
+
expect(tasks[1].owner).toBe('bob');
|
|
372
|
+
});
|
|
373
|
+
it('ignores malformed task lines', () => {
|
|
374
|
+
const content = `### Task: No number [ ]
|
|
375
|
+
### Task 1 No colon [ ]
|
|
376
|
+
### Task 1: No marker
|
|
377
|
+
### Task 1: Valid [ ]`;
|
|
378
|
+
const tasks = parseTasksFromPlan(content, 1);
|
|
379
|
+
expect(tasks).toHaveLength(1);
|
|
380
|
+
expect(tasks[0].title).toBe('Valid');
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type BugSeverity = 'low' | 'medium' | 'high' | 'critical';
|
|
2
|
+
export type BugStatus = 'open' | 'in_progress' | 'fixed' | 'verified' | 'closed' | 'wontfix';
|
|
3
|
+
export interface Bug {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
description: string;
|
|
7
|
+
status: BugStatus;
|
|
8
|
+
priority: BugSeverity;
|
|
9
|
+
reporter?: string;
|
|
10
|
+
assignee?: string;
|
|
11
|
+
issueId?: string;
|
|
12
|
+
createdAt?: string;
|
|
13
|
+
labels?: string[];
|
|
14
|
+
}
|
|
15
|
+
export interface BugsPaneProps {
|
|
16
|
+
isActive?: boolean;
|
|
17
|
+
isTTY?: boolean;
|
|
18
|
+
apiBaseUrl?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function BugsPane({ isActive, isTTY, apiBaseUrl, }: BugsPaneProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect, useMemo } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import Spinner from 'ink-spinner';
|
|
5
|
+
// Severity color mapping
|
|
6
|
+
const severityColors = {
|
|
7
|
+
critical: 'red',
|
|
8
|
+
high: 'yellow',
|
|
9
|
+
medium: 'blue',
|
|
10
|
+
low: 'gray',
|
|
11
|
+
};
|
|
12
|
+
// Status color mapping
|
|
13
|
+
const statusColors = {
|
|
14
|
+
open: 'red',
|
|
15
|
+
in_progress: 'yellow',
|
|
16
|
+
fixed: 'green',
|
|
17
|
+
verified: 'cyan',
|
|
18
|
+
closed: 'gray',
|
|
19
|
+
wontfix: 'magenta',
|
|
20
|
+
};
|
|
21
|
+
export function BugsPane({ isActive = true, isTTY = true, apiBaseUrl = 'http://localhost:5001', }) {
|
|
22
|
+
// Form state
|
|
23
|
+
const [title, setTitle] = useState('');
|
|
24
|
+
const [description, setDescription] = useState('');
|
|
25
|
+
const [severity, setSeverity] = useState('medium');
|
|
26
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
27
|
+
const [submitMessage, setSubmitMessage] = useState(null);
|
|
28
|
+
// List state
|
|
29
|
+
const [bugs, setBugs] = useState([]);
|
|
30
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
31
|
+
const [loadError, setLoadError] = useState(null);
|
|
32
|
+
const [filter, setFilter] = useState('all');
|
|
33
|
+
// Navigation state
|
|
34
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
35
|
+
const [activeSection, setActiveSection] = useState('form');
|
|
36
|
+
const [formField, setFormField] = useState('title');
|
|
37
|
+
// Fetch bugs on mount and after submission
|
|
38
|
+
const fetchBugs = async () => {
|
|
39
|
+
setIsLoading(true);
|
|
40
|
+
setLoadError(null);
|
|
41
|
+
try {
|
|
42
|
+
const response = await fetch(`${apiBaseUrl}/api/bugs`);
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
45
|
+
}
|
|
46
|
+
const data = await response.json();
|
|
47
|
+
setBugs(data);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
setLoadError(err instanceof Error ? err.message : 'Failed to fetch bugs');
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
setIsLoading(false);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
fetchBugs();
|
|
58
|
+
}, [apiBaseUrl]);
|
|
59
|
+
// Filter bugs
|
|
60
|
+
const filteredBugs = useMemo(() => {
|
|
61
|
+
switch (filter) {
|
|
62
|
+
case 'open':
|
|
63
|
+
return bugs.filter(b => b.status === 'open' || b.status === 'in_progress');
|
|
64
|
+
case 'closed':
|
|
65
|
+
return bugs.filter(b => b.status === 'closed' || b.status === 'fixed' || b.status === 'verified' || b.status === 'wontfix');
|
|
66
|
+
default:
|
|
67
|
+
return bugs;
|
|
68
|
+
}
|
|
69
|
+
}, [bugs, filter]);
|
|
70
|
+
// Submit bug
|
|
71
|
+
const submitBug = async () => {
|
|
72
|
+
if (!title.trim()) {
|
|
73
|
+
setSubmitMessage({ type: 'error', text: 'Title is required' });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
setIsSubmitting(true);
|
|
77
|
+
setSubmitMessage(null);
|
|
78
|
+
try {
|
|
79
|
+
const response = await fetch(`${apiBaseUrl}/api/bug`, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { 'Content-Type': 'application/json' },
|
|
82
|
+
body: JSON.stringify({
|
|
83
|
+
description: `${title}\n\n${description}`.trim(),
|
|
84
|
+
severity,
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
const error = await response.json();
|
|
89
|
+
throw new Error(error.error || `HTTP ${response.status}`);
|
|
90
|
+
}
|
|
91
|
+
// Clear form and refresh list
|
|
92
|
+
setTitle('');
|
|
93
|
+
setDescription('');
|
|
94
|
+
setSeverity('medium');
|
|
95
|
+
setSubmitMessage({ type: 'success', text: 'Bug submitted successfully!' });
|
|
96
|
+
await fetchBugs();
|
|
97
|
+
// Clear success message after 3 seconds
|
|
98
|
+
setTimeout(() => setSubmitMessage(null), 3000);
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
setSubmitMessage({
|
|
102
|
+
type: 'error',
|
|
103
|
+
text: err instanceof Error ? err.message : 'Failed to submit bug',
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
setIsSubmitting(false);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
// Keyboard handling
|
|
111
|
+
useInput((input, key) => {
|
|
112
|
+
if (!isActive)
|
|
113
|
+
return;
|
|
114
|
+
// Tab to switch sections
|
|
115
|
+
if (key.tab) {
|
|
116
|
+
if (activeSection === 'form') {
|
|
117
|
+
setActiveSection('filters');
|
|
118
|
+
}
|
|
119
|
+
else if (activeSection === 'filters') {
|
|
120
|
+
setActiveSection('list');
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
setActiveSection('form');
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// Section-specific handling
|
|
128
|
+
if (activeSection === 'form') {
|
|
129
|
+
// Navigate form fields with up/down
|
|
130
|
+
if (key.upArrow) {
|
|
131
|
+
if (formField === 'description')
|
|
132
|
+
setFormField('title');
|
|
133
|
+
else if (formField === 'severity')
|
|
134
|
+
setFormField('description');
|
|
135
|
+
}
|
|
136
|
+
else if (key.downArrow) {
|
|
137
|
+
if (formField === 'title')
|
|
138
|
+
setFormField('description');
|
|
139
|
+
else if (formField === 'description')
|
|
140
|
+
setFormField('severity');
|
|
141
|
+
}
|
|
142
|
+
// Severity selection with left/right
|
|
143
|
+
if (formField === 'severity') {
|
|
144
|
+
const severities = ['low', 'medium', 'high', 'critical'];
|
|
145
|
+
const currentIndex = severities.indexOf(severity);
|
|
146
|
+
if (key.leftArrow && currentIndex > 0) {
|
|
147
|
+
setSeverity(severities[currentIndex - 1]);
|
|
148
|
+
}
|
|
149
|
+
else if (key.rightArrow && currentIndex < severities.length - 1) {
|
|
150
|
+
setSeverity(severities[currentIndex + 1]);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Enter to submit when on severity
|
|
154
|
+
if (key.return && formField === 'severity') {
|
|
155
|
+
submitBug();
|
|
156
|
+
}
|
|
157
|
+
// Type text in title/description fields
|
|
158
|
+
if (formField === 'title' || formField === 'description') {
|
|
159
|
+
if (key.backspace || key.delete) {
|
|
160
|
+
if (formField === 'title') {
|
|
161
|
+
setTitle(prev => prev.slice(0, -1));
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
setDescription(prev => prev.slice(0, -1));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
else if (input && !key.ctrl && !key.meta && input.length === 1) {
|
|
168
|
+
if (formField === 'title') {
|
|
169
|
+
setTitle(prev => prev + input);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
setDescription(prev => prev + input);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
else if (activeSection === 'filters') {
|
|
178
|
+
// Filter selection with 1, 2, 3 or arrow keys
|
|
179
|
+
if (input === '1' || (key.leftArrow && filter !== 'all')) {
|
|
180
|
+
setFilter('all');
|
|
181
|
+
}
|
|
182
|
+
else if (input === '2' || (key.rightArrow && filter === 'all')) {
|
|
183
|
+
setFilter('open');
|
|
184
|
+
}
|
|
185
|
+
else if (input === '3' || (key.rightArrow && filter === 'open')) {
|
|
186
|
+
setFilter('closed');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
else if (activeSection === 'list') {
|
|
190
|
+
// Navigate bug list
|
|
191
|
+
if (key.downArrow || input === 'j') {
|
|
192
|
+
setSelectedIndex(prev => Math.min(prev + 1, filteredBugs.length - 1));
|
|
193
|
+
}
|
|
194
|
+
else if (key.upArrow || input === 'k') {
|
|
195
|
+
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// Refresh with 'r'
|
|
199
|
+
if (input === 'r') {
|
|
200
|
+
fetchBugs();
|
|
201
|
+
}
|
|
202
|
+
}, { isActive: isTTY });
|
|
203
|
+
// Reset selected index when filter changes
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
setSelectedIndex(0);
|
|
206
|
+
}, [filter]);
|
|
207
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { flexDirection: "column", borderStyle: activeSection === 'form' ? 'double' : 'single', borderColor: activeSection === 'form' ? 'cyan' : 'gray', padding: 1, marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Submit New Bug" }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: formField === 'title' && activeSection === 'form' ? 'cyan' : 'white', children: "Title:" }), _jsxs(Text, { children: [title || (formField === 'title' && activeSection === 'form' ? '_' : ''), formField === 'title' && activeSection === 'form' && _jsx(Text, { color: "cyan", children: "|" })] })] }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: formField === 'description' && activeSection === 'form' ? 'cyan' : 'white', children: "Description:" }), _jsxs(Text, { children: [description || (formField === 'description' && activeSection === 'form' ? '_' : ''), formField === 'description' && activeSection === 'form' && _jsx(Text, { color: "cyan", children: "|" })] })] }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: formField === 'severity' && activeSection === 'form' ? 'cyan' : 'white', children: "Severity:" }), ['low', 'medium', 'high', 'critical'].map((s) => (_jsx(Box, { marginLeft: 1, children: _jsx(Text, { backgroundColor: s === severity ? severityColors[s] : undefined, color: s === severity ? 'white' : severityColors[s], bold: s === severity, children: s === severity ? `[${s.toUpperCase()}]` : s }) }, s)))] }), _jsx(Box, { marginTop: 1, children: isSubmitting ? (_jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), " Submitting..."] })) : submitMessage ? (_jsx(Text, { color: submitMessage.type === 'success' ? 'green' : 'red', children: submitMessage.text })) : (_jsx(Text, { dimColor: true, children: formField === 'severity' ? 'Press Enter to submit' : 'Navigate with arrows, Tab to filters' })) })] }), _jsxs(Box, { borderStyle: activeSection === 'filters' ? 'double' : 'single', borderColor: activeSection === 'filters' ? 'cyan' : 'gray', paddingX: 1, marginBottom: 1, children: [_jsx(Text, { children: "Filters: " }), ['all', 'open', 'closed'].map((f, i) => (_jsxs(Box, { marginLeft: 1, children: [_jsxs(Text, { dimColor: true, children: ["[", i + 1, "] "] }), _jsx(Text, { color: f === filter ? 'cyan' : 'white', bold: f === filter, underline: f === filter, children: f.charAt(0).toUpperCase() + f.slice(1) })] }, f))), _jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["(", filteredBugs.length, " bugs)"] }) })] }), _jsxs(Box, { flexDirection: "column", borderStyle: activeSection === 'list' ? 'double' : 'single', borderColor: activeSection === 'list' ? 'cyan' : 'gray', padding: 1, flexGrow: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Bug List" }), isLoading ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), " Loading bugs..."] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "--- Loading... ---" }), _jsx(Text, { dimColor: true, children: "--- Loading... ---" }), _jsx(Text, { dimColor: true, children: "--- Loading... ---" })] })] })) : loadError ? (_jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "red", children: ["Error: ", loadError] }), _jsx(Text, { dimColor: true, children: " (press r to retry)" })] })) : filteredBugs.length === 0 ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "No bugs found" }), _jsx(Text, { dimColor: true, children: filter === 'all'
|
|
208
|
+
? 'Submit a bug above or wait for issues to appear'
|
|
209
|
+
: `No ${filter} bugs - try changing the filter` })] })) : (_jsx(Box, { flexDirection: "column", marginTop: 1, children: filteredBugs.map((bug, index) => (_jsxs(Box, { flexDirection: "row", paddingX: 1, marginBottom: 1, borderStyle: selectedIndex === index && activeSection === 'list' ? 'single' : undefined, borderColor: selectedIndex === index && activeSection === 'list' ? 'cyan' : undefined, children: [_jsx(Box, { width: 10, children: _jsx(Text, { color: "gray", children: bug.id }) }), _jsx(Box, { width: 10, children: _jsx(Text, { backgroundColor: severityColors[bug.priority] || 'gray', color: "white", children: ` ${(bug.priority || 'medium').toUpperCase()} ` }) }), _jsx(Box, { width: 12, children: _jsxs(Text, { color: statusColors[bug.status] || 'gray', children: ["[", bug.status, "]"] }) }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: bug.title || bug.description?.split('\n')[0] || 'Untitled' }) }), bug.createdAt && (_jsx(Box, { width: 12, children: _jsx(Text, { dimColor: true, children: bug.createdAt }) }))] }, bug.id))) }))] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Tab: section | Arrows: navigate | r: refresh | Enter: submit" }) })] }));
|
|
210
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|