claude-plan-viewer 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.html +2 -2
- package/index.ts +220 -43
- package/package.json +13 -3
- package/src/client/App.tsx +173 -0
- package/src/client/components/DetailOverlay.tsx +88 -0
- package/src/client/components/DetailPanel.tsx +84 -0
- package/src/client/components/Header.tsx +55 -0
- package/src/client/components/HelpModal.tsx +58 -0
- package/src/client/components/Markdown.tsx +16 -0
- package/src/client/components/PlanRow.tsx +53 -0
- package/src/client/components/PlansTable.tsx +94 -0
- package/src/client/components/ProjectFilter.tsx +180 -0
- package/src/client/components/SearchInput.tsx +42 -0
- package/src/client/components/index.ts +10 -0
- package/src/client/hooks/index.ts +3 -0
- package/src/client/hooks/useDebounce.ts +17 -0
- package/src/client/hooks/useFilters.ts +78 -0
- package/src/client/hooks/useKeyboard.ts +114 -0
- package/src/client/hooks/usePlans.ts +169 -0
- package/src/client/hooks/useProjects.ts +21 -0
- package/src/client/index.tsx +11 -0
- package/src/client/types.ts +19 -0
- package/src/client/utils/api.ts +38 -0
- package/src/client/utils/formatters.ts +40 -0
- package/src/client/utils/index.ts +15 -0
- package/src/client/utils/markdown.ts +123 -0
- package/src/client/utils/strings.ts +18 -0
- package/styles.css +304 -120
- package/frontend.ts +0 -843
package/index.html
CHANGED
package/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { readdir, stat, watch } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import index from "./index.html";
|
|
@@ -8,16 +8,54 @@ import prismBundlePath from "./prism.bundle.js" with { type: "file" };
|
|
|
8
8
|
const PLANS_DIR = join(homedir(), ".claude", "plans");
|
|
9
9
|
const PROJECTS_DIR = join(homedir(), ".claude", "projects");
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
11
|
+
interface CliArgs {
|
|
12
|
+
port?: number;
|
|
13
|
+
json?: boolean;
|
|
14
|
+
output?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseCliArgs(): CliArgs {
|
|
18
|
+
const args: CliArgs = {};
|
|
19
|
+
const argv = process.argv.slice(2);
|
|
20
|
+
|
|
21
|
+
for (let i = 0; i < argv.length; i++) {
|
|
22
|
+
const arg = argv[i];
|
|
23
|
+
const nextArg = argv[i + 1];
|
|
24
|
+
|
|
25
|
+
if (arg === "--port" || arg === "-p") {
|
|
26
|
+
if (nextArg && !nextArg.startsWith("-")) {
|
|
27
|
+
args.port = parseInt(nextArg, 10);
|
|
28
|
+
i++;
|
|
29
|
+
}
|
|
30
|
+
} else if (arg === "--json" || arg === "-j") {
|
|
31
|
+
args.json = true;
|
|
32
|
+
} else if (arg === "--output" || arg === "-o") {
|
|
33
|
+
if (nextArg && !nextArg.startsWith("-")) {
|
|
34
|
+
args.output = nextArg;
|
|
35
|
+
i++;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return args;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function exportPlansAsJson(outputPath?: string): Promise<void> {
|
|
44
|
+
const plans = await loadPlans();
|
|
45
|
+
|
|
46
|
+
const plansWithContent = plans.map((plan) => ({
|
|
47
|
+
...plan,
|
|
48
|
+
content: contentCache.get(plan.filename) || "",
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
const jsonOutput = JSON.stringify(plansWithContent, null, 2);
|
|
52
|
+
|
|
53
|
+
if (outputPath) {
|
|
54
|
+
await Bun.write(outputPath, jsonOutput);
|
|
55
|
+
console.log(`Exported ${plans.length} plans to ${outputPath}`);
|
|
56
|
+
} else {
|
|
57
|
+
console.log(jsonOutput);
|
|
19
58
|
}
|
|
20
|
-
return undefined;
|
|
21
59
|
}
|
|
22
60
|
|
|
23
61
|
// Find an available port starting from the requested port
|
|
@@ -52,17 +90,21 @@ async function openInEditor(filepath: string): Promise<void> {
|
|
|
52
90
|
}
|
|
53
91
|
}
|
|
54
92
|
|
|
55
|
-
interface
|
|
93
|
+
interface PlanMetadata {
|
|
56
94
|
filename: string;
|
|
57
95
|
filepath: string;
|
|
58
96
|
title: string;
|
|
59
|
-
content: string;
|
|
60
97
|
size: number;
|
|
61
98
|
modified: string;
|
|
62
99
|
created: string;
|
|
63
100
|
lineCount: number;
|
|
64
101
|
wordCount: number;
|
|
65
102
|
project: string | null;
|
|
103
|
+
sessionId: string | null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface Plan extends PlanMetadata {
|
|
107
|
+
content: string;
|
|
66
108
|
}
|
|
67
109
|
|
|
68
110
|
// Extract project name from a full path (cross-platform)
|
|
@@ -83,27 +125,56 @@ function extractProjectName(cwd: string): string {
|
|
|
83
125
|
function extractCwdFromJsonl(content: string): string | null {
|
|
84
126
|
if (!content) return null;
|
|
85
127
|
const match = content.match(/"cwd":"([^"]+)"/);
|
|
86
|
-
if (!match) return null;
|
|
128
|
+
if (!match || !match[1]) return null;
|
|
87
129
|
// Unescape JSON string (convert \\\\ to \\)
|
|
88
130
|
return match[1].replace(/\\\\/g, "\\");
|
|
89
131
|
}
|
|
90
132
|
|
|
91
|
-
// Extract
|
|
133
|
+
// Extract slug -> sessionId mapping from JSONL content
|
|
134
|
+
// Each line in JSONL may contain both "slug" and "sessionId" fields
|
|
135
|
+
function extractSlugSessionMap(content: string): Map<string, string> {
|
|
136
|
+
if (!content) return new Map();
|
|
137
|
+
const slugSessionMap = new Map<string, string>();
|
|
138
|
+
|
|
139
|
+
// Process each line to find slug and sessionId pairs
|
|
140
|
+
const lines = content.split("\n");
|
|
141
|
+
for (const line of lines) {
|
|
142
|
+
if (!line.trim()) continue;
|
|
143
|
+
|
|
144
|
+
const slugMatch = line.match(/"slug":"([\w-]+)"/);
|
|
145
|
+
const sessionMatch = line.match(/"sessionId":"([^"]+)"/);
|
|
146
|
+
|
|
147
|
+
if (slugMatch && slugMatch[1] && sessionMatch && sessionMatch[1]) {
|
|
148
|
+
slugSessionMap.set(slugMatch[1], sessionMatch[1]);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return slugSessionMap;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Extract unique slugs from JSONL content (for backwards compatibility)
|
|
92
156
|
function extractSlugsFromJsonl(content: string): string[] {
|
|
93
157
|
if (!content) return [];
|
|
94
158
|
const slugs = new Set<string>();
|
|
95
159
|
const matches = content.matchAll(/"slug":"([\w-]+)"/g);
|
|
96
160
|
for (const match of matches) {
|
|
97
|
-
|
|
161
|
+
if (match[1]) {
|
|
162
|
+
slugs.add(match[1]);
|
|
163
|
+
}
|
|
98
164
|
}
|
|
99
165
|
return Array.from(slugs);
|
|
100
166
|
}
|
|
101
167
|
|
|
168
|
+
interface SlugMetadata {
|
|
169
|
+
project: string;
|
|
170
|
+
sessionId: string | null;
|
|
171
|
+
}
|
|
172
|
+
|
|
102
173
|
interface ProjectMapping {
|
|
103
|
-
[slug: string]:
|
|
174
|
+
[slug: string]: SlugMetadata;
|
|
104
175
|
}
|
|
105
176
|
|
|
106
|
-
// Build a mapping of plan slugs to project names by scanning Claude Code's project metadata
|
|
177
|
+
// Build a mapping of plan slugs to project names and session IDs by scanning Claude Code's project metadata
|
|
107
178
|
async function buildProjectMapping(): Promise<ProjectMapping> {
|
|
108
179
|
const mapping: ProjectMapping = {};
|
|
109
180
|
|
|
@@ -115,12 +186,12 @@ async function buildProjectMapping(): Promise<ProjectMapping> {
|
|
|
115
186
|
const dirStats = await stat(dirPath);
|
|
116
187
|
if (!dirStats.isDirectory()) continue;
|
|
117
188
|
|
|
118
|
-
// Find JSONL files and extract cwd + slugs
|
|
189
|
+
// Find JSONL files and extract cwd + slugs + sessionIds
|
|
119
190
|
const files = await readdir(dirPath);
|
|
120
191
|
const jsonlFiles = files.filter((f) => f.endsWith(".jsonl"));
|
|
121
192
|
|
|
122
193
|
let projectName: string | null = null;
|
|
123
|
-
const
|
|
194
|
+
const slugSessionMap = new Map<string, string>();
|
|
124
195
|
|
|
125
196
|
for (const file of jsonlFiles) {
|
|
126
197
|
try {
|
|
@@ -134,18 +205,23 @@ async function buildProjectMapping(): Promise<ProjectMapping> {
|
|
|
134
205
|
}
|
|
135
206
|
}
|
|
136
207
|
|
|
137
|
-
// Collect
|
|
138
|
-
const
|
|
139
|
-
|
|
208
|
+
// Collect slug -> sessionId mappings
|
|
209
|
+
const fileSlugSessions = extractSlugSessionMap(content);
|
|
210
|
+
for (const [slug, sessionId] of fileSlugSessions) {
|
|
211
|
+
slugSessionMap.set(slug, sessionId);
|
|
212
|
+
}
|
|
140
213
|
} catch {
|
|
141
214
|
// Skip files that can't be read
|
|
142
215
|
}
|
|
143
216
|
}
|
|
144
217
|
|
|
145
|
-
// Map all slugs to this project
|
|
218
|
+
// Map all slugs to this project with their session IDs
|
|
146
219
|
if (projectName) {
|
|
147
|
-
for (const slug of
|
|
148
|
-
mapping[slug] =
|
|
220
|
+
for (const [slug, sessionId] of slugSessionMap) {
|
|
221
|
+
mapping[slug] = {
|
|
222
|
+
project: projectName,
|
|
223
|
+
sessionId: sessionId,
|
|
224
|
+
};
|
|
149
225
|
}
|
|
150
226
|
}
|
|
151
227
|
}
|
|
@@ -156,9 +232,19 @@ async function buildProjectMapping(): Promise<ProjectMapping> {
|
|
|
156
232
|
return mapping;
|
|
157
233
|
}
|
|
158
234
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
235
|
+
let cachedPlans: PlanMetadata[] | null = null;
|
|
236
|
+
let cachedProjectMapping: ProjectMapping | null = null;
|
|
237
|
+
const contentCache = new Map<string, string>();
|
|
238
|
+
|
|
239
|
+
async function loadPlans(): Promise<PlanMetadata[]> {
|
|
240
|
+
// Build or use cached project mapping
|
|
241
|
+
let projectMapping: ProjectMapping;
|
|
242
|
+
if (!cachedProjectMapping) {
|
|
243
|
+
projectMapping = await buildProjectMapping();
|
|
244
|
+
cachedProjectMapping = projectMapping;
|
|
245
|
+
} else {
|
|
246
|
+
projectMapping = cachedProjectMapping;
|
|
247
|
+
}
|
|
162
248
|
|
|
163
249
|
const files = await readdir(PLANS_DIR);
|
|
164
250
|
const mdFiles = files.filter((f) => f.endsWith(".md"));
|
|
@@ -167,6 +253,7 @@ async function loadPlans(): Promise<Plan[]> {
|
|
|
167
253
|
mdFiles.map(async (filename) => {
|
|
168
254
|
const filepath = join(PLANS_DIR, filename);
|
|
169
255
|
const file = Bun.file(filepath);
|
|
256
|
+
|
|
170
257
|
const [content, stats] = await Promise.all([
|
|
171
258
|
file.text(),
|
|
172
259
|
stat(filepath),
|
|
@@ -179,42 +266,120 @@ async function loadPlans(): Promise<Plan[]> {
|
|
|
179
266
|
|
|
180
267
|
// Look up project from metadata using plan slug (filename without .md)
|
|
181
268
|
const slug = filename.replace(".md", "");
|
|
269
|
+
const lineCount = content.split("\n").length;
|
|
270
|
+
const wordCount = content.split(/\s+/).filter(Boolean).length;
|
|
271
|
+
|
|
272
|
+
const metadata = projectMapping[slug];
|
|
273
|
+
// Cache content separately for search and lazy loading
|
|
274
|
+
contentCache.set(filename, content);
|
|
182
275
|
|
|
183
276
|
return {
|
|
184
277
|
filename,
|
|
185
278
|
filepath,
|
|
186
279
|
title,
|
|
187
|
-
content,
|
|
188
280
|
size: stats.size,
|
|
189
281
|
modified: stats.mtime.toISOString(),
|
|
190
282
|
created: stats.birthtime.toISOString(),
|
|
191
|
-
lineCount
|
|
192
|
-
wordCount
|
|
193
|
-
project:
|
|
283
|
+
lineCount,
|
|
284
|
+
wordCount,
|
|
285
|
+
project: metadata?.project || null,
|
|
286
|
+
sessionId: metadata?.sessionId || null,
|
|
194
287
|
};
|
|
195
288
|
})
|
|
196
289
|
);
|
|
197
290
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
291
|
+
cachedPlans = plans;
|
|
292
|
+
return plans;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function invalidateCache() {
|
|
296
|
+
cachedPlans = null;
|
|
297
|
+
cachedProjectMapping = null;
|
|
298
|
+
contentCache.clear();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Watch plans directory for changes and invalidate cache
|
|
302
|
+
async function watchPlansDirectory() {
|
|
303
|
+
try {
|
|
304
|
+
const watcher = watch(PLANS_DIR);
|
|
305
|
+
for await (const event of watcher) {
|
|
306
|
+
if (event.filename?.endsWith(".md")) {
|
|
307
|
+
invalidateCache();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
} catch {
|
|
311
|
+
// Directory may not exist or watching may not be supported
|
|
312
|
+
}
|
|
201
313
|
}
|
|
202
314
|
|
|
203
315
|
// Main server startup
|
|
204
316
|
async function startServer() {
|
|
205
|
-
const
|
|
206
|
-
const port = await findAvailablePort(
|
|
317
|
+
const args = parseCliArgs();
|
|
318
|
+
const port = await findAvailablePort(args.port ?? 3000);
|
|
207
319
|
|
|
208
320
|
const server = Bun.serve({
|
|
209
321
|
port,
|
|
210
|
-
static: {
|
|
211
|
-
"/prism.bundle.js": Bun.file(prismBundlePath),
|
|
212
|
-
},
|
|
213
322
|
routes: {
|
|
214
323
|
"/": index,
|
|
215
|
-
"/api/
|
|
216
|
-
|
|
217
|
-
|
|
324
|
+
"/api/projects": async () => {
|
|
325
|
+
// Lazy load cache on first request
|
|
326
|
+
if (!cachedPlans) {
|
|
327
|
+
await loadPlans();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const plans = cachedPlans || [];
|
|
331
|
+
const projects = [...new Set(plans.map(p => p.project).filter(Boolean))] as string[];
|
|
332
|
+
projects.sort((a, b) => a.localeCompare(b));
|
|
333
|
+
|
|
334
|
+
return Response.json({ projects });
|
|
335
|
+
},
|
|
336
|
+
"/api/plans": async (req) => {
|
|
337
|
+
// Lazy load cache on first request
|
|
338
|
+
if (!cachedPlans) {
|
|
339
|
+
await loadPlans();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const plans = cachedPlans || [];
|
|
343
|
+
|
|
344
|
+
// Strip content from response - will be fetched separately via /api/plans/{id}/content
|
|
345
|
+
const plansWithoutContent = plans.map(p => ({
|
|
346
|
+
filename: p.filename,
|
|
347
|
+
filepath: p.filepath,
|
|
348
|
+
title: p.title,
|
|
349
|
+
size: p.size,
|
|
350
|
+
modified: p.modified,
|
|
351
|
+
created: p.created,
|
|
352
|
+
lineCount: p.lineCount,
|
|
353
|
+
wordCount: p.wordCount,
|
|
354
|
+
project: p.project,
|
|
355
|
+
sessionId: p.sessionId,
|
|
356
|
+
}));
|
|
357
|
+
|
|
358
|
+
return Response.json({
|
|
359
|
+
plans: plansWithoutContent,
|
|
360
|
+
});
|
|
361
|
+
},
|
|
362
|
+
"/api/plans/:filename/content": async (req) => {
|
|
363
|
+
// Lazy load cache on first request
|
|
364
|
+
if (!cachedPlans) {
|
|
365
|
+
await loadPlans();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const filename = req.params.filename as string;
|
|
369
|
+
const content = contentCache.get(filename);
|
|
370
|
+
|
|
371
|
+
if (content === undefined) {
|
|
372
|
+
return new Response("Plan not found", { status: 404 });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return Response.json({ content });
|
|
376
|
+
},
|
|
377
|
+
"/api/refresh": {
|
|
378
|
+
POST: async () => {
|
|
379
|
+
invalidateCache();
|
|
380
|
+
await loadPlans();
|
|
381
|
+
return Response.json({ success: true });
|
|
382
|
+
},
|
|
218
383
|
},
|
|
219
384
|
"/api/open": {
|
|
220
385
|
POST: async (req) => {
|
|
@@ -253,12 +418,24 @@ const c = {
|
|
|
253
418
|
|
|
254
419
|
// Main entry point
|
|
255
420
|
(async () => {
|
|
421
|
+
const args = parseCliArgs();
|
|
422
|
+
|
|
423
|
+
if (args.json) {
|
|
424
|
+
await exportPlansAsJson(args.output);
|
|
425
|
+
process.exit(0);
|
|
426
|
+
}
|
|
427
|
+
|
|
256
428
|
const server = await startServer();
|
|
257
429
|
const planCount = (await readdir(PLANS_DIR)).filter(f => f.endsWith('.md')).length;
|
|
430
|
+
|
|
431
|
+
// Start watching for file changes (runs in background)
|
|
432
|
+
watchPlansDirectory();
|
|
433
|
+
|
|
258
434
|
console.log();
|
|
259
435
|
console.log(`${c.bold}${c.magenta} 📋 Plans Viewer${c.reset}`);
|
|
260
436
|
console.log(`${c.dim} ─────────────────────────────${c.reset}`);
|
|
261
437
|
console.log(`${c.green} ✓${c.reset} Server running`);
|
|
438
|
+
console.log(`${c.green} ✓${c.reset} Watching for file changes`);
|
|
262
439
|
console.log();
|
|
263
440
|
console.log(`${c.dim} Local:${c.reset} ${c.cyan}${c.bold}http://localhost:${server.port}${c.reset}`);
|
|
264
441
|
console.log(`${c.dim} Plans:${c.reset} ${c.yellow}${planCount} plans${c.reset} in ${c.dim}${PLANS_DIR}${c.reset}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-plan-viewer",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "A web-based viewer for Claude Code plan files",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,13 +9,15 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"index.ts",
|
|
11
11
|
"index.html",
|
|
12
|
-
"
|
|
12
|
+
"src",
|
|
13
13
|
"styles.css",
|
|
14
14
|
"prism.bundle.js"
|
|
15
15
|
],
|
|
16
16
|
"scripts": {
|
|
17
17
|
"start": "bun index.ts",
|
|
18
18
|
"dev": "bun --hot index.ts",
|
|
19
|
+
"test": "bun test index.test.ts frontend.test.ts",
|
|
20
|
+
"test:e2e": "bunx playwright test",
|
|
19
21
|
"build": "bun build --compile --minify --bytecode ./index.ts --outfile ./dist/plans-viewer",
|
|
20
22
|
"build:macos-arm64": "bun build --compile --target=bun-darwin-arm64 --minify --bytecode ./index.ts --outfile ./dist/plans-viewer-macos-arm64",
|
|
21
23
|
"build:macos-x64": "bun build --compile --target=bun-darwin-x64 --minify --bytecode ./index.ts --outfile ./dist/plans-viewer-macos-x64",
|
|
@@ -45,9 +47,17 @@
|
|
|
45
47
|
"bun": ">=1.0.0"
|
|
46
48
|
},
|
|
47
49
|
"devDependencies": {
|
|
48
|
-
"@
|
|
50
|
+
"@playwright/test": "^1.57.0",
|
|
51
|
+
"@types/bun": "latest",
|
|
52
|
+
"@types/react": "^19.2.7",
|
|
53
|
+
"@types/react-dom": "^19.2.3"
|
|
49
54
|
},
|
|
50
55
|
"peerDependencies": {
|
|
51
56
|
"typescript": "^5.9.3"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"react": "^19.2.3",
|
|
60
|
+
"react-dom": "^19.2.3",
|
|
61
|
+
"react-select": "^5.10.2"
|
|
52
62
|
}
|
|
53
63
|
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { useState, useCallback, useEffect, useMemo } from "react";
|
|
2
|
+
import type { Plan } from "./types.ts";
|
|
3
|
+
import { usePlans } from "./hooks/usePlans.ts";
|
|
4
|
+
import { useProjects } from "./hooks/useProjects.ts";
|
|
5
|
+
import { useFilters } from "./hooks/useFilters.ts";
|
|
6
|
+
import { useDebounce } from "./hooks/useDebounce.ts";
|
|
7
|
+
import { useKeyboard } from "./hooks/useKeyboard.ts";
|
|
8
|
+
import { openInEditor } from "./utils/api.ts";
|
|
9
|
+
import { Header } from "./components/Header.tsx";
|
|
10
|
+
import { PlansTable } from "./components/PlansTable.tsx";
|
|
11
|
+
import { DetailPanel } from "./components/DetailPanel.tsx";
|
|
12
|
+
import { DetailOverlay } from "./components/DetailOverlay.tsx";
|
|
13
|
+
import { HelpModal } from "./components/HelpModal.tsx";
|
|
14
|
+
|
|
15
|
+
export function App() {
|
|
16
|
+
const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null);
|
|
17
|
+
const [showOverlay, setShowOverlay] = useState(false);
|
|
18
|
+
const [showHelp, setShowHelp] = useState(false);
|
|
19
|
+
|
|
20
|
+
// Filter state
|
|
21
|
+
const {
|
|
22
|
+
searchQuery,
|
|
23
|
+
setSearchQuery,
|
|
24
|
+
sortKey,
|
|
25
|
+
sortDir,
|
|
26
|
+
setSort,
|
|
27
|
+
selectedProjects,
|
|
28
|
+
toggleProject,
|
|
29
|
+
clearProjects,
|
|
30
|
+
} = useFilters();
|
|
31
|
+
|
|
32
|
+
// Debounce search to avoid hitting API on every keystroke
|
|
33
|
+
const debouncedSearch = useDebounce(searchQuery, 300);
|
|
34
|
+
|
|
35
|
+
// Convert Set to array for API
|
|
36
|
+
const projectsArray = useMemo(
|
|
37
|
+
() => Array.from(selectedProjects),
|
|
38
|
+
[selectedProjects]
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// Fetch plans with server-side filtering
|
|
42
|
+
const { plans, loading, refresh, ensureContent } = usePlans({
|
|
43
|
+
q: debouncedSearch,
|
|
44
|
+
sort: sortKey,
|
|
45
|
+
dir: sortDir,
|
|
46
|
+
projects: projectsArray,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Fetch projects list
|
|
50
|
+
const { projects: allProjects } = useProjects();
|
|
51
|
+
|
|
52
|
+
// Load content when plan is selected
|
|
53
|
+
const handleSelectPlan = useCallback(
|
|
54
|
+
async (plan: Plan | null) => {
|
|
55
|
+
if (plan) {
|
|
56
|
+
const withContent = await ensureContent(plan);
|
|
57
|
+
setSelectedPlan(withContent);
|
|
58
|
+
} else {
|
|
59
|
+
setSelectedPlan(null);
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
[ensureContent]
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// Open in editor
|
|
66
|
+
const handleOpenEditor = useCallback(async () => {
|
|
67
|
+
if (selectedPlan) {
|
|
68
|
+
await openInEditor(selectedPlan.filepath);
|
|
69
|
+
}
|
|
70
|
+
}, [selectedPlan]);
|
|
71
|
+
|
|
72
|
+
// Copy session ID
|
|
73
|
+
const handleCopySession = useCallback((sessionId: string) => {
|
|
74
|
+
navigator.clipboard.writeText(`claude --resume ${sessionId}`);
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
// Clear search
|
|
78
|
+
const handleClearSearch = useCallback(() => {
|
|
79
|
+
setSearchQuery("");
|
|
80
|
+
const searchEl = document.getElementById("search") as HTMLInputElement;
|
|
81
|
+
searchEl?.blur();
|
|
82
|
+
}, [setSearchQuery]);
|
|
83
|
+
|
|
84
|
+
// Keyboard shortcuts
|
|
85
|
+
useKeyboard({
|
|
86
|
+
plans,
|
|
87
|
+
selectedPlan,
|
|
88
|
+
onSelectPlan: handleSelectPlan,
|
|
89
|
+
onOpenEditor: handleOpenEditor,
|
|
90
|
+
onToggleHelp: () => setShowHelp((prev) => !prev),
|
|
91
|
+
onToggleOverlay: () => setShowOverlay((prev) => !prev),
|
|
92
|
+
onClearSearch: handleClearSearch,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Auto-select first plan when plans change
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!selectedPlan && plans.length > 0) {
|
|
98
|
+
const firstPlan = plans[0];
|
|
99
|
+
if (firstPlan) {
|
|
100
|
+
handleSelectPlan(firstPlan);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}, [plans, selectedPlan, handleSelectPlan]);
|
|
104
|
+
|
|
105
|
+
// Clear selection if selected plan is no longer in results
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (selectedPlan && !plans.find((p) => p.filename === selectedPlan.filename)) {
|
|
108
|
+
setSelectedPlan(null);
|
|
109
|
+
}
|
|
110
|
+
}, [plans, selectedPlan]);
|
|
111
|
+
|
|
112
|
+
if (loading && plans.length === 0) {
|
|
113
|
+
return (
|
|
114
|
+
<div className="container">
|
|
115
|
+
<div className="loading-container">
|
|
116
|
+
<div className="loading">Loading plans...</div>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div className="container">
|
|
124
|
+
<div id="list-panel" className="list-panel">
|
|
125
|
+
<Header
|
|
126
|
+
searchQuery={searchQuery}
|
|
127
|
+
onSearchChange={setSearchQuery}
|
|
128
|
+
projects={allProjects}
|
|
129
|
+
selectedProjects={selectedProjects}
|
|
130
|
+
onToggleProject={toggleProject}
|
|
131
|
+
onClearProjects={clearProjects}
|
|
132
|
+
onRefresh={refresh}
|
|
133
|
+
/>
|
|
134
|
+
|
|
135
|
+
{plans.length === 0 ? (
|
|
136
|
+
<div className="empty-state">
|
|
137
|
+
{searchQuery || selectedProjects.size > 0
|
|
138
|
+
? "No plans match your filters"
|
|
139
|
+
: "No plans found"}
|
|
140
|
+
</div>
|
|
141
|
+
) : (
|
|
142
|
+
<PlansTable
|
|
143
|
+
plans={plans}
|
|
144
|
+
selectedPlan={selectedPlan}
|
|
145
|
+
searchQuery={searchQuery}
|
|
146
|
+
sortKey={sortKey}
|
|
147
|
+
sortDir={sortDir}
|
|
148
|
+
onSelectPlan={handleSelectPlan}
|
|
149
|
+
onSort={setSort}
|
|
150
|
+
/>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<DetailPanel
|
|
155
|
+
plan={selectedPlan}
|
|
156
|
+
onOpenEditor={handleOpenEditor}
|
|
157
|
+
onToggleOverlay={() => setShowOverlay(true)}
|
|
158
|
+
onCopySession={handleCopySession}
|
|
159
|
+
/>
|
|
160
|
+
|
|
161
|
+
{showOverlay && selectedPlan && (
|
|
162
|
+
<DetailOverlay
|
|
163
|
+
plan={selectedPlan}
|
|
164
|
+
onClose={() => setShowOverlay(false)}
|
|
165
|
+
onOpenEditor={handleOpenEditor}
|
|
166
|
+
onCopySession={handleCopySession}
|
|
167
|
+
/>
|
|
168
|
+
)}
|
|
169
|
+
|
|
170
|
+
{showHelp && <HelpModal onClose={() => setShowHelp(false)} />}
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|