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.
- package/README.md +77 -0
- package/launchd/cc-agent-ui.plist.template +37 -0
- package/package.json +17 -0
- package/public/index.html +1132 -0
- package/server.js +263 -0
|
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
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>
|