cc-agent-ui 0.2.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,1132 @@
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>cc-agent</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet">
9
+ <style>
10
+ /* ── Reset & Base ─────────────────────────────────────────────────────── */
11
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
12
+
13
+ :root {
14
+ --bg: #0d0f14;
15
+ --sidebar-bg: #0a0c10;
16
+ --card-bg: #111318;
17
+ --card-body: #0d0f14;
18
+ --border: #1c1f28;
19
+ --border-hi: #2a2f3d;
20
+ --text: #c8ccd8;
21
+ --dim: #484c5e;
22
+ --dimmer: #2a2d3a;
23
+ --cyan: #56b6c2;
24
+ --cyan-glow: rgba(86,182,194,0.12);
25
+ --green: #98c379;
26
+ --green-glow: rgba(152,195,121,0.1);
27
+ --red: #e06c75;
28
+ --red-glow: rgba(224,108,117,0.1);
29
+ --yellow: #e5c07b;
30
+ --yellow-glow:rgba(229,192,123,0.1);
31
+ --purple: #c678dd;
32
+ --orange: #d19a66;
33
+ --blue: #61afef;
34
+ --font: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
35
+ }
36
+
37
+ html, body { width: 100%; height: 100%; overflow: hidden; }
38
+ body {
39
+ background: var(--bg);
40
+ color: var(--text);
41
+ font-family: var(--font);
42
+ font-size: 12px;
43
+ display: flex;
44
+ flex-direction: column;
45
+ }
46
+
47
+ /* ── Top bar ──────────────────────────────────────────────────────────── */
48
+ #topbar {
49
+ flex-shrink: 0;
50
+ height: 40px;
51
+ background: var(--sidebar-bg);
52
+ border-bottom: 1px solid var(--border);
53
+ display: flex;
54
+ align-items: center;
55
+ padding: 0 16px;
56
+ gap: 20px;
57
+ z-index: 200;
58
+ }
59
+
60
+ #logo {
61
+ font-size: 13px;
62
+ font-weight: 700;
63
+ color: var(--cyan);
64
+ letter-spacing: 0.05em;
65
+ display: flex;
66
+ align-items: center;
67
+ gap: 7px;
68
+ flex-shrink: 0;
69
+ }
70
+ #logo-dot {
71
+ width: 8px; height: 8px; border-radius: 50%;
72
+ background: var(--cyan);
73
+ box-shadow: 0 0 8px var(--cyan);
74
+ animation: breathe 2.5s ease-in-out infinite;
75
+ }
76
+ @keyframes breathe {
77
+ 0%,100% { opacity:1; transform:scale(1); }
78
+ 50% { opacity:0.4; transform:scale(0.7); }
79
+ }
80
+
81
+ .topbar-sep { width: 1px; height: 20px; background: var(--border); flex-shrink:0; }
82
+
83
+ #job-counts {
84
+ display: flex; gap: 16px; align-items: center;
85
+ }
86
+ .jcount {
87
+ display: flex; align-items: center; gap: 5px;
88
+ font-size: 11px;
89
+ }
90
+ .jcount-n { font-weight: 700; font-size: 14px; }
91
+ .jcount-l { color: var(--dim); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; }
92
+ .jcount.running .jcount-n { color: var(--cyan); }
93
+ .jcount.done .jcount-n { color: var(--green); }
94
+ .jcount.failed .jcount-n { color: var(--red); }
95
+ .jcount.pending .jcount-n { color: var(--yellow); }
96
+
97
+ .topbar-hint {
98
+ margin-left: auto;
99
+ color: var(--dimmer);
100
+ font-size: 10px;
101
+ letter-spacing: 0.03em;
102
+ }
103
+
104
+ #ws-dot {
105
+ width: 7px; height: 7px; border-radius: 50%;
106
+ background: var(--dim); flex-shrink:0;
107
+ transition: background 0.3s, box-shadow 0.3s;
108
+ }
109
+ #ws-dot.ok { background: var(--green); box-shadow: 0 0 6px var(--green); }
110
+ #ws-dot.err { background: var(--red); }
111
+
112
+ /* ── Layout ───────────────────────────────────────────────────────────── */
113
+ #main {
114
+ flex: 1;
115
+ display: flex;
116
+ overflow: hidden;
117
+ }
118
+
119
+ /* ── Sidebar ──────────────────────────────────────────────────────────── */
120
+ #sidebar {
121
+ width: 260px;
122
+ flex-shrink: 0;
123
+ background: var(--sidebar-bg);
124
+ border-right: 1px solid var(--border);
125
+ display: flex;
126
+ flex-direction: column;
127
+ overflow: hidden;
128
+ }
129
+
130
+ #sidebar-header {
131
+ padding: 10px 14px 8px;
132
+ border-bottom: 1px solid var(--border);
133
+ font-size: 10px;
134
+ color: var(--dim);
135
+ text-transform: uppercase;
136
+ letter-spacing: 0.1em;
137
+ display: flex;
138
+ align-items: center;
139
+ gap: 8px;
140
+ }
141
+ #filter-btns {
142
+ display: flex; gap: 4px; margin-left: auto;
143
+ }
144
+ .fbtn {
145
+ font-family: var(--font);
146
+ font-size: 9px;
147
+ padding: 2px 6px;
148
+ border: 1px solid var(--border);
149
+ background: transparent;
150
+ color: var(--dim);
151
+ border-radius: 3px;
152
+ cursor: pointer;
153
+ transition: all 0.15s;
154
+ }
155
+ .fbtn:hover, .fbtn.active { border-color: var(--cyan); color: var(--cyan); }
156
+
157
+ #job-list {
158
+ flex: 1;
159
+ overflow-y: auto;
160
+ scrollbar-width: thin;
161
+ scrollbar-color: var(--border) transparent;
162
+ }
163
+ #job-list::-webkit-scrollbar { width: 4px; }
164
+ #job-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
165
+
166
+ .job-item {
167
+ padding: 9px 14px;
168
+ border-bottom: 1px solid var(--border);
169
+ cursor: pointer;
170
+ transition: background 0.15s;
171
+ position: relative;
172
+ }
173
+ .job-item:hover { background: rgba(255,255,255,0.025); }
174
+ .job-item.active { background: rgba(86,182,194,0.06); }
175
+ .job-item.active::before {
176
+ content: '';
177
+ position: absolute;
178
+ left: 0; top: 0; bottom: 0;
179
+ width: 2px;
180
+ background: var(--cyan);
181
+ }
182
+
183
+ .ji-top {
184
+ display: flex; align-items: center; gap: 6px;
185
+ margin-bottom: 4px;
186
+ }
187
+ .ji-status {
188
+ width: 6px; height: 6px; border-radius: 50%;
189
+ flex-shrink: 0;
190
+ }
191
+ .ji-status.running { background: var(--cyan); box-shadow: 0 0 5px var(--cyan); animation: breathe 1.5s ease-in-out infinite; }
192
+ .ji-status.done { background: var(--green); }
193
+ .ji-status.failed { background: var(--red); }
194
+ .ji-status.cancelled{ background: var(--dim); }
195
+ .ji-status.pending_approval { background: var(--yellow); animation: breathe 2s ease-in-out infinite; }
196
+ .ji-status.cloning { background: var(--blue); animation: breathe 1.5s ease-in-out infinite; }
197
+
198
+ .ji-repo {
199
+ font-size: 9px; color: var(--dim);
200
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
201
+ flex: 1;
202
+ }
203
+ .ji-time { font-size: 9px; color: var(--dimmer); flex-shrink: 0; }
204
+
205
+ .ji-task {
206
+ font-size: 10px; color: var(--text);
207
+ overflow: hidden; text-overflow: ellipsis;
208
+ display: -webkit-box;
209
+ -webkit-line-clamp: 2;
210
+ -webkit-box-orient: vertical;
211
+ line-height: 1.5;
212
+ }
213
+
214
+ /* ── Viewport (infinite canvas container) ─────────────────────────────── */
215
+ #viewport {
216
+ flex: 1;
217
+ overflow: hidden;
218
+ position: relative;
219
+ cursor: grab;
220
+ background:
221
+ radial-gradient(circle at 50% 50%, rgba(86,182,194,0.03) 0%, transparent 70%),
222
+ var(--bg);
223
+ /* dot grid */
224
+ background-image:
225
+ radial-gradient(circle, var(--dimmer) 1px, transparent 1px);
226
+ background-size: 28px 28px;
227
+ }
228
+ #viewport:active { cursor: grabbing; }
229
+
230
+ /* mini-map hint */
231
+ #zoom-hint {
232
+ position: absolute;
233
+ bottom: 12px; right: 14px;
234
+ font-size: 10px; color: var(--dim);
235
+ pointer-events: none;
236
+ z-index: 10;
237
+ }
238
+
239
+ #canvas-inner {
240
+ position: absolute;
241
+ top: 0; left: 0;
242
+ transform-origin: 0 0;
243
+ display: grid;
244
+ grid-template-columns: repeat(var(--cols, 3), 460px);
245
+ gap: 14px;
246
+ padding: 24px;
247
+ width: max-content;
248
+ }
249
+
250
+ /* ── Terminal Card ────────────────────────────────────────────────────── */
251
+ .tcard {
252
+ width: 460px;
253
+ background: var(--card-bg);
254
+ border: 1px solid var(--border);
255
+ border-radius: 6px;
256
+ display: flex;
257
+ flex-direction: column;
258
+ overflow: hidden;
259
+ transition: border-color 0.25s, box-shadow 0.25s;
260
+ position: relative;
261
+ }
262
+
263
+ /* status colors */
264
+ .tcard.running { border-color: #2a3545; box-shadow: 0 0 24px var(--cyan-glow); }
265
+ .tcard.done { border-color: #1e2d1e; box-shadow: 0 0 16px var(--green-glow); }
266
+ .tcard.failed { border-color: #2d1e20; box-shadow: 0 0 16px var(--red-glow); }
267
+ .tcard.pending_approval { border-color: #2d2a1e; box-shadow: 0 0 16px var(--yellow-glow); }
268
+ .tcard.cloning { border-color: #1e2233; box-shadow: 0 0 16px rgba(97,175,239,0.1); }
269
+
270
+ /* scan line on running */
271
+ .tcard.running::before {
272
+ content: '';
273
+ position: absolute;
274
+ left: 0; right: 0; height: 1px;
275
+ background: linear-gradient(90deg, transparent 0%, var(--cyan) 50%, transparent 100%);
276
+ opacity: 0.4;
277
+ animation: scanline 3s linear infinite;
278
+ pointer-events: none;
279
+ z-index: 5;
280
+ }
281
+ @keyframes scanline {
282
+ 0% { top: 0; }
283
+ 100% { top: 100%; }
284
+ }
285
+
286
+ /* ── Card Header ── */
287
+ .card-hdr {
288
+ display: flex;
289
+ align-items: center;
290
+ padding: 0 12px;
291
+ height: 36px;
292
+ gap: 8px;
293
+ border-bottom: 1px solid var(--border);
294
+ background: rgba(0,0,0,0.2);
295
+ flex-shrink: 0;
296
+ }
297
+
298
+ .card-num {
299
+ font-size: 10px;
300
+ font-weight: 700;
301
+ color: var(--dim);
302
+ flex-shrink: 0;
303
+ }
304
+ .card-title {
305
+ font-size: 11px;
306
+ font-weight: 600;
307
+ color: var(--text);
308
+ overflow: hidden;
309
+ text-overflow: ellipsis;
310
+ white-space: nowrap;
311
+ flex: 1;
312
+ letter-spacing: 0.01em;
313
+ }
314
+ .card-status-badge {
315
+ font-size: 9px;
316
+ padding: 2px 7px;
317
+ border-radius: 3px;
318
+ text-transform: uppercase;
319
+ letter-spacing: 0.08em;
320
+ font-weight: 600;
321
+ flex-shrink: 0;
322
+ }
323
+ .badge-running { background: rgba(86,182,194,0.15); color: var(--cyan); border: 1px solid rgba(86,182,194,0.3); }
324
+ .badge-done { background: rgba(152,195,121,0.15); color: var(--green); border: 1px solid rgba(152,195,121,0.3); }
325
+ .badge-failed { background: rgba(224,108,117,0.15); color: var(--red); border: 1px solid rgba(224,108,117,0.3); }
326
+ .badge-cancelled{ background: rgba(72,76,94,0.3); color: var(--dim); border: 1px solid var(--border); }
327
+ .badge-pending_approval { background: rgba(229,192,123,0.15); color: var(--yellow); border: 1px solid rgba(229,192,123,0.3); }
328
+ .badge-cloning { background: rgba(97,175,239,0.15); color: var(--blue); border: 1px solid rgba(97,175,239,0.3); }
329
+
330
+ .card-id {
331
+ font-size: 9px;
332
+ color: var(--dimmer);
333
+ flex-shrink: 0;
334
+ font-weight: 400;
335
+ }
336
+ /* traffic lights */
337
+ .card-dots { display:flex; gap:5px; margin-left:6px; flex-shrink:0; }
338
+ .card-dot { width:9px; height:9px; border-radius:50%; }
339
+ .dot-r { background:#e06c75; }
340
+ .dot-y { background:#e5c07b; }
341
+ .dot-g { background:#98c379; }
342
+
343
+ /* ── Card repo bar ── */
344
+ .card-repo {
345
+ padding: 5px 12px;
346
+ font-size: 9px;
347
+ color: var(--dim);
348
+ border-bottom: 1px solid var(--border);
349
+ background: rgba(0,0,0,0.1);
350
+ display: flex; align-items: center; gap: 8px;
351
+ flex-shrink: 0;
352
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
353
+ }
354
+ .card-repo-icon { color: var(--dimmer); }
355
+ .card-branch { color: var(--purple); margin-left: auto; flex-shrink:0; }
356
+
357
+ /* ── Terminal body ── */
358
+ .card-term {
359
+ flex: 1;
360
+ background: var(--card-body);
361
+ padding: 10px 12px;
362
+ overflow-y: auto;
363
+ min-height: 220px;
364
+ max-height: 300px;
365
+ scrollbar-width: thin;
366
+ scrollbar-color: var(--border) transparent;
367
+ font-size: 11px;
368
+ line-height: 1.65;
369
+ }
370
+ .card-term::-webkit-scrollbar { width: 4px; }
371
+ .card-term::-webkit-scrollbar-thumb { background: var(--border-hi); border-radius: 2px; }
372
+
373
+ /* Output line coloring */
374
+ .tl { white-space: pre-wrap; word-break: break-all; }
375
+ .tl-sys { color: var(--dim); } /* [cc-agent] meta lines */
376
+ .tl-ok { color: var(--green); } /* success */
377
+ .tl-err { color: var(--red); } /* errors */
378
+ .tl-warn { color: var(--yellow); } /* warnings */
379
+ .tl-tool { color: var(--blue); } /* tool calls */
380
+ .tl-file { color: var(--orange); } /* file operations */
381
+ .tl-head { color: var(--cyan); font-weight:600; } /* bold headings */
382
+ .tl-dim { color: var(--dim); } /* faded */
383
+ .tl-new { color: var(--text); animation: fadein 0.25s ease-out; }
384
+ @keyframes fadein { from { opacity:0; } to { opacity:1; } }
385
+
386
+ /* ── Card footer (prompt bar) ── */
387
+ .card-foot {
388
+ border-top: 1px solid var(--border);
389
+ background: rgba(0,0,0,0.2);
390
+ padding: 5px 12px;
391
+ display: flex; align-items: center; gap: 10px;
392
+ flex-shrink: 0;
393
+ min-height: 28px;
394
+ }
395
+ .card-prompt {
396
+ font-size: 10px; color: var(--dim);
397
+ display: flex; align-items: center; gap: 6px;
398
+ }
399
+ .prompt-arrow { color: var(--red); font-size: 11px; font-weight:700; }
400
+ .card-foot-meta { margin-left: auto; font-size: 9px; color: var(--dimmer); white-space:nowrap; }
401
+
402
+ /* ── Flash animations ── */
403
+ @keyframes flash-g { 0%{background:rgba(152,195,121,0.2)} 100%{background:var(--card-bg)} }
404
+ @keyframes flash-r { 0%{background:rgba(224,108,117,0.2)} 100%{background:var(--card-bg)} }
405
+ .flash-ok { animation: flash-g 0.8s ease-out; }
406
+ .flash-err { animation: flash-r 0.8s ease-out; }
407
+
408
+ /* ── File Browser Panel ──────────────────────────────────────────────────── */
409
+ #filebrowser {
410
+ position: fixed;
411
+ top: 40px; right: 0; bottom: 0;
412
+ width: 520px;
413
+ background: var(--sidebar-bg);
414
+ border-left: 1px solid var(--border-hi);
415
+ display: flex;
416
+ flex-direction: column;
417
+ z-index: 500;
418
+ transform: translateX(100%);
419
+ transition: transform 0.22s cubic-bezier(.4,0,.2,1);
420
+ }
421
+ #filebrowser.open { transform: translateX(0); }
422
+
423
+ #fb-topbar {
424
+ display: flex;
425
+ align-items: center;
426
+ padding: 0 14px;
427
+ height: 38px;
428
+ border-bottom: 1px solid var(--border);
429
+ gap: 8px;
430
+ flex-shrink: 0;
431
+ }
432
+ #fb-path {
433
+ flex: 1;
434
+ font-size: 10px;
435
+ color: var(--cyan);
436
+ overflow: hidden;
437
+ text-overflow: ellipsis;
438
+ white-space: nowrap;
439
+ cursor: default;
440
+ }
441
+ #fb-close {
442
+ background: transparent;
443
+ border: none;
444
+ color: var(--dim);
445
+ font-size: 16px;
446
+ cursor: pointer;
447
+ padding: 0 4px;
448
+ line-height: 1;
449
+ font-family: var(--font);
450
+ }
451
+ #fb-close:hover { color: var(--text); }
452
+ #fb-back {
453
+ background: transparent;
454
+ border: 1px solid var(--border);
455
+ color: var(--dim);
456
+ font-size: 10px;
457
+ cursor: pointer;
458
+ padding: 2px 8px;
459
+ border-radius: 3px;
460
+ font-family: var(--font);
461
+ }
462
+ #fb-back:hover { color: var(--text); border-color: var(--border-hi); }
463
+
464
+ #fb-body {
465
+ flex: 1;
466
+ overflow: auto;
467
+ scrollbar-width: thin;
468
+ scrollbar-color: var(--border) transparent;
469
+ }
470
+
471
+ /* Directory listing */
472
+ .fb-entry {
473
+ display: flex;
474
+ align-items: center;
475
+ padding: 7px 14px;
476
+ border-bottom: 1px solid var(--border);
477
+ cursor: pointer;
478
+ gap: 10px;
479
+ font-size: 11px;
480
+ transition: background 0.12s;
481
+ }
482
+ .fb-entry:hover { background: rgba(255,255,255,0.03); }
483
+ .fb-icon { font-size: 13px; flex-shrink: 0; width: 18px; text-align: center; }
484
+ .fb-name { flex: 1; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
485
+ .fb-entry.is-dir .fb-name { color: var(--blue); }
486
+ .fb-size { font-size: 9px; color: var(--dim); flex-shrink: 0; }
487
+
488
+ /* File view */
489
+ #fb-file-view {
490
+ padding: 12px 14px;
491
+ font-size: 11px;
492
+ line-height: 1.65;
493
+ white-space: pre-wrap;
494
+ word-break: break-all;
495
+ color: var(--text);
496
+ }
497
+ #fb-file-view.media-view {
498
+ display: flex;
499
+ align-items: center;
500
+ justify-content: center;
501
+ padding: 24px;
502
+ }
503
+ #fb-file-view img, #fb-file-view video, #fb-file-view audio {
504
+ max-width: 100%;
505
+ max-height: calc(100vh - 120px);
506
+ border-radius: 4px;
507
+ }
508
+
509
+ /* Clickable file paths in terminal */
510
+ .fp-link {
511
+ color: var(--orange);
512
+ text-decoration: underline;
513
+ text-decoration-color: rgba(209,154,102,0.4);
514
+ cursor: pointer;
515
+ border-radius: 2px;
516
+ }
517
+ .fp-link:hover {
518
+ background: rgba(209,154,102,0.1);
519
+ text-decoration-color: var(--orange);
520
+ }
521
+
522
+ /* ── Empty state ── */
523
+ #empty-state {
524
+ position: absolute;
525
+ inset: 0;
526
+ display: flex; flex-direction: column;
527
+ align-items: center; justify-content: center;
528
+ gap: 12px;
529
+ color: var(--dim);
530
+ pointer-events: none;
531
+ }
532
+ #empty-state .e-icon { font-size: 48px; color: var(--dimmer); margin-bottom: 4px; }
533
+ #empty-state .e-title { font-size: 16px; font-weight:700; color: var(--dim); }
534
+ #empty-state .e-sub { font-size: 11px; color: var(--dimmer); }
535
+ </style>
536
+ </head>
537
+ <body>
538
+
539
+ <!-- Top bar -->
540
+ <div id="topbar">
541
+ <div id="logo">
542
+ <div id="logo-dot"></div>
543
+ cc-agent
544
+ </div>
545
+ <div class="topbar-sep"></div>
546
+ <div id="job-counts">
547
+ <div class="jcount running"><span class="jcount-n" id="c-running">0</span><span class="jcount-l">running</span></div>
548
+ <div class="jcount pending"><span class="jcount-n" id="c-pending">0</span><span class="jcount-l">pending</span></div>
549
+ <div class="jcount done"><span class="jcount-n" id="c-done">0</span><span class="jcount-l">done</span></div>
550
+ <div class="jcount failed"><span class="jcount-n" id="c-failed">0</span><span class="jcount-l">failed</span></div>
551
+ </div>
552
+ <div class="topbar-hint">scroll · drag to pan · wheel to zoom · click job to focus</div>
553
+ <div id="ws-dot" title="WebSocket"></div>
554
+ </div>
555
+
556
+ <!-- Main -->
557
+ <div id="main">
558
+
559
+ <!-- Sidebar -->
560
+ <div id="sidebar">
561
+ <div id="sidebar-header">
562
+ jobs
563
+ <div id="filter-btns">
564
+ <button class="fbtn active" data-filter="all">all</button>
565
+ <button class="fbtn" data-filter="running">live</button>
566
+ <button class="fbtn" data-filter="done">done</button>
567
+ <button class="fbtn" data-filter="failed">err</button>
568
+ </div>
569
+ </div>
570
+ <div id="job-list"></div>
571
+ </div>
572
+
573
+ <!-- Infinite canvas viewport -->
574
+ <div id="viewport">
575
+ <div id="canvas-inner"></div>
576
+ <div id="empty-state">
577
+ <div class="e-icon">⬡</div>
578
+ <div class="e-title">No agents running</div>
579
+ <div class="e-sub">Spawn a job with cc-agent to see it here</div>
580
+ </div>
581
+ <div id="zoom-hint">100%</div>
582
+ </div>
583
+
584
+ </div>
585
+
586
+ <!-- File Browser Panel -->
587
+ <div id="filebrowser">
588
+ <div id="fb-topbar">
589
+ <button id="fb-back" onclick="fbGoBack()">← back</button>
590
+ <span id="fb-path">/</span>
591
+ <button id="fb-close" onclick="fbClose()">✕</button>
592
+ </div>
593
+ <div id="fb-body"></div>
594
+ </div>
595
+
596
+ <script>
597
+ // ── State ──────────────────────────────────────────────────────────────────
598
+ const jobs = {}; // id → { job, card, logEl, lineCount }
599
+ let filterMode = 'all';
600
+ let jobCounter = 0;
601
+ let scale = 1, ox = 40, oy = 40;
602
+ let dragging = false, dragX = 0, dragY = 0;
603
+
604
+ const $ = id => document.getElementById(id);
605
+ const viewport = $('viewport');
606
+ const inner = $('canvas-inner');
607
+ const jobList = $('job-list');
608
+ const emptyState = $('empty-state');
609
+ const zoomHint = $('zoom-hint');
610
+
611
+ // ── Pan / Zoom ─────────────────────────────────────────────────────────────
612
+ function applyTransform() {
613
+ inner.style.transform = `translate(${ox}px, ${oy}px) scale(${scale})`;
614
+ zoomHint.textContent = `${Math.round(scale * 100)}%`;
615
+ }
616
+ applyTransform();
617
+
618
+ viewport.addEventListener('wheel', e => {
619
+ e.preventDefault();
620
+ const factor = e.deltaY < 0 ? 1.08 : 0.93;
621
+ const newScale = Math.max(0.25, Math.min(2.5, scale * factor));
622
+ // Zoom toward mouse position
623
+ const rect = viewport.getBoundingClientRect();
624
+ const mx = e.clientX - rect.left;
625
+ const my = e.clientY - rect.top;
626
+ ox = mx - (mx - ox) * (newScale / scale);
627
+ oy = my - (my - oy) * (newScale / scale);
628
+ scale = newScale;
629
+ applyTransform();
630
+ }, { passive: false });
631
+
632
+ viewport.addEventListener('mousedown', e => {
633
+ if (e.target.closest('.tcard')) return; // don't drag on cards
634
+ dragging = true; dragX = e.clientX; dragY = e.clientY;
635
+ });
636
+ window.addEventListener('mousemove', e => {
637
+ if (!dragging) return;
638
+ ox += e.clientX - dragX; oy += e.clientY - dragY;
639
+ dragX = e.clientX; dragY = e.clientY;
640
+ applyTransform();
641
+ });
642
+ window.addEventListener('mouseup', () => dragging = false);
643
+
644
+ // Touch pan/pinch
645
+ let lastTouchDist = null, lastTouchX = 0, lastTouchY = 0;
646
+ viewport.addEventListener('touchstart', e => {
647
+ if (e.touches.length === 1) {
648
+ lastTouchX = e.touches[0].clientX;
649
+ lastTouchY = e.touches[0].clientY;
650
+ } else if (e.touches.length === 2) {
651
+ lastTouchDist = Math.hypot(
652
+ e.touches[0].clientX - e.touches[1].clientX,
653
+ e.touches[0].clientY - e.touches[1].clientY
654
+ );
655
+ }
656
+ }, { passive: true });
657
+
658
+ viewport.addEventListener('touchmove', e => {
659
+ e.preventDefault();
660
+ if (e.touches.length === 1) {
661
+ ox += e.touches[0].clientX - lastTouchX;
662
+ oy += e.touches[0].clientY - lastTouchY;
663
+ lastTouchX = e.touches[0].clientX;
664
+ lastTouchY = e.touches[0].clientY;
665
+ applyTransform();
666
+ } else if (e.touches.length === 2) {
667
+ const dist = Math.hypot(
668
+ e.touches[0].clientX - e.touches[1].clientX,
669
+ e.touches[0].clientY - e.touches[1].clientY
670
+ );
671
+ if (lastTouchDist) {
672
+ scale = Math.max(0.25, Math.min(2.5, scale * (dist / lastTouchDist)));
673
+ applyTransform();
674
+ }
675
+ lastTouchDist = dist;
676
+ }
677
+ }, { passive: false });
678
+
679
+ // ── Log line colorizer ─────────────────────────────────────────────────────
680
+ function classifyLine(raw) {
681
+ const s = raw.replace(/\x1b\[[0-9;]*m/g, '').trim();
682
+ if (!s) return null;
683
+ if (s.startsWith('[cc-agent]')) return 'tl tl-sys';
684
+ if (/✓|✅|PASS|passed|merged|pushed|success/i.test(s)) return 'tl tl-ok';
685
+ if (/✗|❌|error|Error|FAIL|failed|exception/i.test(s)) return 'tl tl-err';
686
+ if (/⚠|warn|Warning/i.test(s)) return 'tl tl-warn';
687
+ if (/^(Read|Edit|Write|Bash|Glob|Grep|WebFetch|TodoWrite|Agent)\b/.test(s)) return 'tl tl-tool';
688
+ if (/\.(ts|js|tsx|jsx|py|go|rs|md|json|yaml|sh)\b/.test(s)) return 'tl tl-file';
689
+ if (/^#{1,3}\s|^\*\*/.test(s)) return 'tl tl-head';
690
+ if (/^[\-–—>·]/.test(s)) return 'tl tl-dim';
691
+ return 'tl';
692
+ }
693
+
694
+ function appendLines(logEl, lines, isNew) {
695
+ const frag = document.createDocumentFragment();
696
+ for (const raw of lines) {
697
+ const cls = classifyLine(raw);
698
+ if (!cls) continue;
699
+ const d = document.createElement('div');
700
+ d.className = isNew ? cls + ' tl-new' : cls;
701
+ const clean = raw.replace(/\x1b\[[0-9;]*m/g, '');
702
+ // Linkify file paths
703
+ d.innerHTML = linkifyPaths(clean);
704
+ if (isNew) setTimeout(() => d.classList.remove('tl-new'), 1500);
705
+ frag.appendChild(d);
706
+ }
707
+ logEl.appendChild(frag);
708
+ while (logEl.children.length > 300) logEl.removeChild(logEl.firstChild);
709
+ logEl.scrollTop = logEl.scrollHeight;
710
+ }
711
+
712
+ // ── Card factory ───────────────────────────────────────────────────────────
713
+ function shortRepo(url) {
714
+ if (!url) return 'local';
715
+ const m = url.match(/github\.com\/([^/]+\/[^/]+)/);
716
+ return m ? m[1] : url;
717
+ }
718
+
719
+ function timeAgo(iso) {
720
+ if (!iso) return '';
721
+ const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
722
+ if (s < 60) return `${s}s ago`;
723
+ if (s < 3600) return `${Math.floor(s/60)}m ago`;
724
+ return `${Math.floor(s/3600)}h ago`;
725
+ }
726
+
727
+ function shortTask(task) {
728
+ if (!task) return '(no task)';
729
+ // Strip "RESUMING interrupted job..." prefix
730
+ const cleaned = task.replace(/^RESUMING interrupted job\..*?\n\nOriginal task:\n/s, '')
731
+ .replace(/^RESUMING interrupted job\..+$/m, '').trim();
732
+ const firstLine = cleaned.split('\n')[0].trim();
733
+ return firstLine.slice(0, 80) || '(task)';
734
+ }
735
+
736
+ function makeCard(job, n) {
737
+ const card = document.createElement('div');
738
+ const status = job.status || 'unknown';
739
+ card.className = `tcard ${status}`;
740
+ card.id = `card-${job.id}`;
741
+
742
+ const repoStr = shortRepo(job.repoUrl);
743
+ const taskStr = shortTask(job.task);
744
+ const idShort = job.id.slice(0, 8);
745
+
746
+ card.innerHTML = `
747
+ <div class="card-hdr">
748
+ <span class="card-num">${n} ·</span>
749
+ <span class="card-title" title="${escHtml(taskStr)}">${escHtml(taskStr)}</span>
750
+ <span class="card-status-badge badge-${status}">${status.replace('_',' ')}</span>
751
+ <span class="card-id">#${idShort}</span>
752
+ <div class="card-dots">
753
+ <div class="card-dot dot-r"></div>
754
+ <div class="card-dot dot-y"></div>
755
+ <div class="card-dot dot-g"></div>
756
+ </div>
757
+ </div>
758
+ <div class="card-repo">
759
+ <span class="card-repo-icon">⎇</span>
760
+ <span>${escHtml(repoStr)}</span>
761
+ ${job.branch ? `<span class="card-branch">${escHtml(job.branch)}</span>` : ''}
762
+ </div>
763
+ <div class="card-term"></div>
764
+ <div class="card-foot">
765
+ <span class="card-prompt">
766
+ <span class="prompt-arrow">▶▶</span>
767
+ <span>${status === 'running' ? 'claude running' : status}</span>
768
+ </span>
769
+ <span class="card-foot-meta">${timeAgo(job.startedAt)}</span>
770
+ </div>
771
+ `;
772
+
773
+ const logEl = card.querySelector('.card-term');
774
+ return { card, logEl };
775
+ }
776
+
777
+ function escHtml(s) {
778
+ return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
779
+ }
780
+
781
+ // ── Sidebar item ───────────────────────────────────────────────────────────
782
+ function makeSidebarItem(job) {
783
+ const item = document.createElement('div');
784
+ item.className = `job-item`;
785
+ item.id = `si-${job.id}`;
786
+ const status = job.status || 'unknown';
787
+
788
+ item.innerHTML = `
789
+ <div class="ji-top">
790
+ <div class="ji-status ${status}"></div>
791
+ <span class="ji-repo">${escHtml(shortRepo(job.repoUrl))}</span>
792
+ <span class="ji-time">${timeAgo(job.startedAt)}</span>
793
+ </div>
794
+ <div class="ji-task">${escHtml(shortTask(job.task))}</div>
795
+ `;
796
+
797
+ item.addEventListener('click', () => focusCard(job.id));
798
+ return item;
799
+ }
800
+
801
+ function focusCard(id) {
802
+ // Highlight sidebar
803
+ document.querySelectorAll('.job-item').forEach(e => e.classList.remove('active'));
804
+ const si = $(`si-${id}`);
805
+ if (si) { si.classList.add('active'); si.scrollIntoView({ block: 'nearest' }); }
806
+
807
+ const entry = jobs[id];
808
+ const card = $(`card-${id}`);
809
+ if (!card) return;
810
+
811
+ // If card is hidden by filter, switch to 'all'
812
+ if (card.style.display === 'none') {
813
+ document.querySelectorAll('.fbtn').forEach(b => b.classList.remove('active'));
814
+ document.querySelector('.fbtn[data-filter="all"]').classList.add('active');
815
+ filterMode = 'all';
816
+ applyFilter();
817
+ }
818
+
819
+ // Use actual DOM layout positions (accurate regardless of grid index math)
820
+ const cardX = card.offsetLeft;
821
+ const cardY = card.offsetTop;
822
+ const cardW = card.offsetWidth || 460;
823
+ const cardH = card.offsetHeight || 370;
824
+ const vw = viewport.clientWidth, vh = viewport.clientHeight;
825
+ ox = vw/2 - (cardX + cardW/2) * scale;
826
+ oy = vh/2 - (cardY + cardH/2) * scale;
827
+ applyTransform();
828
+
829
+ // Scroll terminal to bottom
830
+ if (entry?.logEl) entry.logEl.scrollTop = entry.logEl.scrollHeight;
831
+ }
832
+
833
+ // ── Update sidebar counts ──────────────────────────────────────────────────
834
+ function updateCounts() {
835
+ let running=0, pending=0, done=0, failed=0;
836
+ for (const { job } of Object.values(jobs)) {
837
+ if (job.status === 'running' || job.status === 'cloning') running++;
838
+ else if (job.status === 'pending_approval') pending++;
839
+ else if (job.status === 'done') done++;
840
+ else if (job.status === 'failed') failed++;
841
+ }
842
+ $('c-running').textContent = running;
843
+ $('c-pending').textContent = pending;
844
+ $('c-done').textContent = done;
845
+ $('c-failed').textContent = failed;
846
+ }
847
+
848
+ // ── Apply filter to sidebar + canvas ──────────────────────────────────────
849
+ function applyFilter() {
850
+ for (const { job, card } of Object.values(jobs)) {
851
+ const show = filterMode === 'all'
852
+ || (filterMode === 'running' && ['running','cloning','pending_approval'].includes(job.status))
853
+ || (filterMode === 'done' && job.status === 'done')
854
+ || (filterMode === 'failed' && job.status === 'failed');
855
+ const item = $(`si-${job.id}`);
856
+ if (item) item.style.display = show ? '' : 'none';
857
+ if (card) card.style.display = show ? '' : 'none';
858
+ }
859
+ }
860
+
861
+ document.querySelectorAll('.fbtn').forEach(btn => {
862
+ btn.addEventListener('click', () => {
863
+ document.querySelectorAll('.fbtn').forEach(b => b.classList.remove('active'));
864
+ btn.classList.add('active');
865
+ filterMode = btn.dataset.filter;
866
+ applyFilter();
867
+ });
868
+ });
869
+
870
+ // ── Handle snapshot (initial load) ────────────────────────────────────────
871
+ function handleSnapshot(data) {
872
+ // Sort: running first, then pending, then recent done/failed
873
+ const sorted = [...data.jobs].sort((a, b) => {
874
+ const order = { running:0, cloning:1, pending_approval:2, failed:3, done:4, cancelled:5 };
875
+ return (order[a.status]??9) - (order[b.status]??9) ||
876
+ new Date(b.startedAt||0) - new Date(a.startedAt||0);
877
+ });
878
+
879
+ for (const job of sorted) {
880
+ addJob(job, job.lines || []);
881
+ }
882
+ updateCounts();
883
+ applyFilter();
884
+ if (Object.keys(jobs).length > 0) {
885
+ emptyState.style.display = 'none';
886
+ }
887
+ }
888
+
889
+ function addJob(job, lines) {
890
+ if (jobs[job.id]) return; // already exists
891
+ jobCounter++;
892
+ const { card, logEl } = makeCard(job, jobCounter);
893
+ inner.appendChild(card);
894
+ const sidebarItem = makeSidebarItem(job);
895
+ jobList.prepend(sidebarItem);
896
+ jobs[job.id] = { job: { ...job }, card, logEl, n: jobCounter };
897
+ if (lines.length) appendLines(logEl, lines, false);
898
+ }
899
+
900
+ // ── Handle job update ──────────────────────────────────────────────────────
901
+ function handleJobUpdate(data) {
902
+ const job = data.job;
903
+ const entry = jobs[job.id];
904
+
905
+ if (!entry) {
906
+ // New job we haven't seen yet
907
+ addJob(job, []);
908
+ emptyState.style.display = 'none';
909
+ updateCounts();
910
+ applyFilter();
911
+ return;
912
+ }
913
+
914
+ const prev = entry.job.status;
915
+ entry.job = { ...entry.job, ...job };
916
+ const status = job.status;
917
+
918
+ // Update card class
919
+ entry.card.className = `tcard ${status}`;
920
+
921
+ // Update badge
922
+ const badge = entry.card.querySelector('.card-status-badge');
923
+ if (badge) { badge.className = `card-status-badge badge-${status}`; badge.textContent = status.replace('_',' '); }
924
+
925
+ // Update prompt
926
+ const prompt = entry.card.querySelector('.card-prompt span:last-child');
927
+ if (prompt) prompt.textContent = status === 'running' ? 'claude running' : status;
928
+
929
+ // Flash
930
+ if (status === 'done' && prev !== 'done') {
931
+ entry.card.classList.add('flash-ok');
932
+ setTimeout(() => entry.card.classList.remove('flash-ok'), 900);
933
+ }
934
+ if (status === 'failed' && prev !== 'failed') {
935
+ entry.card.classList.add('flash-err');
936
+ setTimeout(() => entry.card.classList.remove('flash-err'), 900);
937
+ }
938
+
939
+ // Update sidebar dot
940
+ const dot = $(`si-${job.id}`)?.querySelector('.ji-status');
941
+ if (dot) { dot.className = `ji-status ${status}`; }
942
+
943
+ updateCounts();
944
+ applyFilter();
945
+ }
946
+
947
+ // ── Handle job_new ─────────────────────────────────────────────────────────
948
+ function handleJobNew(data) {
949
+ addJob(data.job, data.job.lines || []);
950
+ emptyState.style.display = 'none';
951
+ updateCounts();
952
+ applyFilter();
953
+ }
954
+
955
+ // ── Handle output ──────────────────────────────────────────────────────────
956
+ function handleOutput(data) {
957
+ const entry = jobs[data.id];
958
+ if (!entry) return;
959
+ appendLines(entry.logEl, data.lines, true);
960
+ }
961
+
962
+ // ── WebSocket ──────────────────────────────────────────────────────────────
963
+ function connect() {
964
+ const ws = new WebSocket(`ws://${location.host}`);
965
+ const dot = $('ws-dot');
966
+
967
+ ws.onopen = () => { dot.className = 'ok'; };
968
+ ws.onclose = () => { dot.className = 'err'; setTimeout(connect, 2000); };
969
+ ws.onerror = () => dot.className = 'err';
970
+
971
+ ws.onmessage = ({ data }) => {
972
+ let evt;
973
+ try { evt = JSON.parse(data); } catch { return; }
974
+ switch (evt.type) {
975
+ case 'snapshot': handleSnapshot(evt); break;
976
+ case 'job_update': handleJobUpdate(evt); break;
977
+ case 'job_new': handleJobNew(evt); break;
978
+ case 'job_output': handleOutput(evt); break;
979
+ }
980
+ };
981
+ }
982
+
983
+ // ── File Browser ───────────────────────────────────────────────────────────
984
+ const fb = $('filebrowser');
985
+ const fbBody = $('fb-body');
986
+ const fbPathEl = $('fb-path');
987
+ let fbHistory = [];
988
+
989
+ function fbOpen(p) {
990
+ fb.classList.add('open');
991
+ fbNavigate(p);
992
+ }
993
+
994
+ function fbClose() {
995
+ fb.classList.remove('open');
996
+ }
997
+
998
+ function fbGoBack() {
999
+ if (fbHistory.length > 1) {
1000
+ fbHistory.pop();
1001
+ fbNavigate(fbHistory.pop(), true);
1002
+ }
1003
+ }
1004
+
1005
+ async function fbNavigate(p, fromBack) {
1006
+ if (!fromBack) fbHistory.push(p);
1007
+ fbPathEl.textContent = p;
1008
+ fbBody.innerHTML = '<div style="padding:14px;color:var(--dim)">loading…</div>';
1009
+ try {
1010
+ const res = await fetch(`/api/browse?path=${encodeURIComponent(p)}`);
1011
+ if (!res.ok) throw new Error(await res.text());
1012
+ const ct = res.headers.get('content-type') || '';
1013
+ if (ct.includes('application/json')) {
1014
+ const data = await res.json();
1015
+ if (data.type === 'dir') renderDir(data);
1016
+ } else {
1017
+ renderFile(p, ct, res);
1018
+ }
1019
+ } catch (e) {
1020
+ fbBody.innerHTML = `<div style="padding:14px;color:var(--red)">${escHtml(e.message)}</div>`;
1021
+ }
1022
+ }
1023
+
1024
+ function fmtSize(n) {
1025
+ if (n == null) return '';
1026
+ if (n < 1024) return n + 'B';
1027
+ if (n < 1048576) return (n/1024).toFixed(1) + 'KB';
1028
+ return (n/1048576).toFixed(1) + 'MB';
1029
+ }
1030
+
1031
+ function fileIcon(name, type) {
1032
+ if (type === 'dir') return '📁';
1033
+ const ext = name.split('.').pop().toLowerCase();
1034
+ if (['js','ts','tsx','jsx','py','go','rs','sh'].includes(ext)) return '📄';
1035
+ if (['png','jpg','jpeg','gif','svg','webp'].includes(ext)) return '🖼';
1036
+ if (['mp4','webm','mov'].includes(ext)) return '🎬';
1037
+ if (['mp3','wav','ogg'].includes(ext)) return '🎵';
1038
+ if (ext === 'pdf') return '📋';
1039
+ if (['md','txt'].includes(ext)) return '📝';
1040
+ if (ext === 'json') return '{}';
1041
+ return '📄';
1042
+ }
1043
+
1044
+ function renderDir(data) {
1045
+ const frag = document.createDocumentFragment();
1046
+ for (const e of data.entries) {
1047
+ const row = document.createElement('div');
1048
+ row.className = `fb-entry${e.type === 'dir' ? ' is-dir' : ''}`;
1049
+ row.innerHTML = `<span class="fb-icon">${fileIcon(e.name, e.type)}</span><span class="fb-name">${escHtml(e.name)}</span><span class="fb-size">${fmtSize(e.size)}</span>`;
1050
+ row.addEventListener('click', () => fbNavigate(e.path));
1051
+ frag.appendChild(row);
1052
+ }
1053
+ fbBody.innerHTML = '';
1054
+ if (data.entries.length === 0) {
1055
+ fbBody.innerHTML = '<div style="padding:14px;color:var(--dim)">empty directory</div>';
1056
+ } else {
1057
+ fbBody.appendChild(frag);
1058
+ }
1059
+ }
1060
+
1061
+ async function renderFile(p, ct, res) {
1062
+ const isText = ct.startsWith('text/') || ct.includes('json') || ct.includes('javascript') || ct.includes('xml');
1063
+ const isImage = ct.startsWith('image/');
1064
+ const isVideo = ct.startsWith('video/');
1065
+ const isAudio = ct.startsWith('audio/');
1066
+ const url = `/api/browse?path=${encodeURIComponent(p)}`;
1067
+ const div = document.createElement('div');
1068
+ div.id = 'fb-file-view';
1069
+ if (isImage) {
1070
+ div.className = 'media-view';
1071
+ div.innerHTML = `<img src="${url}" alt="${escHtml(p)}">`;
1072
+ } else if (isVideo) {
1073
+ div.className = 'media-view';
1074
+ div.innerHTML = `<video controls src="${url}"></video>`;
1075
+ } else if (isAudio) {
1076
+ div.className = 'media-view';
1077
+ div.innerHTML = `<audio controls src="${url}"></audio>`;
1078
+ } else if (isText) {
1079
+ const text = await res.text();
1080
+ div.textContent = text;
1081
+ } else {
1082
+ div.innerHTML = `<div style="color:var(--dim);padding:14px">Binary file — <a href="${url}" download style="color:var(--cyan)">download</a></div>`;
1083
+ }
1084
+ fbBody.innerHTML = '';
1085
+ fbBody.appendChild(div);
1086
+ }
1087
+
1088
+ // ── File path detection in terminal lines ─────────────────────────────────
1089
+ const PATH_RE = /((?:\/[^\s:'"<>()[\]{}\\|]+)+(?:\.[a-zA-Z0-9]+)?|~(?:\/[^\s:'"<>()[\]{}\\|]+)*)/g;
1090
+
1091
+ function linkifyPaths(text) {
1092
+ // Escape then re-insert clickable spans for file paths
1093
+ // Split on path patterns, escape non-path segments, wrap paths in spans
1094
+ const parts = [];
1095
+ let last = 0;
1096
+ let m;
1097
+ PATH_RE.lastIndex = 0;
1098
+ while ((m = PATH_RE.exec(text)) !== null) {
1099
+ if (m.index > last) parts.push(escHtml(text.slice(last, m.index)));
1100
+ parts.push(`<span class="fp-link" onclick="fbOpen(${JSON.stringify(m[0])})">${escHtml(m[0])}</span>`);
1101
+ last = m.index + m[0].length;
1102
+ }
1103
+ if (last < text.length) parts.push(escHtml(text.slice(last)));
1104
+ return parts.join('');
1105
+ }
1106
+
1107
+ // Update time-ago labels every 30s
1108
+ setInterval(() => {
1109
+ for (const { job, card } of Object.values(jobs)) {
1110
+ const meta = card.querySelector('.card-foot-meta');
1111
+ if (meta) meta.textContent = timeAgo(job.startedAt);
1112
+ const si = $(`si-${job.id}`);
1113
+ if (si) {
1114
+ const t = si.querySelector('.ji-time');
1115
+ if (t) t.textContent = timeAgo(job.startedAt);
1116
+ }
1117
+ }
1118
+ }, 30000);
1119
+
1120
+ // Auto-layout columns based on viewport width
1121
+ function updateCols() {
1122
+ const vw = viewport.clientWidth;
1123
+ const cols = Math.max(1, Math.floor((vw - 40) / 474));
1124
+ inner.style.setProperty('--cols', cols);
1125
+ }
1126
+ updateCols();
1127
+ new ResizeObserver(updateCols).observe(viewport);
1128
+
1129
+ connect();
1130
+ </script>
1131
+ </body>
1132
+ </html>