@poulles/worktree-dashboard 0.1.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.
@@ -0,0 +1,1019 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Worktree Dashboard</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ :root {
11
+ --bg: #0d1117;
12
+ --surface: #161b22;
13
+ --border: #30363d;
14
+ --text: #c9d1d9;
15
+ --muted: #8b949e;
16
+ --accent: #58a6ff;
17
+ --green: #3fb950;
18
+ --blue: #58a6ff;
19
+ --amber: #d29922;
20
+ --gray: #6e7681;
21
+ --red: #f85149;
22
+ --font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
23
+ }
24
+
25
+ body {
26
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
27
+ background: var(--bg);
28
+ color: var(--text);
29
+ min-height: 100vh;
30
+ font-size: 14px;
31
+ line-height: 1.5;
32
+ }
33
+
34
+ /* ── Header ──────────────────────────────────────────────── */
35
+ .header {
36
+ position: sticky;
37
+ top: 0;
38
+ z-index: 100;
39
+ background: var(--surface);
40
+ border-bottom: 1px solid var(--border);
41
+ padding: 12px 20px;
42
+ display: flex;
43
+ align-items: center;
44
+ justify-content: space-between;
45
+ gap: 12px;
46
+ }
47
+
48
+ .header-left {
49
+ display: flex;
50
+ align-items: center;
51
+ gap: 12px;
52
+ }
53
+
54
+ .header-logo {
55
+ height: 32px;
56
+ width: auto;
57
+ object-fit: contain;
58
+ border-radius: 4px;
59
+ }
60
+
61
+ .header-title {
62
+ font-size: 16px;
63
+ font-weight: 600;
64
+ color: var(--text);
65
+ }
66
+
67
+ .header-right {
68
+ display: flex;
69
+ align-items: center;
70
+ gap: 16px;
71
+ }
72
+
73
+ .version-badge {
74
+ font-size: 11px;
75
+ color: var(--muted);
76
+ font-family: var(--font-mono);
77
+ }
78
+
79
+ .pulse-wrap {
80
+ display: flex;
81
+ align-items: center;
82
+ gap: 6px;
83
+ font-size: 12px;
84
+ color: var(--muted);
85
+ }
86
+
87
+ .pulse {
88
+ width: 8px;
89
+ height: 8px;
90
+ border-radius: 50%;
91
+ background: var(--green);
92
+ animation: pulse 2s ease-in-out infinite;
93
+ }
94
+
95
+ .pulse.disconnected { background: var(--red); animation: none; }
96
+
97
+ @keyframes pulse {
98
+ 0%, 100% { opacity: 1; }
99
+ 50% { opacity: 0.3; }
100
+ }
101
+
102
+ .btn {
103
+ display: inline-flex;
104
+ align-items: center;
105
+ gap: 6px;
106
+ padding: 6px 14px;
107
+ border-radius: 6px;
108
+ border: 1px solid var(--border);
109
+ background: var(--surface);
110
+ color: var(--text);
111
+ font-size: 13px;
112
+ cursor: pointer;
113
+ transition: background 0.15s, border-color 0.15s;
114
+ white-space: nowrap;
115
+ }
116
+
117
+ .btn:hover { background: #21262d; border-color: #8b949e; }
118
+
119
+ .btn-primary {
120
+ background: var(--accent);
121
+ border-color: var(--accent);
122
+ color: #0d1117;
123
+ font-weight: 600;
124
+ }
125
+
126
+ .btn-primary:hover { background: #79b8ff; border-color: #79b8ff; }
127
+
128
+ .btn-danger { border-color: var(--red); color: var(--red); }
129
+ .btn-danger:hover { background: rgba(248,81,73,0.1); }
130
+
131
+ .btn-sm { padding: 4px 10px; font-size: 12px; }
132
+
133
+ /* ── Summary bar ─────────────────────────────────────────── */
134
+ .summary {
135
+ padding: 10px 20px;
136
+ display: flex;
137
+ align-items: center;
138
+ gap: 20px;
139
+ border-bottom: 1px solid var(--border);
140
+ background: var(--bg);
141
+ flex-wrap: wrap;
142
+ }
143
+
144
+ .summary-item {
145
+ display: flex;
146
+ align-items: center;
147
+ gap: 6px;
148
+ font-size: 13px;
149
+ color: var(--muted);
150
+ }
151
+
152
+ .summary-count {
153
+ font-weight: 600;
154
+ font-size: 15px;
155
+ }
156
+
157
+ .summary-item.total .summary-count { color: var(--text); }
158
+ .summary-item.working .summary-count { color: var(--green); }
159
+ .summary-item.thinking .summary-count { color: var(--blue); }
160
+ .summary-item.waiting .summary-count { color: var(--amber); }
161
+ .summary-item.idle .summary-count { color: var(--gray); }
162
+
163
+ /* ── Cards ───────────────────────────────────────────────── */
164
+ .grid {
165
+ display: grid;
166
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
167
+ gap: 16px;
168
+ padding: 20px;
169
+ }
170
+
171
+ .card {
172
+ background: var(--surface);
173
+ border: 1px solid var(--border);
174
+ border-radius: 8px;
175
+ overflow: hidden;
176
+ display: flex;
177
+ flex-direction: column;
178
+ transition: border-color 0.2s;
179
+ }
180
+
181
+ .card:hover { border-color: #58a6ff44; }
182
+
183
+ .card-top-border {
184
+ height: 3px;
185
+ }
186
+
187
+ .status-working .card-top-border { background: var(--green); }
188
+ .status-thinking .card-top-border { background: var(--blue); }
189
+ .status-waiting .card-top-border { background: var(--amber); }
190
+ .status-done .card-top-border { background: var(--gray); }
191
+ .status-idle .card-top-border { background: var(--gray); }
192
+ .status-no-session .card-top-border { background: var(--border); }
193
+
194
+ .card-body { padding: 14px 16px; flex: 1; display: flex; flex-direction: column; gap: 8px; }
195
+
196
+ .card-header {
197
+ display: flex;
198
+ align-items: flex-start;
199
+ justify-content: space-between;
200
+ gap: 8px;
201
+ }
202
+
203
+ .card-name {
204
+ font-size: 15px;
205
+ font-weight: 600;
206
+ color: var(--text);
207
+ word-break: break-all;
208
+ }
209
+
210
+ .main-badge {
211
+ font-size: 10px;
212
+ padding: 1px 6px;
213
+ border-radius: 10px;
214
+ background: #21262d;
215
+ border: 1px solid var(--border);
216
+ color: var(--muted);
217
+ white-space: nowrap;
218
+ flex-shrink: 0;
219
+ }
220
+
221
+ .status-badge {
222
+ display: inline-flex;
223
+ align-items: center;
224
+ gap: 4px;
225
+ font-size: 11px;
226
+ font-weight: 500;
227
+ padding: 2px 8px;
228
+ border-radius: 10px;
229
+ white-space: nowrap;
230
+ }
231
+
232
+ .status-badge::before {
233
+ content: '';
234
+ width: 6px;
235
+ height: 6px;
236
+ border-radius: 50%;
237
+ background: currentColor;
238
+ }
239
+
240
+ .badge-working { color: var(--green); background: rgba(63,185,80,.12); }
241
+ .badge-thinking { color: var(--blue); background: rgba(88,166,255,.12); }
242
+ .badge-waiting { color: var(--amber); background: rgba(210,153,34,.12); }
243
+ .badge-done { color: var(--gray); background: rgba(110,118,129,.12); }
244
+ .badge-idle { color: var(--gray); background: rgba(110,118,129,.12); }
245
+ .badge-no-session { color: var(--muted); background: rgba(110,118,129,.08); }
246
+
247
+ .badge-working::before { animation: pulse 1.5s ease-in-out infinite; }
248
+
249
+ .branch-pill {
250
+ display: inline-block;
251
+ font-family: var(--font-mono);
252
+ font-size: 11px;
253
+ padding: 2px 8px;
254
+ background: #21262d;
255
+ border: 1px solid var(--border);
256
+ border-radius: 4px;
257
+ color: var(--accent);
258
+ max-width: 100%;
259
+ overflow: hidden;
260
+ text-overflow: ellipsis;
261
+ white-space: nowrap;
262
+ }
263
+
264
+ .last-prompt {
265
+ display: flex;
266
+ flex-direction: column;
267
+ gap: 5px;
268
+ padding: 8px 10px;
269
+ background: rgba(88,166,255,.06);
270
+ border: 1px solid #58a6ff22;
271
+ border-radius: 6px;
272
+ }
273
+
274
+ /* Turn still in flight — the prompt is the worktree's current task. */
275
+ .last-prompt-active {
276
+ background: rgba(63,185,80,.07);
277
+ border-color: #3fb95033;
278
+ }
279
+
280
+ .last-prompt-head {
281
+ display: flex;
282
+ align-items: center;
283
+ justify-content: space-between;
284
+ gap: 8px;
285
+ }
286
+
287
+ .last-prompt-label {
288
+ display: inline-flex;
289
+ align-items: center;
290
+ gap: 5px;
291
+ }
292
+
293
+ .last-prompt-active .last-prompt-label { color: var(--green); }
294
+
295
+ .last-prompt-active .last-prompt-label::before {
296
+ content: '';
297
+ width: 6px;
298
+ height: 6px;
299
+ border-radius: 50%;
300
+ background: var(--green);
301
+ animation: pulse 1.5s ease-in-out infinite;
302
+ }
303
+
304
+ .last-prompt-summary {
305
+ font-size: 12.5px;
306
+ color: var(--text);
307
+ line-height: 1.45;
308
+ }
309
+
310
+ .last-prompt-at {
311
+ font-size: 10px;
312
+ color: var(--muted);
313
+ font-family: var(--font-mono);
314
+ flex-shrink: 0;
315
+ }
316
+
317
+ .card-meta {
318
+ font-size: 12px;
319
+ color: var(--muted);
320
+ display: flex;
321
+ flex-wrap: wrap;
322
+ gap: 6px 16px;
323
+ }
324
+
325
+ .card-meta-item { display: flex; align-items: center; gap: 4px; }
326
+
327
+ .card-path {
328
+ font-family: var(--font-mono);
329
+ font-size: 11px;
330
+ color: var(--muted);
331
+ word-break: break-all;
332
+ }
333
+
334
+ .file-link {
335
+ font-family: var(--font-mono);
336
+ font-size: 11px;
337
+ color: var(--accent);
338
+ text-decoration: none;
339
+ word-break: break-all;
340
+ }
341
+
342
+ .file-link:hover { text-decoration: underline; }
343
+
344
+ .card-message {
345
+ font-size: 12px;
346
+ color: var(--muted);
347
+ display: -webkit-box;
348
+ -webkit-line-clamp: 2;
349
+ -webkit-box-orient: vertical;
350
+ overflow: hidden;
351
+ line-height: 1.5;
352
+ font-style: italic;
353
+ }
354
+
355
+ .changed-files {
356
+ display: flex;
357
+ flex-wrap: wrap;
358
+ gap: 4px;
359
+ }
360
+
361
+ .file-pill {
362
+ font-family: var(--font-mono);
363
+ font-size: 10px;
364
+ padding: 1px 6px;
365
+ background: #21262d;
366
+ border: 1px solid var(--border);
367
+ border-radius: 4px;
368
+ color: var(--muted);
369
+ max-width: 200px;
370
+ overflow: hidden;
371
+ text-overflow: ellipsis;
372
+ white-space: nowrap;
373
+ }
374
+
375
+ .commits {
376
+ display: flex;
377
+ flex-direction: column;
378
+ gap: 3px;
379
+ }
380
+
381
+ .commit-row {
382
+ display: flex;
383
+ align-items: baseline;
384
+ gap: 6px;
385
+ font-size: 11px;
386
+ line-height: 1.4;
387
+ }
388
+
389
+ .commit-hash {
390
+ font-family: var(--font-mono);
391
+ font-size: 10px;
392
+ color: var(--muted);
393
+ flex-shrink: 0;
394
+ }
395
+
396
+ .commit-subject {
397
+ color: var(--text);
398
+ overflow: hidden;
399
+ text-overflow: ellipsis;
400
+ white-space: nowrap;
401
+ }
402
+
403
+ .commit-date {
404
+ color: var(--muted);
405
+ font-size: 10px;
406
+ flex-shrink: 0;
407
+ margin-left: auto;
408
+ }
409
+
410
+ .card-footer {
411
+ padding: 10px 16px;
412
+ border-top: 1px solid var(--border);
413
+ display: flex;
414
+ gap: 8px;
415
+ align-items: center;
416
+ }
417
+
418
+ .card-footer .btn { flex: 1; justify-content: center; }
419
+
420
+ .confirm-remove {
421
+ display: flex;
422
+ align-items: center;
423
+ gap: 6px;
424
+ font-size: 12px;
425
+ color: var(--muted);
426
+ width: 100%;
427
+ }
428
+
429
+ .confirm-remove span { flex: 1; }
430
+
431
+ /* ── Run / dev-server row ────────────────────────────────── */
432
+ .run-row {
433
+ display: flex;
434
+ align-items: center;
435
+ gap: 8px;
436
+ padding: 8px 16px;
437
+ border-top: 1px solid var(--border);
438
+ font-size: 12px;
439
+ flex-wrap: wrap;
440
+ }
441
+
442
+ .run-dot {
443
+ width: 8px;
444
+ height: 8px;
445
+ border-radius: 50%;
446
+ background: var(--green);
447
+ flex-shrink: 0;
448
+ animation: pulse 1.5s ease-in-out infinite;
449
+ }
450
+
451
+ .run-script {
452
+ font-family: var(--font-mono);
453
+ color: var(--text);
454
+ }
455
+
456
+ .run-port {
457
+ font-family: var(--font-mono);
458
+ color: var(--accent);
459
+ text-decoration: none;
460
+ margin-left: auto;
461
+ }
462
+
463
+ .run-port:hover { text-decoration: underline; }
464
+
465
+ .run-exited { color: var(--muted); }
466
+
467
+ .run-hint {
468
+ font-family: var(--font-mono);
469
+ color: var(--muted);
470
+ font-size: 11px;
471
+ }
472
+
473
+ .run-select {
474
+ padding: 4px 8px;
475
+ border-radius: 6px;
476
+ border: 1px solid var(--border);
477
+ background: var(--bg);
478
+ color: var(--text);
479
+ font-size: 12px;
480
+ font-family: inherit;
481
+ outline: none;
482
+ }
483
+
484
+ .run-row .btn { flex: 1; justify-content: center; }
485
+
486
+ .section-label {
487
+ font-size: 11px;
488
+ font-weight: 600;
489
+ text-transform: uppercase;
490
+ letter-spacing: 0.05em;
491
+ color: var(--muted);
492
+ }
493
+
494
+ /* ── Modal ───────────────────────────────────────────────── */
495
+ .modal-backdrop {
496
+ position: fixed;
497
+ inset: 0;
498
+ background: rgba(1, 4, 9, 0.7);
499
+ z-index: 200;
500
+ display: flex;
501
+ align-items: center;
502
+ justify-content: center;
503
+ padding: 16px;
504
+ }
505
+
506
+ .modal-backdrop.hidden { display: none; }
507
+
508
+ .modal {
509
+ background: var(--surface);
510
+ border: 1px solid var(--border);
511
+ border-radius: 10px;
512
+ padding: 24px;
513
+ width: 100%;
514
+ max-width: 400px;
515
+ box-shadow: 0 16px 48px rgba(0,0,0,0.6);
516
+ }
517
+
518
+ .modal-title {
519
+ font-size: 16px;
520
+ font-weight: 600;
521
+ margin-bottom: 20px;
522
+ }
523
+
524
+ .field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; }
525
+
526
+ .field label { font-size: 13px; font-weight: 500; color: var(--text); }
527
+
528
+ .field input, .field select {
529
+ padding: 8px 12px;
530
+ border-radius: 6px;
531
+ border: 1px solid var(--border);
532
+ background: var(--bg);
533
+ color: var(--text);
534
+ font-size: 14px;
535
+ font-family: inherit;
536
+ transition: border-color 0.15s;
537
+ outline: none;
538
+ }
539
+
540
+ .field input:focus, .field select:focus { border-color: var(--accent); }
541
+
542
+ .modal-actions {
543
+ display: flex;
544
+ justify-content: flex-end;
545
+ gap: 8px;
546
+ margin-top: 20px;
547
+ }
548
+
549
+ .error-msg {
550
+ color: var(--red);
551
+ font-size: 12px;
552
+ margin-top: 8px;
553
+ min-height: 16px;
554
+ }
555
+
556
+ /* ── Empty state ─────────────────────────────────────────── */
557
+ .empty-state {
558
+ grid-column: 1 / -1;
559
+ text-align: center;
560
+ padding: 60px 20px;
561
+ color: var(--muted);
562
+ }
563
+
564
+ .empty-state h2 { font-size: 18px; margin-bottom: 8px; color: var(--text); }
565
+ .empty-state p { font-size: 14px; }
566
+
567
+ /* ── Scrollbar ───────────────────────────────────────────── */
568
+ ::-webkit-scrollbar { width: 6px; }
569
+ ::-webkit-scrollbar-track { background: transparent; }
570
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
571
+ </style>
572
+ </head>
573
+ <body>
574
+
575
+ <!-- Header -->
576
+ <header class="header">
577
+ <div class="header-left">
578
+ <img id="header-logo" class="header-logo" style="display:none" alt="Logo" />
579
+ <span id="header-title" class="header-title"></span>
580
+ </div>
581
+ <div class="header-right">
582
+ <span class="version-badge" id="version-badge"></span>
583
+ <button class="btn" onclick="openCreateModal()">+ New worktree</button>
584
+ <div class="pulse-wrap">
585
+ <div class="pulse" id="pulse-dot"></div>
586
+ <span id="pulse-label">Live</span>
587
+ </div>
588
+ </div>
589
+ </header>
590
+
591
+ <!-- Summary bar -->
592
+ <div class="summary" id="summary">
593
+ <div class="summary-item total"><span class="summary-count" id="s-total">0</span> total</div>
594
+ <div class="summary-item working"><span class="summary-count" id="s-working">0</span> working</div>
595
+ <div class="summary-item thinking"><span class="summary-count" id="s-thinking">0</span> thinking</div>
596
+ <div class="summary-item waiting"><span class="summary-count" id="s-waiting">0</span> waiting</div>
597
+ <div class="summary-item idle"><span class="summary-count" id="s-idle">0</span> idle</div>
598
+ </div>
599
+
600
+ <!-- Card grid -->
601
+ <div class="grid" id="grid"></div>
602
+
603
+ <!-- Create worktree modal -->
604
+ <div class="modal-backdrop hidden" id="modal-backdrop" onclick="closeModalIfBackdrop(event)">
605
+ <div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
606
+ <div class="modal-title" id="modal-title">New worktree</div>
607
+ <div class="field" id="wt-type-field" style="display:none">
608
+ <label for="wt-type">Type</label>
609
+ <select id="wt-type"></select>
610
+ </div>
611
+ <div id="wt-template-fields"></div>
612
+ <div id="wt-default-fields">
613
+ <div class="field">
614
+ <label for="wt-name">Worktree name</label>
615
+ <input id="wt-name" type="text" placeholder="hotel-page" autocomplete="off" />
616
+ </div>
617
+ <div class="field">
618
+ <label for="wt-branch">Branch name</label>
619
+ <input id="wt-branch" type="text" placeholder="feat/hotel-page" autocomplete="off" />
620
+ </div>
621
+ </div>
622
+ <div class="error-msg" id="modal-error"></div>
623
+ <div class="modal-actions">
624
+ <button class="btn" onclick="closeModal()">Cancel</button>
625
+ <button class="btn btn-primary" onclick="submitCreate()">Create →</button>
626
+ </div>
627
+ </div>
628
+ </div>
629
+
630
+ <script>
631
+ (function () {
632
+ const cfg = window.__CONFIG__ || {};
633
+
634
+ // ── Init ─────────────────────────────────────────────────────────────────────
635
+
636
+ if (cfg.logo) {
637
+ const img = document.getElementById('header-logo');
638
+ img.src = cfg.logo;
639
+ img.style.display = 'block';
640
+ }
641
+ document.getElementById('header-title').textContent = cfg.title || 'Worktree Dashboard';
642
+ document.title = cfg.title || 'Worktree Dashboard';
643
+ if (cfg.version) document.getElementById('version-badge').textContent = 'v' + cfg.version;
644
+
645
+ // ── Notification permission ──────────────────────────────────────────────────
646
+ if ('Notification' in window && Notification.permission === 'default') {
647
+ Notification.requestPermission();
648
+ }
649
+
650
+ // ── SSE ──────────────────────────────────────────────────────────────────────
651
+ let prevStatuses = {};
652
+
653
+ function connect() {
654
+ const es = new EventSource('/events');
655
+ es.addEventListener('worktrees', (e) => {
656
+ const data = JSON.parse(e.data);
657
+ renderDashboard(data);
658
+ });
659
+ es.onerror = () => {
660
+ setDisconnected();
661
+ es.close();
662
+ setTimeout(connect, 5000);
663
+ };
664
+ es.onopen = setConnected;
665
+ }
666
+
667
+ function setConnected() {
668
+ document.getElementById('pulse-dot').classList.remove('disconnected');
669
+ document.getElementById('pulse-label').textContent = 'Live';
670
+ }
671
+
672
+ function setDisconnected() {
673
+ document.getElementById('pulse-dot').classList.add('disconnected');
674
+ document.getElementById('pulse-label').textContent = 'Disconnected';
675
+ }
676
+
677
+ connect();
678
+
679
+ // ── Render ───────────────────────────────────────────────────────────────────
680
+
681
+ const statusLabels = {
682
+ working: 'Working',
683
+ thinking: 'Thinking',
684
+ waiting: 'Waiting',
685
+ done: 'Done',
686
+ idle: 'Idle',
687
+ 'no session': 'No session',
688
+ };
689
+
690
+ function renderDashboard(worktrees) {
691
+ // Check for newly waiting worktrees and send notifications
692
+ for (const wt of worktrees) {
693
+ if (wt.status === 'waiting' && prevStatuses[wt.name] && prevStatuses[wt.name] !== 'waiting') {
694
+ notify(wt.name);
695
+ }
696
+ }
697
+ prevStatuses = Object.fromEntries(worktrees.map(wt => [wt.name, wt.status]));
698
+
699
+ // Summary
700
+ const counts = { working: 0, thinking: 0, waiting: 0, idle: 0 };
701
+ for (const wt of worktrees) { if (counts[wt.status] !== undefined) counts[wt.status]++; }
702
+ document.getElementById('s-total').textContent = worktrees.length;
703
+ document.getElementById('s-working').textContent = counts.working;
704
+ document.getElementById('s-thinking').textContent = counts.thinking;
705
+ document.getElementById('s-waiting').textContent = counts.waiting;
706
+ document.getElementById('s-idle').textContent = counts.idle;
707
+
708
+ // Cards
709
+ const grid = document.getElementById('grid');
710
+ grid.innerHTML = '';
711
+
712
+ if (worktrees.length === 0) {
713
+ grid.innerHTML = '<div class="empty-state"><h2>No worktrees found</h2><p>Create a worktree with the button above.</p></div>';
714
+ return;
715
+ }
716
+
717
+ for (const wt of worktrees) {
718
+ grid.appendChild(buildCard(wt));
719
+ }
720
+ }
721
+
722
+ function esc(str) {
723
+ return String(str ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
724
+ }
725
+
726
+ function buildRunRow(wt) {
727
+ if (!wt.scripts || wt.scripts.length === 0) return '';
728
+ const run = wt.running;
729
+
730
+ if (run && run.status === 'running') {
731
+ return `
732
+ <div class="run-row">
733
+ <span class="run-dot"></span>
734
+ <span class="run-script">${esc(run.script)}</span>
735
+ <a class="run-port" href="${esc(run.url)}" target="_blank" rel="noopener" title="Open ${esc(run.url)}">localhost:${run.port} ↗</a>
736
+ <button class="btn btn-sm btn-danger" onclick="stopScript('${esc(wt.name)}')">Stop</button>
737
+ </div>
738
+ `;
739
+ }
740
+
741
+ const exited = run && run.status === 'exited';
742
+ const picker = wt.scripts.length > 1
743
+ ? `<select class="run-select" id="run-sel-${esc(wt.name)}">${wt.scripts.map(s => `<option value="${esc(s)}">${esc(s)}</option>`).join('')}</select>`
744
+ : `<input type="hidden" id="run-sel-${esc(wt.name)}" value="${esc(wt.scripts[0])}" />`;
745
+ const label = wt.scripts.length === 1 ? `▶ Start (${esc(wt.scripts[0])})` : '▶ Start';
746
+
747
+ return `
748
+ <div class="run-row">
749
+ ${exited ? `<span class="run-exited" title="exited with code ${esc(run.exitCode)}">stopped</span>` : ''}
750
+ ${picker}
751
+ <button class="btn btn-sm" onclick="startScript('${esc(wt.name)}')">${label}</button>
752
+ ${wt.defaultPort != null ? `<span class="run-hint">:${wt.defaultPort}</span>` : ''}
753
+ </div>
754
+ `;
755
+ }
756
+
757
+ function buildCard(wt) {
758
+ const statusKey = (wt.status || 'no session').replace(/\s+/g, '-');
759
+ const badgeKey = wt.status === 'no session' ? 'no-session' : (wt.status || 'idle');
760
+ const label = statusLabels[wt.status] || wt.status;
761
+
762
+ const card = document.createElement('div');
763
+ card.className = `card status-${statusKey}`;
764
+ card.dataset.name = wt.name;
765
+
766
+ const changedPills = (wt.changedFiles || [])
767
+ .map(f => `<span class="file-pill" title="${esc(f)}">${esc(f.split('/').pop())}</span>`)
768
+ .join('');
769
+
770
+ const commitRows = (wt.commits || [])
771
+ .map(c => `<div class="commit-row" title="${esc(c.subject)} — ${esc(c.author || '')}">
772
+ <span class="commit-hash">${esc(c.hash)}</span>
773
+ <span class="commit-subject">${esc(c.subject)}</span>
774
+ <span class="commit-date">${esc(c.relativeDate || '')}</span>
775
+ </div>`)
776
+ .join('');
777
+
778
+ const lp = wt.lastPrompt;
779
+ const lpFiles = lp && Array.isArray(lp.files) && lp.files.length
780
+ ? lp.files.map(f => `<span class="file-pill" title="${esc(f)}">${esc(f.split('/').pop())}</span>`).join('')
781
+ : '';
782
+ const lastPromptHtml = lp && lp.text
783
+ ? `<div class="last-prompt${lp.working ? ' last-prompt-active' : ''}">
784
+ <div class="last-prompt-head">
785
+ <span class="section-label last-prompt-label">${lp.working ? 'Working on' : 'Last prompt'}</span>
786
+ ${lp.at ? `<span class="last-prompt-at">${esc(lp.at)}</span>` : ''}
787
+ </div>
788
+ <div class="last-prompt-summary">${esc(lp.text)}</div>
789
+ ${lpFiles ? `<div class="changed-files">${lpFiles}</div>` : ''}
790
+ </div>`
791
+ : '';
792
+
793
+ const fileHtml = wt.lastFile
794
+ ? `<a class="file-link" href="#" onclick="openFile(event,'${esc(wt.lastFile)}',${wt.lastLine||'null'})" title="${esc(wt.lastFile)}">${esc(wt.lastFile.split('/').pop())}${wt.lastLine ? ':' + wt.lastLine : ''}</a>`
795
+ : '<span style="color:var(--muted)">—</span>';
796
+
797
+ card.innerHTML = `
798
+ <div class="card-top-border"></div>
799
+ <div class="card-body">
800
+ <div class="card-header">
801
+ <span class="card-name">${esc(wt.name)}${wt.isMain ? ' <span class="main-badge">main</span>' : ''}</span>
802
+ <span class="status-badge badge-${badgeKey}">${esc(label)}</span>
803
+ </div>
804
+ ${wt.branch ? `<div><span class="branch-pill">${esc(wt.branch)}</span></div>` : ''}
805
+ ${lastPromptHtml}
806
+ <div class="card-path">${esc(wt.path)}</div>
807
+ <div class="card-meta">
808
+ ${wt.lastActivity ? `<div class="card-meta-item">⏱ ${esc(wt.lastActivity)}</div>` : ''}
809
+ ${wt.lastTool ? `<div class="card-meta-item">🔧 ${esc(wt.lastTool)}</div>` : ''}
810
+ ${wt.sessionDuration ? `<div class="card-meta-item">⌚ ${esc(wt.sessionDuration)}</div>` : ''}
811
+ ${wt.tokenCount ? `<div class="card-meta-item">~${(wt.tokenCount/1000).toFixed(1)}k tokens</div>` : ''}
812
+ </div>
813
+ <div class="card-meta-item" style="gap:6px">
814
+ <span class="section-label">File</span>
815
+ ${fileHtml}
816
+ </div>
817
+ ${wt.lastMessage ? `<div class="card-message">"${esc(wt.lastMessage)}"</div>` : ''}
818
+ ${changedPills ? `<div class="changed-files">${changedPills}</div>` : ''}
819
+ ${commitRows ? `<div class="card-meta-item" style="gap:6px"><span class="section-label">Commits</span></div><div class="commits">${commitRows}</div>` : ''}
820
+ </div>
821
+ ${buildRunRow(wt)}
822
+ <div class="card-footer" id="footer-${esc(wt.name)}">
823
+ <button class="btn btn-sm" onclick="openWorktree('${esc(wt.path)}')">Open in VS Code</button>
824
+ ${wt.isMain ? '' : `<button class="btn btn-sm btn-danger" onclick="confirmRemove('${esc(wt.name)}')">Remove</button>`}
825
+ </div>
826
+ `;
827
+ return card;
828
+ }
829
+
830
+ // ── Actions ──────────────────────────────────────────────────────────────────
831
+
832
+ window.openWorktree = function(path) {
833
+ post('/api/open', { path }).catch(console.error);
834
+ };
835
+
836
+ window.openFile = function(e, filePath, line) {
837
+ e.preventDefault();
838
+ post('/api/open-file', { path: filePath, line }).catch(console.error);
839
+ };
840
+
841
+ window.startScript = function(name) {
842
+ const el = document.getElementById('run-sel-' + name);
843
+ const script = el ? el.value : null;
844
+ if (!script) return;
845
+ post('/api/worktree/start', { name, script }).catch(e => alert('Start failed: ' + e.message));
846
+ };
847
+
848
+ window.stopScript = function(name) {
849
+ post('/api/worktree/stop', { name }).catch(e => alert('Stop failed: ' + e.message));
850
+ };
851
+
852
+ window.confirmRemove = function(name) {
853
+ const footer = document.getElementById('footer-' + name);
854
+ if (!footer) return;
855
+ footer.innerHTML = `
856
+ <div class="confirm-remove">
857
+ <span>Remove <strong>${esc(name)}</strong>?</span>
858
+ <button class="btn btn-sm" onclick="cancelRemove('${esc(name)}')">Cancel</button>
859
+ <button class="btn btn-sm btn-danger" onclick="doRemove('${esc(name)}')">Remove</button>
860
+ </div>
861
+ `;
862
+ };
863
+
864
+ window.cancelRemove = function(name) {
865
+ const footer = document.getElementById('footer-' + name);
866
+ if (!footer) return;
867
+ footer.innerHTML = `
868
+ <button class="btn btn-sm" onclick="openWorktree('${esc(getCardPath(name))}')">Open in VS Code</button>
869
+ <button class="btn btn-sm btn-danger" onclick="confirmRemove('${esc(name)}')">Remove</button>
870
+ `;
871
+ };
872
+
873
+ window.doRemove = function(name) {
874
+ post('/api/worktree/remove', { name }).catch(e => alert('Remove failed: ' + e.message));
875
+ };
876
+
877
+ function getCardPath(name) {
878
+ const card = document.querySelector(`.card[data-name="${CSS.escape(name)}"]`);
879
+ return card ? card.querySelector('.card-path')?.textContent ?? '' : '';
880
+ }
881
+
882
+ // ── Modal ─────────────────────────────────────────────────────────────────────
883
+
884
+ const templates = Array.isArray(cfg.templates) ? cfg.templates : [];
885
+
886
+ function populateTypeSelect() {
887
+ const typeField = document.getElementById('wt-type-field');
888
+ const select = document.getElementById('wt-type');
889
+ if (!templates.length) { typeField.style.display = 'none'; return; }
890
+ typeField.style.display = '';
891
+ select.innerHTML = '<option value="">Blank</option>' +
892
+ templates.map(t => `<option value="${esc(t.id)}">${esc(t.label || t.id)}</option>`).join('');
893
+ }
894
+
895
+ function renderTemplateFields() {
896
+ const select = document.getElementById('wt-type');
897
+ const tplFields = document.getElementById('wt-template-fields');
898
+ const defaultFields = document.getElementById('wt-default-fields');
899
+ const tpl = templates.find(t => t.id === select.value);
900
+
901
+ if (!tpl) {
902
+ tplFields.innerHTML = '';
903
+ defaultFields.style.display = '';
904
+ return;
905
+ }
906
+ defaultFields.style.display = 'none';
907
+ tplFields.innerHTML = (tpl.fields || []).map(f => `
908
+ <div class="field">
909
+ <label for="tpl-${esc(f.key)}">${esc(f.label || f.key)}</label>
910
+ <input id="tpl-${esc(f.key)}" data-key="${esc(f.key)}" type="text"
911
+ placeholder="${esc(f.placeholder || '')}" autocomplete="off" />
912
+ </div>
913
+ `).join('');
914
+ const first = tplFields.querySelector('input');
915
+ if (first) first.focus();
916
+ }
917
+
918
+ window.openCreateModal = function() {
919
+ document.getElementById('wt-name').value = '';
920
+ document.getElementById('wt-branch').value = '';
921
+ document.getElementById('modal-error').textContent = '';
922
+ populateTypeSelect();
923
+ document.getElementById('wt-type').value = '';
924
+ renderTemplateFields();
925
+ document.getElementById('modal-backdrop').classList.remove('hidden');
926
+ document.getElementById('wt-name').focus();
927
+ };
928
+
929
+ document.getElementById('wt-type').addEventListener('change', renderTemplateFields);
930
+
931
+ window.closeModal = function() {
932
+ document.getElementById('modal-backdrop').classList.add('hidden');
933
+ };
934
+
935
+ window.closeModalIfBackdrop = function(e) {
936
+ if (e.target === document.getElementById('modal-backdrop')) closeModal();
937
+ };
938
+
939
+ window.submitCreate = async function() {
940
+ const errEl = document.getElementById('modal-error');
941
+ errEl.textContent = '';
942
+
943
+ const templateId = document.getElementById('wt-type').value;
944
+ let payload;
945
+
946
+ if (templateId) {
947
+ const vars = {};
948
+ for (const input of document.querySelectorAll('#wt-template-fields input')) {
949
+ const value = input.value.trim();
950
+ if (!value) { errEl.textContent = (input.previousElementSibling?.textContent || 'Field') + ' is required.'; return; }
951
+ vars[input.dataset.key] = value;
952
+ }
953
+ payload = { template: templateId, vars };
954
+ } else {
955
+ const name = document.getElementById('wt-name').value.trim();
956
+ const branch = document.getElementById('wt-branch').value.trim();
957
+ if (!name) { errEl.textContent = 'Worktree name is required.'; return; }
958
+ if (!branch) { errEl.textContent = 'Branch name is required.'; return; }
959
+ payload = { name, branch };
960
+ }
961
+
962
+ try {
963
+ const result = await post('/api/worktree/create', payload);
964
+ closeModal();
965
+ if (result.path) {
966
+ post('/api/open', { path: result.path }).catch(console.error);
967
+ }
968
+ } catch (e) {
969
+ errEl.textContent = e.message;
970
+ }
971
+ };
972
+
973
+ // Enter to submit in modal
974
+ document.addEventListener('keydown', (e) => {
975
+ if (e.key === 'Escape') closeModal();
976
+ if (e.key === 'Enter' && !document.getElementById('modal-backdrop').classList.contains('hidden')) {
977
+ submitCreate();
978
+ }
979
+ });
980
+
981
+ // Auto-fill branch from name
982
+ document.getElementById('wt-name').addEventListener('input', (e) => {
983
+ const branchInput = document.getElementById('wt-branch');
984
+ if (!branchInput.dataset.touched) {
985
+ branchInput.value = 'feat/' + e.target.value.toLowerCase().replace(/\s+/g, '-');
986
+ }
987
+ });
988
+
989
+ document.getElementById('wt-branch').addEventListener('input', (e) => {
990
+ e.target.dataset.touched = e.target.value ? '1' : '';
991
+ });
992
+
993
+ // ── Notifications ─────────────────────────────────────────────────────────────
994
+
995
+ function notify(name) {
996
+ if ('Notification' in window && Notification.permission === 'granted') {
997
+ new Notification(name + ' is waiting', {
998
+ body: 'The Claude agent has a question for you.',
999
+ });
1000
+ }
1001
+ }
1002
+
1003
+ // ── Fetch helper ──────────────────────────────────────────────────────────────
1004
+
1005
+ async function post(url, body) {
1006
+ const res = await fetch(url, {
1007
+ method: 'POST',
1008
+ headers: { 'Content-Type': 'application/json' },
1009
+ body: JSON.stringify(body),
1010
+ });
1011
+ const data = await res.json();
1012
+ if (!res.ok || data.error) throw new Error(data.error || 'Request failed');
1013
+ return data;
1014
+ }
1015
+
1016
+ })();
1017
+ </script>
1018
+ </body>
1019
+ </html>