@logicseek/hello-cc 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +110 -0
- package/bin/hcc.mjs +865 -2430
- package/docs/commands.md +21 -9
- package/docs/commands.zh-CN.md +19 -9
- package/lib/cli-args.mjs +89 -0
- package/lib/db-schema.mjs +324 -0
- package/lib/errors.mjs +7 -0
- package/lib/format.mjs +39 -0
- package/lib/guidance.mjs +123 -0
- package/lib/json-file.mjs +14 -0
- package/lib/package-meta.mjs +24 -0
- package/lib/release-notes.mjs +40 -0
- package/lib/runtime-paths.mjs +45 -0
- package/lib/setup.mjs +3 -474
- package/lib/shell-path.mjs +69 -0
- package/lib/shim-script.mjs +384 -0
- package/lib/web-http.mjs +55 -0
- package/lib/web-runtime.mjs +108 -0
- package/lib/web-ui-template.mjs +1909 -0
- package/package.json +1 -1
- package/scripts/github-release.mjs +11 -49
- package/scripts/regression.mjs +443 -18
- package/scripts/release-notes.mjs +6 -38
|
@@ -0,0 +1,1909 @@
|
|
|
1
|
+
export function webIndexHtml() {
|
|
2
|
+
return `<!doctype html>
|
|
3
|
+
<html lang="en">
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
|
+
<title>hello-cc</title>
|
|
8
|
+
<link rel="stylesheet" href="/assets/xterm.css">
|
|
9
|
+
<style>
|
|
10
|
+
:root {
|
|
11
|
+
color-scheme: dark;
|
|
12
|
+
--bg: #101214;
|
|
13
|
+
--panel: #181b1f;
|
|
14
|
+
--panel-2: #20242a;
|
|
15
|
+
--border: #303640;
|
|
16
|
+
--text: #eef2f6;
|
|
17
|
+
--muted: #a3adba;
|
|
18
|
+
--accent: #40c4aa;
|
|
19
|
+
--warn: #f2bb4f;
|
|
20
|
+
--danger: #ff6b6b;
|
|
21
|
+
--ok: #75d17c;
|
|
22
|
+
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
|
23
|
+
--sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
24
|
+
}
|
|
25
|
+
* { box-sizing: border-box; }
|
|
26
|
+
body {
|
|
27
|
+
margin: 0;
|
|
28
|
+
min-height: 100vh;
|
|
29
|
+
background: var(--bg);
|
|
30
|
+
color: var(--text);
|
|
31
|
+
font-family: var(--sans);
|
|
32
|
+
overflow: hidden;
|
|
33
|
+
}
|
|
34
|
+
button, input, select {
|
|
35
|
+
font: inherit;
|
|
36
|
+
}
|
|
37
|
+
button {
|
|
38
|
+
border: 1px solid var(--border);
|
|
39
|
+
background: var(--panel-2);
|
|
40
|
+
color: var(--text);
|
|
41
|
+
height: 32px;
|
|
42
|
+
border-radius: 6px;
|
|
43
|
+
padding: 0 10px;
|
|
44
|
+
cursor: pointer;
|
|
45
|
+
}
|
|
46
|
+
button:hover { border-color: var(--accent); }
|
|
47
|
+
button.primary { background: #1b5f54; border-color: #267f71; }
|
|
48
|
+
button.danger { background: #5d252a; border-color: #8c333d; }
|
|
49
|
+
input, select {
|
|
50
|
+
width: 100%;
|
|
51
|
+
height: 32px;
|
|
52
|
+
border: 1px solid var(--border);
|
|
53
|
+
background: #0d0f12;
|
|
54
|
+
color: var(--text);
|
|
55
|
+
border-radius: 6px;
|
|
56
|
+
padding: 0 9px;
|
|
57
|
+
min-width: 0;
|
|
58
|
+
}
|
|
59
|
+
label {
|
|
60
|
+
display: grid;
|
|
61
|
+
gap: 4px;
|
|
62
|
+
color: var(--muted);
|
|
63
|
+
font-size: 12px;
|
|
64
|
+
}
|
|
65
|
+
.app {
|
|
66
|
+
position: relative;
|
|
67
|
+
height: 100vh;
|
|
68
|
+
display: grid;
|
|
69
|
+
--left-width: 320px;
|
|
70
|
+
--right-width: 360px;
|
|
71
|
+
grid-template-columns: var(--left-width) minmax(0, 1fr) var(--right-width);
|
|
72
|
+
min-width: 640px;
|
|
73
|
+
transition: grid-template-columns .18s ease;
|
|
74
|
+
}
|
|
75
|
+
.app.resizing { transition: none; }
|
|
76
|
+
.app.left-collapsed { grid-template-columns: 0 minmax(0, 1fr) var(--right-width); }
|
|
77
|
+
.app.right-collapsed { grid-template-columns: var(--left-width) minmax(0, 1fr) 0; }
|
|
78
|
+
.app.left-collapsed.right-collapsed { grid-template-columns: 0 minmax(0, 1fr) 0; }
|
|
79
|
+
/* Hide sidebar borders when collapsed so no 1px seam remains. */
|
|
80
|
+
.app.left-collapsed .sidebar { border-right-width: 0; }
|
|
81
|
+
.app.right-collapsed .inspector { border-left-width: 0; }
|
|
82
|
+
.edge-resizer {
|
|
83
|
+
position: absolute;
|
|
84
|
+
top: 0;
|
|
85
|
+
bottom: 0;
|
|
86
|
+
z-index: 55;
|
|
87
|
+
width: 12px;
|
|
88
|
+
cursor: col-resize;
|
|
89
|
+
touch-action: none;
|
|
90
|
+
user-select: none;
|
|
91
|
+
background: transparent;
|
|
92
|
+
transition: left .18s ease, right .18s ease, background .12s;
|
|
93
|
+
}
|
|
94
|
+
.edge-resizer:hover,
|
|
95
|
+
.app.resizing .edge-resizer {
|
|
96
|
+
background: rgba(126, 231, 215, .08);
|
|
97
|
+
}
|
|
98
|
+
.app.resizing .edge-resizer { transition: none; }
|
|
99
|
+
.edge-resizer-left { left: var(--left-width); transform: translateX(-50%); }
|
|
100
|
+
.edge-resizer-right { right: var(--right-width); transform: translateX(50%); }
|
|
101
|
+
.app.left-collapsed .edge-resizer-left { left: 0; }
|
|
102
|
+
.app.right-collapsed .edge-resizer-right { right: 0; }
|
|
103
|
+
/* Small collapse handles centered vertically on each divider border.
|
|
104
|
+
They are children of .app (no overflow clip) and track the column edge. */
|
|
105
|
+
.edge-toggle {
|
|
106
|
+
position: absolute;
|
|
107
|
+
top: 50%;
|
|
108
|
+
z-index: 60;
|
|
109
|
+
width: 16px;
|
|
110
|
+
height: 44px;
|
|
111
|
+
padding: 0;
|
|
112
|
+
display: flex;
|
|
113
|
+
align-items: center;
|
|
114
|
+
justify-content: center;
|
|
115
|
+
font-size: 11px;
|
|
116
|
+
line-height: 1;
|
|
117
|
+
color: var(--muted);
|
|
118
|
+
background: var(--panel);
|
|
119
|
+
border: 1px solid var(--border);
|
|
120
|
+
border-radius: 6px;
|
|
121
|
+
cursor: col-resize;
|
|
122
|
+
touch-action: none;
|
|
123
|
+
user-select: none;
|
|
124
|
+
transition: left .18s ease, right .18s ease, color .12s, border-color .12s, background .12s;
|
|
125
|
+
}
|
|
126
|
+
.app.resizing .edge-toggle { transition: none; }
|
|
127
|
+
.edge-toggle:hover { color: var(--text); border-color: var(--accent); background: #1b1f26; }
|
|
128
|
+
.edge-left { left: var(--left-width); transform: translate(-50%, -50%); }
|
|
129
|
+
.edge-right { right: var(--right-width); transform: translate(50%, -50%); }
|
|
130
|
+
.app.left-collapsed .edge-left { left: 0; transform: translate(0, -50%); }
|
|
131
|
+
.app.right-collapsed .edge-right { right: 0; transform: translate(0, -50%); }
|
|
132
|
+
.sidebar, .inspector {
|
|
133
|
+
min-height: 0;
|
|
134
|
+
min-width: 0;
|
|
135
|
+
max-width: 100%;
|
|
136
|
+
overflow: hidden;
|
|
137
|
+
background: var(--panel);
|
|
138
|
+
border-right: 1px solid var(--border);
|
|
139
|
+
display: grid;
|
|
140
|
+
grid-template-rows: auto auto auto auto 1fr;
|
|
141
|
+
}
|
|
142
|
+
.inspector {
|
|
143
|
+
border-right: 0;
|
|
144
|
+
border-left: 1px solid var(--border);
|
|
145
|
+
grid-template-rows: auto 1fr;
|
|
146
|
+
}
|
|
147
|
+
.sidebar > *, .inspector > * {
|
|
148
|
+
min-width: 0;
|
|
149
|
+
max-width: 100%;
|
|
150
|
+
}
|
|
151
|
+
.brand {
|
|
152
|
+
padding: 14px;
|
|
153
|
+
border-bottom: 1px solid var(--border);
|
|
154
|
+
display: flex;
|
|
155
|
+
align-items: center;
|
|
156
|
+
justify-content: space-between;
|
|
157
|
+
gap: 10px;
|
|
158
|
+
}
|
|
159
|
+
.brand > div {
|
|
160
|
+
min-width: 0;
|
|
161
|
+
}
|
|
162
|
+
.brand strong { font-size: 15px; }
|
|
163
|
+
.brand span, .path {
|
|
164
|
+
color: var(--muted);
|
|
165
|
+
font-family: var(--mono);
|
|
166
|
+
font-size: 11px;
|
|
167
|
+
overflow: hidden;
|
|
168
|
+
text-overflow: ellipsis;
|
|
169
|
+
white-space: nowrap;
|
|
170
|
+
}
|
|
171
|
+
#connState {
|
|
172
|
+
flex: 0 0 92px;
|
|
173
|
+
max-width: 92px;
|
|
174
|
+
text-align: right;
|
|
175
|
+
}
|
|
176
|
+
.form {
|
|
177
|
+
padding: 12px;
|
|
178
|
+
display: grid;
|
|
179
|
+
gap: 9px;
|
|
180
|
+
border-bottom: 1px solid var(--border);
|
|
181
|
+
}
|
|
182
|
+
.grid2 {
|
|
183
|
+
display: grid;
|
|
184
|
+
grid-template-columns: 1fr 1fr;
|
|
185
|
+
gap: 8px;
|
|
186
|
+
}
|
|
187
|
+
.start-row {
|
|
188
|
+
display: grid;
|
|
189
|
+
grid-template-columns: 1fr auto;
|
|
190
|
+
gap: 8px;
|
|
191
|
+
align-items: end;
|
|
192
|
+
}
|
|
193
|
+
.start-options {
|
|
194
|
+
display: grid;
|
|
195
|
+
grid-template-columns: 1fr 1fr;
|
|
196
|
+
gap: 8px;
|
|
197
|
+
}
|
|
198
|
+
.start-options label[data-resume-field] {
|
|
199
|
+
grid-column: 1 / -1;
|
|
200
|
+
}
|
|
201
|
+
.session-header {
|
|
202
|
+
border-bottom: 1px solid var(--border);
|
|
203
|
+
padding: 8px 10px;
|
|
204
|
+
display: flex;
|
|
205
|
+
align-items: center;
|
|
206
|
+
justify-content: space-between;
|
|
207
|
+
gap: 8px;
|
|
208
|
+
min-height: 48px;
|
|
209
|
+
}
|
|
210
|
+
.session-header strong {
|
|
211
|
+
font-size: 13px;
|
|
212
|
+
font-weight: 600;
|
|
213
|
+
}
|
|
214
|
+
.session-header label {
|
|
215
|
+
width: 118px;
|
|
216
|
+
}
|
|
217
|
+
.sessions, .state {
|
|
218
|
+
min-height: 0;
|
|
219
|
+
overflow-y: auto;
|
|
220
|
+
overflow-x: hidden;
|
|
221
|
+
padding: 10px;
|
|
222
|
+
display: grid;
|
|
223
|
+
grid-template-columns: minmax(0, 1fr);
|
|
224
|
+
align-content: start;
|
|
225
|
+
gap: 8px;
|
|
226
|
+
scrollbar-width: thin;
|
|
227
|
+
scrollbar-color: #3a3f4a transparent;
|
|
228
|
+
}
|
|
229
|
+
.sessions::-webkit-scrollbar, .state::-webkit-scrollbar { width: 8px; }
|
|
230
|
+
.sessions::-webkit-scrollbar-track, .state::-webkit-scrollbar-track { background: transparent; }
|
|
231
|
+
.sessions::-webkit-scrollbar-thumb, .state::-webkit-scrollbar-thumb { background: #3a3f4a; border-radius: 4px; }
|
|
232
|
+
.session {
|
|
233
|
+
border: 1px solid var(--border);
|
|
234
|
+
background: #111418;
|
|
235
|
+
border-radius: 8px;
|
|
236
|
+
padding: 9px;
|
|
237
|
+
display: grid;
|
|
238
|
+
grid-template-columns: minmax(0, 1fr);
|
|
239
|
+
gap: 7px;
|
|
240
|
+
cursor: pointer;
|
|
241
|
+
}
|
|
242
|
+
.session.active { border-color: var(--accent); }
|
|
243
|
+
.row {
|
|
244
|
+
display: flex;
|
|
245
|
+
align-items: center;
|
|
246
|
+
justify-content: space-between;
|
|
247
|
+
gap: 8px;
|
|
248
|
+
min-width: 0;
|
|
249
|
+
}
|
|
250
|
+
.row strong {
|
|
251
|
+
overflow: hidden;
|
|
252
|
+
text-overflow: ellipsis;
|
|
253
|
+
white-space: nowrap;
|
|
254
|
+
}
|
|
255
|
+
.badge {
|
|
256
|
+
font-family: var(--mono);
|
|
257
|
+
font-size: 11px;
|
|
258
|
+
border: 1px solid var(--border);
|
|
259
|
+
color: var(--muted);
|
|
260
|
+
padding: 2px 6px;
|
|
261
|
+
border-radius: 999px;
|
|
262
|
+
white-space: nowrap;
|
|
263
|
+
}
|
|
264
|
+
.badge.running { color: var(--ok); border-color: #3b7b44; }
|
|
265
|
+
.badge.working, .badge.busy { color: var(--warn); border-color: #6b5a20; }
|
|
266
|
+
.badge.stale, .badge.detached, .badge.idle { color: var(--muted); border-color: var(--border); }
|
|
267
|
+
.badge.exited { color: var(--danger); border-color: #87434a; }
|
|
268
|
+
.main {
|
|
269
|
+
min-height: 0;
|
|
270
|
+
min-width: 0;
|
|
271
|
+
overflow: hidden;
|
|
272
|
+
display: grid;
|
|
273
|
+
grid-template-rows: auto 1fr;
|
|
274
|
+
background: #0b0d10;
|
|
275
|
+
}
|
|
276
|
+
.toolbar {
|
|
277
|
+
min-height: 48px;
|
|
278
|
+
border-bottom: 1px solid var(--border);
|
|
279
|
+
display: flex;
|
|
280
|
+
align-items: center;
|
|
281
|
+
gap: 8px;
|
|
282
|
+
padding: 8px 10px;
|
|
283
|
+
overflow: hidden;
|
|
284
|
+
}
|
|
285
|
+
.toolbar .title {
|
|
286
|
+
flex: 1 1 auto;
|
|
287
|
+
min-width: 0;
|
|
288
|
+
display: grid;
|
|
289
|
+
gap: 2px;
|
|
290
|
+
}
|
|
291
|
+
.toolbar .title strong {
|
|
292
|
+
overflow: hidden;
|
|
293
|
+
text-overflow: ellipsis;
|
|
294
|
+
white-space: nowrap;
|
|
295
|
+
}
|
|
296
|
+
.quick {
|
|
297
|
+
display: flex;
|
|
298
|
+
gap: 8px;
|
|
299
|
+
flex-wrap: nowrap;
|
|
300
|
+
align-items: center;
|
|
301
|
+
flex: 0 0 auto;
|
|
302
|
+
}
|
|
303
|
+
/* Toolbar edge toggles for collapsing the side panels. */
|
|
304
|
+
.icon-btn {
|
|
305
|
+
flex: 0 0 auto;
|
|
306
|
+
width: 30px;
|
|
307
|
+
height: 30px;
|
|
308
|
+
padding: 0;
|
|
309
|
+
display: inline-flex;
|
|
310
|
+
align-items: center;
|
|
311
|
+
justify-content: center;
|
|
312
|
+
font-size: 15px;
|
|
313
|
+
line-height: 1;
|
|
314
|
+
color: var(--muted);
|
|
315
|
+
background: transparent;
|
|
316
|
+
border: 1px solid var(--border);
|
|
317
|
+
border-radius: 7px;
|
|
318
|
+
cursor: pointer;
|
|
319
|
+
}
|
|
320
|
+
.icon-btn:hover { color: var(--text); border-color: var(--accent); }
|
|
321
|
+
/* Compact actions dropdown so the top bar stays a single tidy row. */
|
|
322
|
+
.menu-wrap { position: relative; display: inline-flex; }
|
|
323
|
+
.menu-btn { white-space: nowrap; }
|
|
324
|
+
.menu {
|
|
325
|
+
position: fixed;
|
|
326
|
+
z-index: 1000;
|
|
327
|
+
min-width: 150px;
|
|
328
|
+
padding: 5px;
|
|
329
|
+
display: grid;
|
|
330
|
+
gap: 2px;
|
|
331
|
+
background: var(--panel);
|
|
332
|
+
border: 1px solid var(--border);
|
|
333
|
+
border-radius: 8px;
|
|
334
|
+
box-shadow: 0 8px 24px rgba(0,0,0,.45);
|
|
335
|
+
}
|
|
336
|
+
.menu[hidden] { display: none; }
|
|
337
|
+
.menu button {
|
|
338
|
+
width: 100%;
|
|
339
|
+
text-align: left;
|
|
340
|
+
border: 0;
|
|
341
|
+
background: transparent;
|
|
342
|
+
padding: 7px 9px;
|
|
343
|
+
border-radius: 6px;
|
|
344
|
+
color: var(--text);
|
|
345
|
+
}
|
|
346
|
+
.menu button:hover { background: #1b1f26; }
|
|
347
|
+
.menu .divider {
|
|
348
|
+
height: 1px;
|
|
349
|
+
margin: 4px 2px;
|
|
350
|
+
background: var(--border);
|
|
351
|
+
}
|
|
352
|
+
.action-result {
|
|
353
|
+
position: fixed;
|
|
354
|
+
right: 16px;
|
|
355
|
+
top: 64px;
|
|
356
|
+
z-index: 1200;
|
|
357
|
+
width: min(520px, calc(100vw - 32px));
|
|
358
|
+
max-height: min(68vh, 640px);
|
|
359
|
+
display: grid;
|
|
360
|
+
grid-template-rows: auto minmax(0, 1fr);
|
|
361
|
+
background: var(--panel);
|
|
362
|
+
border: 1px solid var(--border);
|
|
363
|
+
border-radius: 8px;
|
|
364
|
+
box-shadow: 0 12px 32px rgba(0,0,0,.5);
|
|
365
|
+
overflow: hidden;
|
|
366
|
+
}
|
|
367
|
+
.action-result[hidden] { display: none; }
|
|
368
|
+
.action-result header {
|
|
369
|
+
display: flex;
|
|
370
|
+
align-items: center;
|
|
371
|
+
justify-content: space-between;
|
|
372
|
+
gap: 10px;
|
|
373
|
+
padding: 9px 10px;
|
|
374
|
+
border-bottom: 1px solid var(--border);
|
|
375
|
+
}
|
|
376
|
+
.action-result header strong { font-size: 13px; }
|
|
377
|
+
.action-result pre {
|
|
378
|
+
margin: 0;
|
|
379
|
+
padding: 10px;
|
|
380
|
+
overflow: auto;
|
|
381
|
+
white-space: pre-wrap;
|
|
382
|
+
word-break: break-word;
|
|
383
|
+
font-family: var(--mono);
|
|
384
|
+
font-size: 11px;
|
|
385
|
+
color: var(--text);
|
|
386
|
+
background: #0d0f12;
|
|
387
|
+
}
|
|
388
|
+
/* Confirm dialog overlay */
|
|
389
|
+
.dialog-overlay {
|
|
390
|
+
position: fixed; inset: 0; z-index: 2000;
|
|
391
|
+
background: rgba(0,0,0,0.55);
|
|
392
|
+
display: flex; align-items: center; justify-content: center;
|
|
393
|
+
}
|
|
394
|
+
.dialog-overlay[hidden] { display: none; }
|
|
395
|
+
.dialog {
|
|
396
|
+
background: var(--panel); border: 1px solid var(--border);
|
|
397
|
+
border-radius: 10px; padding: 20px 22px;
|
|
398
|
+
min-width: 320px; max-width: 420px;
|
|
399
|
+
display: grid; gap: 14px;
|
|
400
|
+
box-shadow: 0 12px 40px rgba(0,0,0,.5);
|
|
401
|
+
}
|
|
402
|
+
.dialog h3 { margin: 0; font-size: 15px; }
|
|
403
|
+
.dialog .row { display: flex; gap: 12px; align-items: center; }
|
|
404
|
+
.dialog .row label { display: flex; gap: 8px; align-items: center; cursor: pointer; }
|
|
405
|
+
.dialog .btns { display: flex; gap: 8px; justify-content: flex-end; }
|
|
406
|
+
#terminal {
|
|
407
|
+
min-height: 0;
|
|
408
|
+
overflow: hidden;
|
|
409
|
+
padding: 8px;
|
|
410
|
+
}
|
|
411
|
+
#terminal .xterm {
|
|
412
|
+
cursor: default;
|
|
413
|
+
}
|
|
414
|
+
/* The terminal mirrors a tmux pane; keep xterm's own hidden helper textarea
|
|
415
|
+
off-screen, but DO show the rendered block cursor (positioned from tmux). */
|
|
416
|
+
#terminal .xterm-helper-textarea {
|
|
417
|
+
caret-color: transparent !important;
|
|
418
|
+
color: transparent !important;
|
|
419
|
+
background: transparent !important;
|
|
420
|
+
left: -10000px !important;
|
|
421
|
+
top: 0 !important;
|
|
422
|
+
width: 1px !important;
|
|
423
|
+
height: 1px !important;
|
|
424
|
+
opacity: 0 !important;
|
|
425
|
+
}
|
|
426
|
+
.card {
|
|
427
|
+
border: 1px solid var(--border);
|
|
428
|
+
border-radius: 8px;
|
|
429
|
+
background: #111418;
|
|
430
|
+
overflow: hidden;
|
|
431
|
+
}
|
|
432
|
+
.card.state-card {
|
|
433
|
+
min-height: 0;
|
|
434
|
+
max-height: min(34vh, 280px);
|
|
435
|
+
display: grid;
|
|
436
|
+
grid-template-rows: auto minmax(0, 1fr);
|
|
437
|
+
}
|
|
438
|
+
.card.state-card.state-card-collapsed {
|
|
439
|
+
grid-template-rows: auto 0;
|
|
440
|
+
}
|
|
441
|
+
.card h2 {
|
|
442
|
+
margin: 0;
|
|
443
|
+
padding: 8px 10px;
|
|
444
|
+
border-bottom: 1px solid var(--border);
|
|
445
|
+
font-size: 13px;
|
|
446
|
+
font-weight: 600;
|
|
447
|
+
display: flex;
|
|
448
|
+
justify-content: space-between;
|
|
449
|
+
align-items: center;
|
|
450
|
+
gap: 8px;
|
|
451
|
+
}
|
|
452
|
+
.state-card-toggle {
|
|
453
|
+
width: 100%;
|
|
454
|
+
min-width: 0;
|
|
455
|
+
border: 0;
|
|
456
|
+
border-bottom: 1px solid var(--border);
|
|
457
|
+
border-radius: 0;
|
|
458
|
+
background: transparent;
|
|
459
|
+
color: inherit;
|
|
460
|
+
padding: 8px 10px;
|
|
461
|
+
display: flex;
|
|
462
|
+
align-items: center;
|
|
463
|
+
justify-content: space-between;
|
|
464
|
+
gap: 8px;
|
|
465
|
+
cursor: pointer;
|
|
466
|
+
text-align: left;
|
|
467
|
+
}
|
|
468
|
+
.state-card-toggle:hover { background: #151922; }
|
|
469
|
+
.state-card-toggle-title {
|
|
470
|
+
min-width: 0;
|
|
471
|
+
display: flex;
|
|
472
|
+
align-items: center;
|
|
473
|
+
gap: 8px;
|
|
474
|
+
overflow: hidden;
|
|
475
|
+
}
|
|
476
|
+
.state-card-toggle-title strong {
|
|
477
|
+
overflow: hidden;
|
|
478
|
+
text-overflow: ellipsis;
|
|
479
|
+
white-space: nowrap;
|
|
480
|
+
}
|
|
481
|
+
.state-card-chevron {
|
|
482
|
+
flex: 0 0 auto;
|
|
483
|
+
color: var(--muted);
|
|
484
|
+
font-size: 12px;
|
|
485
|
+
line-height: 1;
|
|
486
|
+
}
|
|
487
|
+
.state-card.state-card-collapsed .state-card-chevron {
|
|
488
|
+
transform: rotate(-90deg);
|
|
489
|
+
}
|
|
490
|
+
.card .body {
|
|
491
|
+
padding: 8px 10px;
|
|
492
|
+
display: grid;
|
|
493
|
+
gap: 6px;
|
|
494
|
+
}
|
|
495
|
+
.state-card .body {
|
|
496
|
+
min-height: 0;
|
|
497
|
+
max-height: min(28vh, 228px);
|
|
498
|
+
overflow-y: auto;
|
|
499
|
+
overflow-x: hidden;
|
|
500
|
+
align-content: start;
|
|
501
|
+
scrollbar-width: thin;
|
|
502
|
+
scrollbar-color: #3a3f4a transparent;
|
|
503
|
+
}
|
|
504
|
+
.state-card.state-card-collapsed .body {
|
|
505
|
+
display: none;
|
|
506
|
+
}
|
|
507
|
+
.state-card .body::-webkit-scrollbar { width: 8px; }
|
|
508
|
+
.state-card .body::-webkit-scrollbar-track { background: transparent; }
|
|
509
|
+
.state-card .body::-webkit-scrollbar-thumb { background: #3a3f4a; border-radius: 4px; }
|
|
510
|
+
.item {
|
|
511
|
+
display: grid;
|
|
512
|
+
gap: 2px;
|
|
513
|
+
font-size: 12px;
|
|
514
|
+
color: var(--muted);
|
|
515
|
+
border-bottom: 1px solid #242932;
|
|
516
|
+
padding-bottom: 6px;
|
|
517
|
+
}
|
|
518
|
+
.item:last-child { border-bottom: 0; padding-bottom: 0; }
|
|
519
|
+
.item strong { color: var(--text); font-size: 12px; }
|
|
520
|
+
.item span { overflow-wrap: anywhere; }
|
|
521
|
+
.mono { font-family: var(--mono); }
|
|
522
|
+
.empty { color: var(--muted); font-size: 12px; }
|
|
523
|
+
.sec-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); padding: 6px 10px 2px; display: flex; align-items: center; gap: 6px; }
|
|
524
|
+
#terminal { display: flex; flex-direction: column; }
|
|
525
|
+
</style>
|
|
526
|
+
</head>
|
|
527
|
+
<body>
|
|
528
|
+
<div class="app">
|
|
529
|
+
<aside class="sidebar">
|
|
530
|
+
<div class="brand">
|
|
531
|
+
<div>
|
|
532
|
+
<strong>hello-cc</strong>
|
|
533
|
+
<div class="path" id="rootPath"></div>
|
|
534
|
+
</div>
|
|
535
|
+
<span id="connState" data-i18n="conn.offline">offline</span>
|
|
536
|
+
</div>
|
|
537
|
+
<div class="form" style="padding-top:10px;padding-bottom:10px">
|
|
538
|
+
<div class="grid2">
|
|
539
|
+
<label><span data-i18n="project">Project</span><select id="projectSelect"></select></label>
|
|
540
|
+
<label><span data-i18n="language">Language</span><select id="langSelect"><option value="en">English</option><option value="zh">中文</option></select></label>
|
|
541
|
+
</div>
|
|
542
|
+
<label><span data-i18n="projectPath">Project path</span><input id="projectPath" data-i18n-placeholder="projectPathPlaceholder" placeholder="/path/to/project"></label>
|
|
543
|
+
<button id="addProjectBtn" type="button" data-i18n="registerProject">Register Project</button>
|
|
544
|
+
</div>
|
|
545
|
+
<form class="form" id="startForm">
|
|
546
|
+
<div class="start-row">
|
|
547
|
+
<label><span data-i18n="newSession">New session</span><select id="kind"><option value="codex">codex</option><option value="claude">claude</option><option value="shell" data-i18n="kind.shell">shell</option></select></label>
|
|
548
|
+
<button class="primary" type="submit" data-i18n="start">Start</button>
|
|
549
|
+
</div>
|
|
550
|
+
<div class="start-options">
|
|
551
|
+
<label><span data-i18n="mode">Mode</span><select id="startMode"><option value="new" data-i18n="mode.new">new</option><option value="resume" data-i18n="mode.resume">resume</option><option value="last" data-i18n="mode.last">last</option><option value="continue" data-i18n="mode.continue">continue</option></select></label>
|
|
552
|
+
<label data-resume-field><span data-i18n="session">Session</span><select id="resumeSelect"></select></label>
|
|
553
|
+
<label data-resume-field data-resume-custom style="display:none"><span data-i18n="sessionId">Session id</span><input id="resumeArg" data-i18n-placeholder="sessionIdPlaceholder" placeholder="session id or name"></label>
|
|
554
|
+
</div>
|
|
555
|
+
</form>
|
|
556
|
+
<div class="session-header">
|
|
557
|
+
<strong data-i18n="sessions">Sessions</strong>
|
|
558
|
+
<label><span data-i18n="view">View</span><select id="sessionKindFilter"><option value="all" data-i18n="kind.all">all</option><option value="claude">claude</option><option value="codex">codex</option><option value="shell" data-i18n="kind.shell">shell</option><option value="other" data-i18n="kind.other">other</option></select></label>
|
|
559
|
+
</div>
|
|
560
|
+
<div class="sessions" id="sessions"></div>
|
|
561
|
+
</aside>
|
|
562
|
+
|
|
563
|
+
<main class="main">
|
|
564
|
+
<div class="toolbar">
|
|
565
|
+
<div class="title">
|
|
566
|
+
<strong id="activeTitle" data-i18n="noSessionSelected">No session selected</strong>
|
|
567
|
+
<span class="path" id="activeMeta" data-i18n="startOrSelect">Start or select a session from the left panel</span>
|
|
568
|
+
</div>
|
|
569
|
+
<div class="quick" id="quickBar">
|
|
570
|
+
<div class="menu-wrap">
|
|
571
|
+
<button class="menu-btn" id="actionsBtn" type="button" aria-haspopup="true" aria-expanded="false"><span data-i18n="actions">Actions</span> ▾</button>
|
|
572
|
+
<div class="menu" id="actionsMenu" hidden>
|
|
573
|
+
<button data-action="state" data-i18n="action.state">state</button>
|
|
574
|
+
<button data-action="status" data-i18n="action.status">status</button>
|
|
575
|
+
<button data-action="inbox" data-i18n="action.inbox">inbox</button>
|
|
576
|
+
<button data-action="task-next" data-i18n="action.claimNextTask">claim next task</button>
|
|
577
|
+
<button data-action="heartbeat" data-i18n="action.renewHeartbeat">renew heartbeat</button>
|
|
578
|
+
<div class="divider" role="separator"></div>
|
|
579
|
+
<button data-action="register" data-i18n="action.reregister">re-register peer</button>
|
|
580
|
+
<button data-terminal-action="status" data-i18n="action.runStatusTerminal">run status in terminal</button>
|
|
581
|
+
</div>
|
|
582
|
+
</div>
|
|
583
|
+
<button class="danger" id="stopBtn" type="button" data-i18n="stop">stop</button>
|
|
584
|
+
</div>
|
|
585
|
+
</div>
|
|
586
|
+
<div id="terminal" style="min-height:0;flex:1"></div>
|
|
587
|
+
<div id="detectedPanel" style="display:none;overflow:auto;flex:1"></div>
|
|
588
|
+
</main>
|
|
589
|
+
|
|
590
|
+
<section class="action-result" id="actionResult" hidden aria-live="polite">
|
|
591
|
+
<header>
|
|
592
|
+
<strong id="actionResultTitle"></strong>
|
|
593
|
+
<button id="actionResultClose" type="button" aria-label="Close">×</button>
|
|
594
|
+
</header>
|
|
595
|
+
<pre id="actionResultBody"></pre>
|
|
596
|
+
</section>
|
|
597
|
+
|
|
598
|
+
<aside class="inspector">
|
|
599
|
+
<div class="brand">
|
|
600
|
+
<strong data-i18n="projectState">Project State</strong>
|
|
601
|
+
<button id="refreshBtn" type="button" data-i18n="refresh">Refresh</button>
|
|
602
|
+
</div>
|
|
603
|
+
<div class="state" id="state"></div>
|
|
604
|
+
</aside>
|
|
605
|
+
|
|
606
|
+
<button class="edge-toggle edge-left" id="toggleLeft" type="button" data-i18n-title="collapseSidebar" data-i18n-aria="toggleLeftSidebar" title="Collapse sidebar" aria-label="Toggle left sidebar">⟨</button>
|
|
607
|
+
<button class="edge-toggle edge-right" id="toggleRight" type="button" data-i18n-title="collapseStatePanel" data-i18n-aria="toggleRightPanel" title="Collapse state panel" aria-label="Toggle right panel">⟩</button>
|
|
608
|
+
<div class="edge-resizer edge-resizer-left" id="resizeLeft" role="separator" aria-orientation="vertical" data-i18n-aria="resizeLeftSidebar" data-i18n-title="resizeLeftSidebar" aria-label="Resize left sidebar" title="Resize left sidebar"></div>
|
|
609
|
+
<div class="edge-resizer edge-resizer-right" id="resizeRight" role="separator" aria-orientation="vertical" data-i18n-aria="resizeRightPanel" data-i18n-title="resizeRightPanel" aria-label="Resize right panel" title="Resize right panel"></div>
|
|
610
|
+
</div>
|
|
611
|
+
|
|
612
|
+
<div class="dialog-overlay" id="stopDialog" hidden>
|
|
613
|
+
<div class="dialog">
|
|
614
|
+
<h3 id="stopDialogTitle">Stop session?</h3>
|
|
615
|
+
<div class="path" id="stopDialogMeta" style="font-size:12px"></div>
|
|
616
|
+
<div class="row"><label><input type="checkbox" id="stopKillCb"> <span id="stopKillLabel" data-i18n="dialog.killTmux">Also kill tmux session</span></label></div>
|
|
617
|
+
<div class="btns">
|
|
618
|
+
<button id="stopCancelBtn" type="button" data-i18n="dialog.cancel">Cancel</button>
|
|
619
|
+
<button class="danger" id="stopConfirmBtn" type="button" data-i18n="stop">Stop</button>
|
|
620
|
+
</div>
|
|
621
|
+
</div>
|
|
622
|
+
</div>
|
|
623
|
+
|
|
624
|
+
<script src="/assets/xterm.js"></script>
|
|
625
|
+
<script>
|
|
626
|
+
const initialParams = new URLSearchParams(location.search);
|
|
627
|
+
const token = initialParams.get('token') || '';
|
|
628
|
+
const headers = token ? { Authorization: 'Bearer ' + token } : {};
|
|
629
|
+
let currentProject = initialParams.get('project') || initialParams.get('root') || '';
|
|
630
|
+
let projects = [];
|
|
631
|
+
let sessionKindFilter = initialParams.get('kind') || 'all';
|
|
632
|
+
let sessions = []; // managed (PTY) sessions
|
|
633
|
+
let detected = []; // coordination-only peers (from hooks/watcher)
|
|
634
|
+
let resumableCache = []; // provider sessions available to resume (from /api/resumable)
|
|
635
|
+
let active = null; // active managed session id
|
|
636
|
+
let activeDetected = null; // active detected peer id
|
|
637
|
+
let activeType = 'managed'; // 'managed' | 'detected'
|
|
638
|
+
let activePeerTtl = 600;
|
|
639
|
+
let lastStateNow = 0;
|
|
640
|
+
let lastStateRoot = '';
|
|
641
|
+
let ws = null;
|
|
642
|
+
let wsReconnectTimer = null;
|
|
643
|
+
const i18n = {
|
|
644
|
+
en: {
|
|
645
|
+
language: 'Language',
|
|
646
|
+
project: 'Project',
|
|
647
|
+
projectPath: 'Project path',
|
|
648
|
+
projectPathPlaceholder: '/path/to/project',
|
|
649
|
+
registerProject: 'Register Project',
|
|
650
|
+
newSession: 'New session',
|
|
651
|
+
start: 'Start',
|
|
652
|
+
mode: 'Mode',
|
|
653
|
+
'mode.new': 'new',
|
|
654
|
+
'mode.resume': 'resume',
|
|
655
|
+
'mode.last': 'last',
|
|
656
|
+
'mode.continue': 'continue',
|
|
657
|
+
session: 'Session',
|
|
658
|
+
sessionId: 'Session id',
|
|
659
|
+
sessionIdPlaceholder: 'session id or name',
|
|
660
|
+
sessions: 'Sessions',
|
|
661
|
+
view: 'View',
|
|
662
|
+
'kind.all': 'all',
|
|
663
|
+
'kind.shell': 'shell',
|
|
664
|
+
'kind.other': 'other',
|
|
665
|
+
noSessionSelected: 'No session selected',
|
|
666
|
+
startOrSelect: 'Start or select a session from the left panel',
|
|
667
|
+
actions: 'Actions',
|
|
668
|
+
'action.state': 'state',
|
|
669
|
+
'action.inbox': 'inbox',
|
|
670
|
+
'action.claimNextTask': 'claim next task',
|
|
671
|
+
'action.status': 'status',
|
|
672
|
+
'action.renewHeartbeat': 'renew heartbeat',
|
|
673
|
+
'action.reregister': 're-register peer',
|
|
674
|
+
'action.runStatusTerminal': 'run status in terminal',
|
|
675
|
+
actionResult: 'Action Result',
|
|
676
|
+
stop: 'stop',
|
|
677
|
+
projectState: 'Project State',
|
|
678
|
+
refresh: 'Refresh',
|
|
679
|
+
managed: 'Managed',
|
|
680
|
+
detected: 'Detected',
|
|
681
|
+
running: 'running',
|
|
682
|
+
noActiveSessions: 'No active sessions.',
|
|
683
|
+
startOneAbove: 'Start one above',
|
|
684
|
+
orRunTerminal: 'or run in any terminal:',
|
|
685
|
+
noDetectedPeers: 'No detected peers.',
|
|
686
|
+
viewFilter: 'View filter',
|
|
687
|
+
noTasks: 'No tasks.',
|
|
688
|
+
noPeers: 'No peers.',
|
|
689
|
+
noActiveLocks: 'No active locks.',
|
|
690
|
+
noMessages: 'No messages.',
|
|
691
|
+
noTimelineItems: 'No timeline items.',
|
|
692
|
+
nextAction: 'Next Action',
|
|
693
|
+
timeline: 'Timeline',
|
|
694
|
+
messages: 'Messages',
|
|
695
|
+
peers: 'Peers',
|
|
696
|
+
tasks: 'Tasks',
|
|
697
|
+
locks: 'Locks',
|
|
698
|
+
owner: 'owner',
|
|
699
|
+
assignee: 'assignee',
|
|
700
|
+
task: 'task',
|
|
701
|
+
all: 'all',
|
|
702
|
+
next: 'next',
|
|
703
|
+
finish: 'finish',
|
|
704
|
+
warnings: 'warnings',
|
|
705
|
+
noImmediateAction: 'No immediate coordination action',
|
|
706
|
+
detectedSession: 'Detected Session',
|
|
707
|
+
sendMessage: 'Send Message',
|
|
708
|
+
messageHelp: "Message will appear in the peer's <code>hcc msg inbox</code> and be injected on next hook fire.",
|
|
709
|
+
messageBodyPlaceholder: 'Message body...',
|
|
710
|
+
send: 'Send',
|
|
711
|
+
peer: 'peer',
|
|
712
|
+
kind: 'kind',
|
|
713
|
+
status: 'status',
|
|
714
|
+
cwd: 'cwd',
|
|
715
|
+
pid: 'pid',
|
|
716
|
+
unknown: 'unknown',
|
|
717
|
+
lastSeen: 'last seen',
|
|
718
|
+
secondsAgo: 's ago',
|
|
719
|
+
age: 'age',
|
|
720
|
+
branch: 'branch',
|
|
721
|
+
runtime: 'runtime',
|
|
722
|
+
thread: 'thread',
|
|
723
|
+
reply: 'reply',
|
|
724
|
+
'status.active': 'active',
|
|
725
|
+
'status.stale': 'stale',
|
|
726
|
+
'status.unknown': 'unknown',
|
|
727
|
+
'status.running': 'running',
|
|
728
|
+
'status.idle': 'idle',
|
|
729
|
+
'status.exited': 'exited',
|
|
730
|
+
'status.claimed': 'claimed',
|
|
731
|
+
'status.review': 'review',
|
|
732
|
+
'status.blocked': 'blocked',
|
|
733
|
+
'status.done': 'done',
|
|
734
|
+
'status.abandoned': 'abandoned',
|
|
735
|
+
'status.pending': 'pending',
|
|
736
|
+
'conn.offline': 'offline',
|
|
737
|
+
'conn.online': 'online',
|
|
738
|
+
'conn.attached': 'attached',
|
|
739
|
+
'conn.reconnecting': 'reconnecting...',
|
|
740
|
+
'conn.coordinationOnly': 'coordination only',
|
|
741
|
+
'conn.error': 'error',
|
|
742
|
+
show: 'Show',
|
|
743
|
+
collapse: 'Collapse',
|
|
744
|
+
sidebar: 'sidebar',
|
|
745
|
+
statePanel: 'state panel',
|
|
746
|
+
dragToResize: 'drag to resize',
|
|
747
|
+
collapseSidebar: 'Collapse sidebar',
|
|
748
|
+
collapseStatePanel: 'Collapse state panel',
|
|
749
|
+
toggleLeftSidebar: 'Toggle left sidebar',
|
|
750
|
+
toggleRightPanel: 'Toggle right panel',
|
|
751
|
+
resizeLeftSidebar: 'Resize left sidebar',
|
|
752
|
+
resizeRightPanel: 'Resize right panel',
|
|
753
|
+
customSession: 'custom session id...',
|
|
754
|
+
'dialog.cancel': 'Cancel',
|
|
755
|
+
'dialog.killTmux': 'Also kill tmux session',
|
|
756
|
+
'action.stopPeer': 'Stop peer',
|
|
757
|
+
'action.restartPeer': 'Restart peer',
|
|
758
|
+
detectedPeer: 'detected peer'
|
|
759
|
+
},
|
|
760
|
+
zh: {
|
|
761
|
+
language: '语言',
|
|
762
|
+
project: '项目',
|
|
763
|
+
projectPath: '项目路径',
|
|
764
|
+
projectPathPlaceholder: '/项目/路径',
|
|
765
|
+
registerProject: '注册项目',
|
|
766
|
+
newSession: '新会话',
|
|
767
|
+
start: '启动',
|
|
768
|
+
mode: '模式',
|
|
769
|
+
'mode.new': '新建',
|
|
770
|
+
'mode.resume': '恢复',
|
|
771
|
+
'mode.last': '最近',
|
|
772
|
+
'mode.continue': '继续',
|
|
773
|
+
session: '会话',
|
|
774
|
+
sessionId: '会话 ID',
|
|
775
|
+
sessionIdPlaceholder: '会话 ID 或名称',
|
|
776
|
+
sessions: '会话',
|
|
777
|
+
view: '视图',
|
|
778
|
+
'kind.all': '全部',
|
|
779
|
+
'kind.shell': 'shell',
|
|
780
|
+
'kind.other': '其他',
|
|
781
|
+
noSessionSelected: '未选择会话',
|
|
782
|
+
startOrSelect: '从左侧面板启动或选择一个会话',
|
|
783
|
+
actions: '操作',
|
|
784
|
+
'action.state': '状态详情',
|
|
785
|
+
'action.inbox': '收件箱',
|
|
786
|
+
'action.claimNextTask': '认领下个任务',
|
|
787
|
+
'action.status': '状态',
|
|
788
|
+
'action.renewHeartbeat': '续期心跳',
|
|
789
|
+
'action.reregister': '重新注册协作方',
|
|
790
|
+
'action.runStatusTerminal': '在终端运行状态命令',
|
|
791
|
+
actionResult: '操作结果',
|
|
792
|
+
stop: '停止',
|
|
793
|
+
projectState: '项目状态',
|
|
794
|
+
refresh: '刷新',
|
|
795
|
+
managed: '托管',
|
|
796
|
+
detected: '检测到',
|
|
797
|
+
running: '运行中',
|
|
798
|
+
noActiveSessions: '没有活动会话。',
|
|
799
|
+
startOneAbove: '在上方启动一个会话',
|
|
800
|
+
orRunTerminal: '或在任意终端运行:',
|
|
801
|
+
noDetectedPeers: '没有检测到的协作方。',
|
|
802
|
+
viewFilter: '视图过滤',
|
|
803
|
+
noTasks: '没有任务。',
|
|
804
|
+
noPeers: '没有协作方。',
|
|
805
|
+
noActiveLocks: '没有活动锁。',
|
|
806
|
+
noMessages: '没有消息。',
|
|
807
|
+
noTimelineItems: '没有时间线条目。',
|
|
808
|
+
nextAction: '下一步',
|
|
809
|
+
timeline: '时间线',
|
|
810
|
+
messages: '消息',
|
|
811
|
+
peers: '协作方',
|
|
812
|
+
tasks: '任务',
|
|
813
|
+
locks: '锁',
|
|
814
|
+
owner: '负责人',
|
|
815
|
+
assignee: '执行者',
|
|
816
|
+
task: '任务',
|
|
817
|
+
all: '全部',
|
|
818
|
+
next: '下一步',
|
|
819
|
+
finish: '收尾',
|
|
820
|
+
warnings: '警告',
|
|
821
|
+
noImmediateAction: '当前没有需要立即处理的协作动作',
|
|
822
|
+
detectedSession: '检测到的会话',
|
|
823
|
+
sendMessage: '发送消息',
|
|
824
|
+
messageHelp: '消息会出现在该 peer 的 <code>hcc msg inbox</code> 中,并在下一次 hook 触发时注入。',
|
|
825
|
+
messageBodyPlaceholder: '消息内容...',
|
|
826
|
+
send: '发送',
|
|
827
|
+
peer: '协作方',
|
|
828
|
+
kind: '类型',
|
|
829
|
+
status: '状态',
|
|
830
|
+
cwd: '工作目录',
|
|
831
|
+
pid: 'PID',
|
|
832
|
+
unknown: '未知',
|
|
833
|
+
lastSeen: '最后出现',
|
|
834
|
+
secondsAgo: ' 秒前',
|
|
835
|
+
age: '时长',
|
|
836
|
+
branch: '分支',
|
|
837
|
+
runtime: '运行时',
|
|
838
|
+
thread: '线程',
|
|
839
|
+
reply: '回复',
|
|
840
|
+
'status.active': '活跃',
|
|
841
|
+
'status.stale': '过期',
|
|
842
|
+
'status.unknown': '未知',
|
|
843
|
+
'status.running': '运行中',
|
|
844
|
+
'status.idle': '空闲',
|
|
845
|
+
'status.exited': '已退出',
|
|
846
|
+
'status.claimed': '已认领',
|
|
847
|
+
'status.review': '审查中',
|
|
848
|
+
'status.blocked': '阻塞',
|
|
849
|
+
'status.done': '完成',
|
|
850
|
+
'status.abandoned': '已放弃',
|
|
851
|
+
'status.pending': '待处理',
|
|
852
|
+
'conn.offline': '离线',
|
|
853
|
+
'conn.online': '在线',
|
|
854
|
+
'conn.attached': '已连接',
|
|
855
|
+
'conn.reconnecting': '重连中...',
|
|
856
|
+
'conn.coordinationOnly': '仅协作',
|
|
857
|
+
'conn.error': '错误',
|
|
858
|
+
show: '显示',
|
|
859
|
+
collapse: '折叠',
|
|
860
|
+
sidebar: '侧栏',
|
|
861
|
+
statePanel: '状态面板',
|
|
862
|
+
dragToResize: '拖动调整大小',
|
|
863
|
+
collapseSidebar: '折叠侧栏',
|
|
864
|
+
collapseStatePanel: '折叠状态面板',
|
|
865
|
+
toggleLeftSidebar: '切换左侧栏',
|
|
866
|
+
toggleRightPanel: '切换右侧面板',
|
|
867
|
+
resizeLeftSidebar: '调整左侧栏宽度',
|
|
868
|
+
resizeRightPanel: '调整右侧面板宽度',
|
|
869
|
+
customSession: '自定义会话 ID...',
|
|
870
|
+
'dialog.cancel': '取消',
|
|
871
|
+
'dialog.killTmux': '同时终止 tmux 会话',
|
|
872
|
+
'action.stopPeer': '停止协作方',
|
|
873
|
+
'action.restartPeer': '恢复协作方',
|
|
874
|
+
detectedPeer: '检测到的协作方'
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
let lang = localStorage.getItem('hcc.lang') || ((navigator.language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en');
|
|
878
|
+
if (!i18n[lang]) lang = 'en';
|
|
879
|
+
|
|
880
|
+
function tr(key, fallback = '') {
|
|
881
|
+
return i18n[lang]?.[key] || i18n.en[key] || fallback || key;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function setText(id, key) {
|
|
885
|
+
const el = document.getElementById(id);
|
|
886
|
+
if (el) el.textContent = tr(key);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function connText(key) {
|
|
890
|
+
const el = document.getElementById('connState');
|
|
891
|
+
if (el) {
|
|
892
|
+
el.dataset.stateKey = key;
|
|
893
|
+
el.textContent = tr('conn.' + key);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function applyLanguage() {
|
|
898
|
+
document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en';
|
|
899
|
+
localStorage.setItem('hcc.lang', lang);
|
|
900
|
+
const sel = document.getElementById('langSelect');
|
|
901
|
+
if (sel) sel.value = lang;
|
|
902
|
+
document.querySelectorAll('[data-i18n]').forEach((el) => { el.textContent = tr(el.dataset.i18n); });
|
|
903
|
+
document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => { el.placeholder = tr(el.dataset.i18nPlaceholder); });
|
|
904
|
+
document.querySelectorAll('[data-i18n-title]').forEach((el) => { el.title = tr(el.dataset.i18nTitle); });
|
|
905
|
+
document.querySelectorAll('[data-i18n-aria]').forEach((el) => { el.setAttribute('aria-label', tr(el.dataset.i18nAria)); });
|
|
906
|
+
if (!active && !activeDetected) {
|
|
907
|
+
setText('activeTitle', 'noSessionSelected');
|
|
908
|
+
setText('activeMeta', 'startOrSelect');
|
|
909
|
+
}
|
|
910
|
+
const stateKey = document.getElementById('connState')?.dataset.stateKey || 'offline';
|
|
911
|
+
connText(stateKey);
|
|
912
|
+
syncStartModeOptions();
|
|
913
|
+
renderSections();
|
|
914
|
+
if (activeType === 'detected' && activeDetected) {
|
|
915
|
+
const draft = document.getElementById('detMsg')?.value || '';
|
|
916
|
+
const peer = detected.find((p) => p.id === activeDetected) || { id: activeDetected };
|
|
917
|
+
document.getElementById('activeTitle').textContent = activeDetected + ' (' + tr('detected') + ')';
|
|
918
|
+
renderDetectedPanel(peer);
|
|
919
|
+
const detMsg = document.getElementById('detMsg');
|
|
920
|
+
if (detMsg && draft) detMsg.value = draft;
|
|
921
|
+
} else if (activeType === 'managed' && active) {
|
|
922
|
+
const meta = sessions.find((s) => s.id === active);
|
|
923
|
+
if (meta) {
|
|
924
|
+
document.getElementById('activeTitle').textContent = sessionPeerId(meta) || active;
|
|
925
|
+
document.getElementById('activeMeta').textContent = sessionMetaText(meta);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
if (lastStateRoot) refreshCurrentState().catch(console.error);
|
|
929
|
+
syncToggleIcons();
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function requestQuery(extra = {}) {
|
|
933
|
+
const params = new URLSearchParams();
|
|
934
|
+
if (token) params.set('token', token);
|
|
935
|
+
if (currentProject) params.set('root', currentProject);
|
|
936
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
937
|
+
if (value !== undefined && value !== null && value !== '') params.set(key, value);
|
|
938
|
+
}
|
|
939
|
+
const text = params.toString();
|
|
940
|
+
return text ? '?' + text : '';
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function updateLocationProject() {
|
|
944
|
+
const params = new URLSearchParams(location.search);
|
|
945
|
+
if (token) params.set('token', token);
|
|
946
|
+
if (currentProject) params.set('project', currentProject);
|
|
947
|
+
if (sessionKindFilter && sessionKindFilter !== 'all') params.set('kind', sessionKindFilter);
|
|
948
|
+
history.replaceState(null, '', location.pathname + '?' + params.toString());
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const term = new Terminal({
|
|
952
|
+
cursorBlink: true,
|
|
953
|
+
cursorInactiveStyle: 'outline',
|
|
954
|
+
cursorStyle: 'bar',
|
|
955
|
+
convertEol: true,
|
|
956
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
|
957
|
+
fontSize: 13,
|
|
958
|
+
theme: { background: '#0b0d10', foreground: '#eef2f6', cursor: '#7dd3fc', cursorAccent: '#0b0d10' }
|
|
959
|
+
});
|
|
960
|
+
term.open(document.getElementById('terminal'));
|
|
961
|
+
|
|
962
|
+
// Accurate terminal sizing via character measurement
|
|
963
|
+
function measureCharSize() {
|
|
964
|
+
const el = document.getElementById('terminal');
|
|
965
|
+
const canvas = document.createElement('canvas');
|
|
966
|
+
const ctx2d = canvas.getContext('2d');
|
|
967
|
+
ctx2d.font = '13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';
|
|
968
|
+
const m = ctx2d.measureText('M');
|
|
969
|
+
return { w: m.width || 8, h: 17 };
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function resizeTerm() {
|
|
973
|
+
const el = document.getElementById('terminal');
|
|
974
|
+
const { w, h } = measureCharSize();
|
|
975
|
+
const cols = Math.max(60, Math.floor((el.clientWidth - 16) / w));
|
|
976
|
+
const rows = Math.max(10, Math.floor((el.clientHeight - 16) / h));
|
|
977
|
+
term.resize(cols, rows);
|
|
978
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
979
|
+
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
window.addEventListener('resize', resizeTerm);
|
|
983
|
+
setTimeout(resizeTerm, 80);
|
|
984
|
+
|
|
985
|
+
term.onData((data) => {
|
|
986
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
987
|
+
ws.send(JSON.stringify({ type: 'input', data }));
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
async function api(path, options = {}) {
|
|
992
|
+
const res = await fetch(path + (path.includes('?') ? '&' : '?') + requestQuery().slice(1), {
|
|
993
|
+
...options,
|
|
994
|
+
headers: { 'Content-Type': 'application/json', ...headers, ...(options.headers || {}) }
|
|
995
|
+
});
|
|
996
|
+
const json = await res.json();
|
|
997
|
+
if (!res.ok) throw new Error(json.error?.message || json.message || 'request failed');
|
|
998
|
+
return json;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function renderProjects() {
|
|
1002
|
+
const select = document.getElementById('projectSelect');
|
|
1003
|
+
select.innerHTML = projects.map((p) =>
|
|
1004
|
+
'<option value="' + esc(p.root) + '">' + esc((p.name || p.root) + ' · ' + p.root) + '</option>'
|
|
1005
|
+
).join('');
|
|
1006
|
+
if (currentProject) select.value = currentProject;
|
|
1007
|
+
document.getElementById('sessionKindFilter').value = sessionKindFilter;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function kindMatches(item) {
|
|
1011
|
+
const kind = ['claude', 'codex', 'shell'].includes(item.kind) ? item.kind : 'other';
|
|
1012
|
+
return sessionKindFilter === 'all' || kind === sessionKindFilter;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function syncStartModeOptions() {
|
|
1016
|
+
const kind = document.getElementById('kind').value;
|
|
1017
|
+
const modeSelect = document.getElementById('startMode');
|
|
1018
|
+
const current = modeSelect.value;
|
|
1019
|
+
const modes = kind === 'claude'
|
|
1020
|
+
? [['new', 'new'], ['resume', 'resume'], ['continue', 'continue']]
|
|
1021
|
+
: kind === 'codex'
|
|
1022
|
+
? [['new', 'new'], ['resume', 'resume'], ['last', 'last']]
|
|
1023
|
+
: [['new', 'new']];
|
|
1024
|
+
modeSelect.innerHTML = modes.map(([value]) => '<option value="' + value + '">' + tr('mode.' + value, value) + '</option>').join('');
|
|
1025
|
+
modeSelect.value = modes.some(([value]) => value === current) ? current : 'new';
|
|
1026
|
+
const isResume = modeSelect.value === 'resume';
|
|
1027
|
+
document.querySelector('[data-resume-field]:not([data-resume-custom])').style.display = isResume ? '' : 'none';
|
|
1028
|
+
if (isResume) loadResumable();
|
|
1029
|
+
else document.querySelector('[data-resume-custom]').style.display = 'none';
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Fetch provider sessions hcc knows about and fill the resume dropdown.
|
|
1033
|
+
async function loadResumable() {
|
|
1034
|
+
try { const d = await api('/api/resumable'); resumableCache = d.resumable || []; }
|
|
1035
|
+
catch { resumableCache = []; }
|
|
1036
|
+
populateResumeSelect();
|
|
1037
|
+
}
|
|
1038
|
+
function populateResumeSelect() {
|
|
1039
|
+
const kind = document.getElementById('kind').value;
|
|
1040
|
+
const sel = document.getElementById('resumeSelect');
|
|
1041
|
+
const prev = sel.value;
|
|
1042
|
+
const items = resumableCache.filter((r) => r.provider === kind);
|
|
1043
|
+
const opts = items.map((r) => {
|
|
1044
|
+
const resume = r.resume || r.session_id || r.session_name || '';
|
|
1045
|
+
const shortResume = resume.length > 14 ? resume.slice(0, 10) + '…' : resume;
|
|
1046
|
+
const label = (r.name && r.name !== resume ? r.name + ' · ' : '') + shortResume + ' (' + r.peer + ')';
|
|
1047
|
+
return '<option value="' + esc(resume) + '">' + esc(label) + '</option>';
|
|
1048
|
+
});
|
|
1049
|
+
opts.push('<option value="__custom__">' + esc(tr('customSession')) + '</option>');
|
|
1050
|
+
sel.innerHTML = opts.join('');
|
|
1051
|
+
if (prev && [...sel.options].some((o) => o.value === prev)) sel.value = prev;
|
|
1052
|
+
toggleResumeCustom();
|
|
1053
|
+
}
|
|
1054
|
+
function toggleResumeCustom() {
|
|
1055
|
+
const sel = document.getElementById('resumeSelect');
|
|
1056
|
+
document.querySelector('[data-resume-custom]').style.display = sel.value === '__custom__' ? '' : 'none';
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
async function loadProjects() {
|
|
1060
|
+
const data = await api('/api/projects');
|
|
1061
|
+
projects = data.projects || [];
|
|
1062
|
+
if (!currentProject) currentProject = data.current?.root || projects[0]?.root || '';
|
|
1063
|
+
renderProjects();
|
|
1064
|
+
updateLocationProject();
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
async function switchProject(root) {
|
|
1068
|
+
currentProject = root;
|
|
1069
|
+
updateLocationProject();
|
|
1070
|
+
active = null;
|
|
1071
|
+
activeDetected = null;
|
|
1072
|
+
activeType = 'managed';
|
|
1073
|
+
if (ws) { clearTimeout(wsReconnectTimer); ws.close(); ws = null; }
|
|
1074
|
+
term.reset();
|
|
1075
|
+
document.getElementById('activeTitle').textContent = tr('noSessionSelected');
|
|
1076
|
+
document.getElementById('activeMeta').textContent = tr('startOrSelect');
|
|
1077
|
+
await Promise.all([refreshSessions(), refreshDetected(), refreshState()]);
|
|
1078
|
+
const first = sessions.find(s => s.status === 'running');
|
|
1079
|
+
if (first) connectManaged(first.id);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function esc(text) {
|
|
1083
|
+
return String(text ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function fmtTime(ts) {
|
|
1087
|
+
if (!ts) return '';
|
|
1088
|
+
return new Date(ts * 1000).toLocaleTimeString();
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function badgeClass(status) {
|
|
1092
|
+
return String(status || '').toLowerCase().replace(/[^a-z0-9_-]/g, '');
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function fmtAge(age) {
|
|
1096
|
+
const n = Number(age);
|
|
1097
|
+
if (!Number.isFinite(n)) return '?';
|
|
1098
|
+
return Math.max(0, Math.round(n)) + (lang === 'zh' ? '秒' : 's');
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function sessionPeerId(session) {
|
|
1102
|
+
return session?.peer_id || session?.id || '';
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function sessionRuntimeNote(session) {
|
|
1106
|
+
const peerId = sessionPeerId(session);
|
|
1107
|
+
return session?.id && peerId && session.id !== peerId ? tr('runtime') + '=' + session.id + ' · ' : '';
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function sessionMetaText(session) {
|
|
1111
|
+
return sessionRuntimeNote(session) + (session.command || '') + (session.pane ? ' · ' + session.pane : '') + ' · ' + (session.cwd || '');
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function statusText(status) {
|
|
1115
|
+
const value = String(status || 'unknown');
|
|
1116
|
+
return tr('status.' + value.toLowerCase(), value);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function lockLabel(lock) {
|
|
1120
|
+
const base = lock.base_resource || lock.resource || '';
|
|
1121
|
+
const scope = lock.scope || '*';
|
|
1122
|
+
return scope === '*' ? base : base + ' [' + scope + ']';
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function managedPeerId(id) {
|
|
1126
|
+
return sessionPeerId(sessions.find((s) => s.id === id)) || id;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function peerIsActive(peer, basisNow = lastStateNow) {
|
|
1130
|
+
const age = Number(peer?.age_sec);
|
|
1131
|
+
if (Number.isFinite(age)) return age <= activePeerTtl;
|
|
1132
|
+
const seen = Number(peer?.last_seen_at || 0);
|
|
1133
|
+
const t = Number(basisNow || 0) || Math.floor(Date.now() / 1000);
|
|
1134
|
+
return seen > 0 && (t - seen) <= activePeerTtl;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function detectedPeerCanStop(peer) {
|
|
1138
|
+
const status = String(peer?.status || '').toLowerCase();
|
|
1139
|
+
if (['exited', 'detached'].includes(status)) return false;
|
|
1140
|
+
return peerIsActive(peer);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function peerStateView(peer, runtime = null, basisNow = lastStateNow) {
|
|
1144
|
+
const activity = peer?.status || 'unknown';
|
|
1145
|
+
const liveness = peerIsActive(peer, basisNow) ? 'active' : 'stale';
|
|
1146
|
+
const age = fmtAge(peer?.age_sec);
|
|
1147
|
+
const branch = peer?.branch ? ' ' + tr('branch') + '=' + peer.branch : '';
|
|
1148
|
+
if (runtime) {
|
|
1149
|
+
return {
|
|
1150
|
+
label: runtime.status || 'running',
|
|
1151
|
+
detail: tr('peer') + '=' + statusText(activity) + ' ' + statusText(liveness) + ' ' + tr('age') + '=' + age + branch
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
if (liveness === 'stale') {
|
|
1155
|
+
return {
|
|
1156
|
+
label: 'stale',
|
|
1157
|
+
detail: tr('lastSeen') + '=' + statusText(activity) + ' ' + tr('age') + '=' + age + branch
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
return {
|
|
1161
|
+
label: activity,
|
|
1162
|
+
detail: statusText(liveness) + ' ' + tr('age') + '=' + age + branch
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function taskOwnerStateText(task) {
|
|
1167
|
+
if (!task?.owner) return '';
|
|
1168
|
+
if (task.owner_stale) {
|
|
1169
|
+
if (task.takeover_ready) return 'owner_state=stale/no-lock';
|
|
1170
|
+
const locks = Number(task.related_lock_count || 0);
|
|
1171
|
+
return locks ? 'owner_state=stale/locks=' + locks : 'owner_state=stale';
|
|
1172
|
+
}
|
|
1173
|
+
if (task.owner_active) return 'owner_state=active';
|
|
1174
|
+
return '';
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function bodyPinned(el) {
|
|
1178
|
+
if (!el) return false;
|
|
1179
|
+
return el.scrollTop + el.clientHeight >= el.scrollHeight - 8;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function stateCardCollapsed(section) {
|
|
1183
|
+
return localStorage.getItem('hcc.stateCard.' + section + '.collapsed') === '1';
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function stateCardHtml(section, title, count, bodyHtml) {
|
|
1187
|
+
const collapsed = stateCardCollapsed(section);
|
|
1188
|
+
return \`
|
|
1189
|
+
<div class="card state-card \${collapsed ? 'state-card-collapsed' : ''}" data-section="\${esc(section)}">
|
|
1190
|
+
<button class="state-card-toggle" type="button" aria-expanded="\${collapsed ? 'false' : 'true'}">
|
|
1191
|
+
<span class="state-card-toggle-title"><strong>\${esc(title)}</strong> <span class="badge">\${esc(count)}</span></span>
|
|
1192
|
+
<span class="state-card-chevron">⌄</span>
|
|
1193
|
+
</button>
|
|
1194
|
+
<div class="body">\${bodyHtml}</div>
|
|
1195
|
+
</div>\`;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function bindStateCardToggles() {
|
|
1199
|
+
document.querySelectorAll('.state-card[data-section] .state-card-toggle').forEach((button) => {
|
|
1200
|
+
button.addEventListener('click', () => {
|
|
1201
|
+
const card = button.closest('.state-card[data-section]');
|
|
1202
|
+
if (!card) return;
|
|
1203
|
+
const section = card.dataset.section || '';
|
|
1204
|
+
const collapsed = !card.classList.contains('state-card-collapsed');
|
|
1205
|
+
card.classList.toggle('state-card-collapsed', collapsed);
|
|
1206
|
+
button.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
|
1207
|
+
localStorage.setItem('hcc.stateCard.' + section + '.collapsed', collapsed ? '1' : '0');
|
|
1208
|
+
});
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function renderTimelineItem(item) {
|
|
1213
|
+
const meta = [
|
|
1214
|
+
item.source + ':' + item.source_id,
|
|
1215
|
+
item.task_id ? tr('task') + ' #' + item.task_id : '',
|
|
1216
|
+
item.thread_id ? tr('thread') + ' #' + item.thread_id : '',
|
|
1217
|
+
item.direction || '',
|
|
1218
|
+
fmtTime(item.ts)
|
|
1219
|
+
].filter(Boolean).join(' · ');
|
|
1220
|
+
return \`
|
|
1221
|
+
<div class="item timeline-item">
|
|
1222
|
+
<strong>\${esc(item.title || item.kind || item.source)} <span class="badge">\${esc(item.kind || item.source)}</span></strong>
|
|
1223
|
+
<span>\${esc(meta)}</span>
|
|
1224
|
+
\${item.text ? '<span>' + esc(item.text) + '</span>' : ''}
|
|
1225
|
+
</div>\`;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
document.getElementById('projectSelect').addEventListener('change', (event) => {
|
|
1229
|
+
switchProject(event.target.value).catch(console.error);
|
|
1230
|
+
});
|
|
1231
|
+
document.getElementById('langSelect').addEventListener('change', (event) => {
|
|
1232
|
+
lang = event.target.value === 'zh' ? 'zh' : 'en';
|
|
1233
|
+
applyLanguage();
|
|
1234
|
+
});
|
|
1235
|
+
document.getElementById('sessionKindFilter').addEventListener('change', (event) => {
|
|
1236
|
+
sessionKindFilter = event.target.value || 'all';
|
|
1237
|
+
updateLocationProject();
|
|
1238
|
+
renderSections();
|
|
1239
|
+
});
|
|
1240
|
+
document.getElementById('addProjectBtn').addEventListener('click', async () => {
|
|
1241
|
+
const input = document.getElementById('projectPath');
|
|
1242
|
+
const root = input.value.trim();
|
|
1243
|
+
if (!root) return;
|
|
1244
|
+
await api('/api/projects', { method: 'POST', body: JSON.stringify({ root }) });
|
|
1245
|
+
input.value = '';
|
|
1246
|
+
await loadProjects();
|
|
1247
|
+
await switchProject(root);
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
// ── Sessions rendering (managed + detected) ──────────────────────────
|
|
1251
|
+
function renderSections() {
|
|
1252
|
+
const box = document.getElementById('sessions');
|
|
1253
|
+
const visibleSessions = sessions.filter(kindMatches);
|
|
1254
|
+
const visibleDetected = detected.filter(kindMatches);
|
|
1255
|
+
const filterNote = sessionKindFilter === 'all' ? '' : '<br><br>' + esc(tr('viewFilter')) + ': ' + esc(sessionKindFilter);
|
|
1256
|
+
const manHtml = visibleSessions.length
|
|
1257
|
+
? visibleSessions.map((s) => {
|
|
1258
|
+
const peerId = sessionPeerId(s);
|
|
1259
|
+
return \`
|
|
1260
|
+
<div class="session \${active === s.id && activeType === 'managed' ? 'active' : ''}" data-id="\${esc(s.id)}" data-type="managed">
|
|
1261
|
+
<div class="row"><strong>\${esc(peerId)}</strong><span class="badge \${badgeClass(s.status)}">\${esc(statusText(s.status))}</span></div>
|
|
1262
|
+
<div class="row"><span class="badge">\${esc(s.kind)}</span><span class="badge \${s.type === 'external' || s.type === 'tmux' ? 'warn' : ''}">\${s.type === 'external' ? 'external' : s.type === 'tmux' ? 'tmux' : 'pty'}</span></div>
|
|
1263
|
+
<div class="path">\${esc(sessionRuntimeNote(s) + (s.command || ''))}</div>
|
|
1264
|
+
</div>\`;
|
|
1265
|
+
}).join('')
|
|
1266
|
+
: '<div class="empty">' + esc(tr('noActiveSessions')) + filterNote + '<br><br>' + esc(tr('startOneAbove')) + '<br>' + esc(tr('orRunTerminal')) + '<br><code>hcc peer start X -- claude</code></div>';
|
|
1267
|
+
|
|
1268
|
+
const detHtml = visibleDetected.length
|
|
1269
|
+
? visibleDetected.map((p) => {
|
|
1270
|
+
const state = peerStateView(p);
|
|
1271
|
+
const canStop = detectedPeerCanStop(p);
|
|
1272
|
+
return \`
|
|
1273
|
+
<div class="session \${activeDetected === p.id && activeType === 'detected' ? 'active' : ''}" data-id="\${esc(p.id)}" data-type="detected">
|
|
1274
|
+
<div class="row">
|
|
1275
|
+
<strong>\${esc(p.id)}</strong>
|
|
1276
|
+
<div style="display:flex;gap:6px;align-items:center">
|
|
1277
|
+
<span class="badge" style="color:var(--warn);border-color:#6b5a20">\${esc(tr('detected'))}</span>
|
|
1278
|
+
\${canStop ? \`
|
|
1279
|
+
<button class="stop-detected-btn" data-action="stop-detected" data-id="\${esc(p.id)}" title="\${esc(tr('action.stopPeer'))}" aria-label="\${esc(tr('action.stopPeer')) + ' ' + esc(p.id)}" type="button" style="flex:0 0 auto;width:22px;height:22px;padding:0;display:inline-flex;align-items:center;justify-content:center;font-size:12px;line-height:1;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:5px;cursor:pointer">✕</button>
|
|
1280
|
+
\` : \`
|
|
1281
|
+
<button data-action="restart-detected" data-id="\${esc(p.id)}" title="\${esc(tr('action.restartPeer'))}" aria-label="\${esc(tr('action.restartPeer')) + ' ' + esc(p.id)}" type="button" style="flex:0 0 auto;width:22px;height:22px;padding:0;display:inline-flex;align-items:center;justify-content:center;font-size:12px;line-height:1;color:var(--ok);background:transparent;border:1px solid var(--ok);border-radius:5px;cursor:pointer">↻</button>
|
|
1282
|
+
\`}
|
|
1283
|
+
</div>
|
|
1284
|
+
</div>
|
|
1285
|
+
<div class="row"><span class="badge">\${esc(p.kind)}</span><span class="badge \${badgeClass(state.label)}" title="\${esc(state.detail)}">\${esc(statusText(state.label))}</span></div>
|
|
1286
|
+
<div class="path" title="\${esc(p.worktree || '')}">\${esc((p.worktree || '').split('/').slice(-2).join('/'))}</div>
|
|
1287
|
+
</div>\`;
|
|
1288
|
+
}).join('')
|
|
1289
|
+
: '<div class="empty">' + esc(tr('noDetectedPeers')) + filterNote + '</div>';
|
|
1290
|
+
|
|
1291
|
+
const savedScroll = box.scrollTop;
|
|
1292
|
+
box.innerHTML = \`
|
|
1293
|
+
<div class="sec-label">\${esc(tr('managed'))} <span class="badge">\${visibleSessions.filter(s=>s.status==='running').length} \${esc(tr('running'))}</span></div>
|
|
1294
|
+
\${manHtml}
|
|
1295
|
+
<div class="sec-label" style="margin-top:10px">\${esc(tr('detected'))} <span class="badge" style="color:var(--warn)">\${visibleDetected.length}</span></div>
|
|
1296
|
+
\${detHtml}
|
|
1297
|
+
\`;
|
|
1298
|
+
box.scrollTop = savedScroll;
|
|
1299
|
+
box.querySelectorAll('.session[data-type="managed"]').forEach((el) => {
|
|
1300
|
+
el.addEventListener('click', () => connectManaged(el.dataset.id));
|
|
1301
|
+
});
|
|
1302
|
+
box.querySelectorAll('.session[data-type="detected"]').forEach((el) => {
|
|
1303
|
+
el.addEventListener('click', (e) => {
|
|
1304
|
+
if (e.target.closest('[data-action]')) return;
|
|
1305
|
+
connectDetected(el.dataset.id);
|
|
1306
|
+
});
|
|
1307
|
+
});
|
|
1308
|
+
box.querySelectorAll('[data-action="stop-detected"]').forEach((btn) => {
|
|
1309
|
+
btn.addEventListener('click', (e) => {
|
|
1310
|
+
e.stopPropagation();
|
|
1311
|
+
const peerId = btn.dataset.id;
|
|
1312
|
+
document.getElementById('stopDialogTitle').textContent = tr('stop') + ' ' + peerId + '?';
|
|
1313
|
+
stopKillCb.checked = false;
|
|
1314
|
+
document.getElementById('stopDialogMeta').textContent = tr('detectedPeer');
|
|
1315
|
+
stopDialog._action = 'detected';
|
|
1316
|
+
stopDialog._peerId = peerId;
|
|
1317
|
+
stopDialog.hidden = false;
|
|
1318
|
+
});
|
|
1319
|
+
});
|
|
1320
|
+
box.querySelectorAll('[data-action="restart-detected"]').forEach((btn) => {
|
|
1321
|
+
btn.addEventListener('click', async (e) => {
|
|
1322
|
+
e.stopPropagation();
|
|
1323
|
+
const peerId = btn.dataset.id;
|
|
1324
|
+
await api('/api/detected/' + encodeURIComponent(peerId) + '/restart', { method: 'POST', body: '{}' });
|
|
1325
|
+
await Promise.all([refreshSessions(), refreshDetected()]);
|
|
1326
|
+
});
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
async function refreshSessions() {
|
|
1331
|
+
const data = await api('/api/sessions');
|
|
1332
|
+
sessions = data.sessions || [];
|
|
1333
|
+
renderSections();
|
|
1334
|
+
if (active && activeType === 'managed') {
|
|
1335
|
+
const meta = sessions.find((s) => s.id === active);
|
|
1336
|
+
if (meta) {
|
|
1337
|
+
document.getElementById('activeTitle').textContent = sessionPeerId(meta) || active;
|
|
1338
|
+
document.getElementById('activeMeta').textContent = sessionMetaText(meta);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
connText('online');
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
async function refreshDetected() {
|
|
1345
|
+
try {
|
|
1346
|
+
const data = await api('/api/detected');
|
|
1347
|
+
activePeerTtl = Number(data.active_peer_ttl || activePeerTtl);
|
|
1348
|
+
lastStateNow = Number(data.now || lastStateNow);
|
|
1349
|
+
detected = data.detected || [];
|
|
1350
|
+
renderSections();
|
|
1351
|
+
} catch {}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// ── Project state panel ───────────────────────────────────────────────
|
|
1355
|
+
function renderState(data) {
|
|
1356
|
+
const stateRoot = data.root || '';
|
|
1357
|
+
document.getElementById('rootPath').textContent = stateRoot;
|
|
1358
|
+
activePeerTtl = Number(data.active_peer_ttl || activePeerTtl);
|
|
1359
|
+
lastStateNow = Number(data.now || lastStateNow);
|
|
1360
|
+
const state = document.getElementById('state');
|
|
1361
|
+
const preserveScroll = lastStateRoot === stateRoot;
|
|
1362
|
+
const savedStateScroll = preserveScroll ? state.scrollTop : 0;
|
|
1363
|
+
const savedCardScroll = preserveScroll
|
|
1364
|
+
? new Map(
|
|
1365
|
+
[...state.querySelectorAll('.state-card[data-section]')].map((card) => [
|
|
1366
|
+
card.dataset.section,
|
|
1367
|
+
{
|
|
1368
|
+
top: card.querySelector('.body')?.scrollTop || 0,
|
|
1369
|
+
pinned: bodyPinned(card.querySelector('.body'))
|
|
1370
|
+
}
|
|
1371
|
+
])
|
|
1372
|
+
)
|
|
1373
|
+
: new Map();
|
|
1374
|
+
const runtimeById = new Map();
|
|
1375
|
+
for (const session of sessions || []) {
|
|
1376
|
+
runtimeById.set(session.id, session);
|
|
1377
|
+
const peerId = sessionPeerId(session);
|
|
1378
|
+
if (peerId) runtimeById.set(peerId, session);
|
|
1379
|
+
}
|
|
1380
|
+
const tasksData = data.tasks || [];
|
|
1381
|
+
const peersData = data.peers || [];
|
|
1382
|
+
const locksData = data.locks || [];
|
|
1383
|
+
const messagesData = data.messages || [];
|
|
1384
|
+
const timelineData = data.timeline || [];
|
|
1385
|
+
const automation = data.automation || {};
|
|
1386
|
+
const nextAction = automation.next_action || {};
|
|
1387
|
+
const tasks = tasksData.map((t) => \`
|
|
1388
|
+
<div class="item"><strong>#\${t.id} \${esc(t.title)}</strong><span>\${esc(statusText(t.status))} \${esc(tr('owner'))}=\${esc(t.owner || '')} \${esc(tr('assignee'))}=\${esc(t.assignee || '')}\${taskOwnerStateText(t) ? ' · ' + esc(taskOwnerStateText(t)) : ''}</span></div>
|
|
1389
|
+
\`).join('') || '<div class="empty">' + esc(tr('noTasks')) + '</div>';
|
|
1390
|
+
const peers = peersData.map((a) => {
|
|
1391
|
+
const peerRuntime = runtimeById.get(a.id);
|
|
1392
|
+
const peerState = peerStateView(a, peerRuntime, data.now);
|
|
1393
|
+
return \`
|
|
1394
|
+
<div class="item"><strong>\${esc(a.id)} <span class="badge">\${esc(a.kind)}</span> <span class="badge \${badgeClass(peerState.label)}">\${esc(statusText(peerState.label))}</span></strong><span>\${esc(peerState.detail)}</span></div>
|
|
1395
|
+
\`;
|
|
1396
|
+
}).join('') || '<div class="empty">' + esc(tr('noPeers')) + '</div>';
|
|
1397
|
+
const locks = locksData.map((l) => \`
|
|
1398
|
+
<div class="item"><strong>\${esc(lockLabel(l))}</strong><span>\${esc(tr('owner'))}=\${esc(l.owner)} \${esc(tr('task'))}=\${l.task_id ? '#' + l.task_id : ''}</span></div>
|
|
1399
|
+
\`).join('') || '<div class="empty">' + esc(tr('noActiveLocks')) + '</div>';
|
|
1400
|
+
const messages = messagesData.map((m) => \`
|
|
1401
|
+
<div class="item"><strong>#\${m.id} \${esc(m.sender)} → \${esc(m.recipient || tr('all'))}\${m.reply_to ? ' ' + esc(tr('reply')) + ' #' + m.reply_to : ''}</strong><span>\${esc(m.body)}</span></div>
|
|
1402
|
+
\`).join('') || '<div class="empty">' + esc(tr('noMessages')) + '</div>';
|
|
1403
|
+
const timeline = timelineData.map(renderTimelineItem).join('') || '<div class="empty">' + esc(tr('noTimelineItems')) + '</div>';
|
|
1404
|
+
const actionLines = [
|
|
1405
|
+
'<div class="item"><strong>' + esc(statusText(automation.phase || 'idle')) + '</strong><span>' + esc(nextAction.reason || tr('noImmediateAction')) + '</span></div>',
|
|
1406
|
+
nextAction.command ? '<div class="item"><strong>' + esc(tr('next')) + '</strong><span class="mono">' + esc(nextAction.command) + '</span></div>' : '',
|
|
1407
|
+
(automation.finish_actions || []).length ? '<div class="item"><strong>' + esc(tr('finish')) + '</strong><span>' + esc(automation.finish_actions.map((a) => a.command).join(' | ')) + '</span></div>' : '',
|
|
1408
|
+
(automation.warnings || []).length ? '<div class="item"><strong>' + esc(tr('warnings')) + '</strong><span>' + esc(automation.warnings.join(' | ')) + '</span></div>' : ''
|
|
1409
|
+
].filter(Boolean).join('');
|
|
1410
|
+
state.innerHTML = [
|
|
1411
|
+
stateCardHtml('automation', tr('nextAction'), statusText(automation.phase || 'idle'), actionLines),
|
|
1412
|
+
stateCardHtml('timeline', tr('timeline'), timelineData.length, timeline),
|
|
1413
|
+
stateCardHtml('messages', tr('messages'), messagesData.length, messages),
|
|
1414
|
+
stateCardHtml('peers', tr('peers'), peersData.length, peers),
|
|
1415
|
+
stateCardHtml('tasks', tr('tasks'), tasksData.length, tasks),
|
|
1416
|
+
stateCardHtml('locks', tr('locks'), locksData.length, locks)
|
|
1417
|
+
].join('');
|
|
1418
|
+
state.scrollTop = savedStateScroll;
|
|
1419
|
+
for (const [section, saved] of savedCardScroll) {
|
|
1420
|
+
const body = state.querySelector('.state-card[data-section="' + section + '"] .body');
|
|
1421
|
+
if (!body) continue;
|
|
1422
|
+
if (section === 'timeline' && saved.pinned) body.scrollTop = body.scrollHeight;
|
|
1423
|
+
else body.scrollTop = saved.top;
|
|
1424
|
+
}
|
|
1425
|
+
bindStateCardToggles();
|
|
1426
|
+
lastStateRoot = stateRoot;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
async function refreshState() {
|
|
1430
|
+
const peer = active ? managedPeerId(active) : null;
|
|
1431
|
+
const p = peer ? '/api/state?peer=' + encodeURIComponent(peer) : '/api/state';
|
|
1432
|
+
const data = await api(p);
|
|
1433
|
+
renderState(data);
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
async function refreshDetectedState() {
|
|
1437
|
+
if (!activeDetected) return;
|
|
1438
|
+
const data = await api('/api/state?peer=' + encodeURIComponent(activeDetected));
|
|
1439
|
+
renderState(data);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
async function refreshCurrentState() {
|
|
1443
|
+
if (activeType === 'detected') return refreshDetectedState();
|
|
1444
|
+
return refreshState();
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// ── Connect to managed (PTY) session ─────────────────────────────────
|
|
1448
|
+
function connectManaged(id) {
|
|
1449
|
+
const meta = sessions.find((s) => s.id === id);
|
|
1450
|
+
const peerId = sessionPeerId(meta) || id;
|
|
1451
|
+
active = id;
|
|
1452
|
+
activeDetected = null;
|
|
1453
|
+
activeType = 'managed';
|
|
1454
|
+
renderSections();
|
|
1455
|
+
document.getElementById('activeTitle').textContent = peerId;
|
|
1456
|
+
document.getElementById('activeMeta').textContent = meta ? sessionMetaText(meta) : '';
|
|
1457
|
+
document.getElementById('terminal').style.display = '';
|
|
1458
|
+
document.getElementById('detectedPanel').style.display = 'none';
|
|
1459
|
+
document.getElementById('quickBar').style.display = '';
|
|
1460
|
+
|
|
1461
|
+
if (ws) { clearTimeout(wsReconnectTimer); ws.close(); }
|
|
1462
|
+
term.reset();
|
|
1463
|
+
openWs(id);
|
|
1464
|
+
refreshState().catch(console.error);
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
function openWs(id) {
|
|
1468
|
+
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
1469
|
+
ws = new WebSocket(proto + '://' + location.host + '/ws/terminal/' + encodeURIComponent(id) + requestQuery());
|
|
1470
|
+
ws.onopen = () => {
|
|
1471
|
+
resizeTerm();
|
|
1472
|
+
connText('attached');
|
|
1473
|
+
};
|
|
1474
|
+
ws.onmessage = (event) => {
|
|
1475
|
+
const msg = JSON.parse(event.data);
|
|
1476
|
+
// Check before any reset whether the viewport is pinned at the bottom
|
|
1477
|
+
const pinned = (() => {
|
|
1478
|
+
try { const b = term.buffer.active; return b.viewportY >= b.baseY; } catch { return true; }
|
|
1479
|
+
})();
|
|
1480
|
+
// The server streams the tmux pane's raw output, so xterm renders
|
|
1481
|
+
// incrementally (no reset/redraw → no flicker) and the program's own
|
|
1482
|
+
// escape sequences carry the cursor.
|
|
1483
|
+
if (msg.type === 'snapshot') { term.reset(); term.write(msg.data || '', () => { term.scrollToBottom(); }); }
|
|
1484
|
+
if (msg.type === 'data') { term.write(msg.data || '', pinned ? () => { term.scrollToBottom(); } : undefined); }
|
|
1485
|
+
if (msg.type === 'replace') { term.reset(); term.write(msg.data || '', pinned ? () => { term.scrollToBottom(); } : undefined); }
|
|
1486
|
+
if (msg.type === 'exit') { refreshSessions().catch(console.error); }
|
|
1487
|
+
};
|
|
1488
|
+
ws.onclose = () => {
|
|
1489
|
+
connText('reconnecting');
|
|
1490
|
+
// Auto-reconnect if session is still in the list and running
|
|
1491
|
+
wsReconnectTimer = setTimeout(() => {
|
|
1492
|
+
const s = sessions.find((s) => s.id === id);
|
|
1493
|
+
if (s && s.status === 'running' && active === id) openWs(id);
|
|
1494
|
+
else connText('online');
|
|
1495
|
+
}, 2000);
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// ── Connect to detected (coordination-only) peer ──────────────────────
|
|
1500
|
+
function connectDetected(id) {
|
|
1501
|
+
const peer = detected.find((p) => p.id === id);
|
|
1502
|
+
active = null;
|
|
1503
|
+
activeDetected = id;
|
|
1504
|
+
activeType = 'detected';
|
|
1505
|
+
if (ws) { clearTimeout(wsReconnectTimer); ws.close(); ws = null; }
|
|
1506
|
+
renderSections();
|
|
1507
|
+
document.getElementById('activeTitle').textContent = id + ' (' + tr('detected') + ')';
|
|
1508
|
+
document.getElementById('activeMeta').textContent = peer ? peer.kind + ' · ' + (peer.worktree || '') : '';
|
|
1509
|
+
document.getElementById('terminal').style.display = 'none';
|
|
1510
|
+
document.getElementById('detectedPanel').style.display = '';
|
|
1511
|
+
document.getElementById('quickBar').style.display = 'none';
|
|
1512
|
+
connText('coordinationOnly');
|
|
1513
|
+
renderDetectedPanel(peer || { id });
|
|
1514
|
+
refreshDetectedState().catch(console.error);
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function renderDetectedPanel(peer) {
|
|
1518
|
+
const dp = document.getElementById('detectedPanel');
|
|
1519
|
+
dp.innerHTML = \`
|
|
1520
|
+
<div style="padding:16px;display:grid;gap:12px">
|
|
1521
|
+
<div class="card">
|
|
1522
|
+
<h2>\${esc(tr('detectedSession'))}</h2>
|
|
1523
|
+
<div class="body">
|
|
1524
|
+
<div class="item"><strong>\${esc(tr('peer'))}</strong><span class="mono">\${esc(peer.id)}</span></div>
|
|
1525
|
+
<div class="item"><strong>\${esc(tr('kind'))}</strong><span>\${esc(peer.kind || '')}</span></div>
|
|
1526
|
+
<div class="item"><strong>\${esc(tr('status'))}</strong><span>\${esc(peer.status || '')}</span></div>
|
|
1527
|
+
<div class="item"><strong>\${esc(tr('cwd'))}</strong><span class="mono" style="font-size:11px">\${esc(peer.worktree || '')}</span></div>
|
|
1528
|
+
<div class="item"><strong>\${esc(tr('pid'))}</strong><span>\${esc(peer.pid || tr('unknown'))}</span></div>
|
|
1529
|
+
<div class="item"><strong>\${esc(tr('lastSeen'))}</strong><span>\${peer.age_sec != null ? esc(peer.age_sec + tr('secondsAgo')) : ''}</span></div>
|
|
1530
|
+
</div>
|
|
1531
|
+
</div>
|
|
1532
|
+
<div class="card">
|
|
1533
|
+
<h2>\${esc(tr('sendMessage'))}</h2>
|
|
1534
|
+
<div class="body" style="gap:8px">
|
|
1535
|
+
<div style="font-size:12px;color:var(--muted)">\${tr('messageHelp')}</div>
|
|
1536
|
+
<textarea id="detMsg" rows="3" style="width:100%;background:#0d0f12;border:1px solid var(--border);color:var(--text);border-radius:6px;padding:8px;font:inherit;resize:vertical" placeholder="\${esc(tr('messageBodyPlaceholder'))}"></textarea>
|
|
1537
|
+
<button class="primary" id="sendDetMsg">\${esc(tr('send'))}</button>
|
|
1538
|
+
</div>
|
|
1539
|
+
</div>
|
|
1540
|
+
</div>
|
|
1541
|
+
\`;
|
|
1542
|
+
document.getElementById('sendDetMsg').addEventListener('click', async () => {
|
|
1543
|
+
const body = document.getElementById('detMsg').value.trim();
|
|
1544
|
+
if (!body) return;
|
|
1545
|
+
await api('/api/detected/' + encodeURIComponent(peer.id) + '/msg', {
|
|
1546
|
+
method: 'POST',
|
|
1547
|
+
body: JSON.stringify({ body, from: 'web' })
|
|
1548
|
+
});
|
|
1549
|
+
document.getElementById('detMsg').value = '';
|
|
1550
|
+
await refreshDetectedState();
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// ── Helpers ───────────────────────────────────────────────────────────
|
|
1555
|
+
function sendLine(text) {
|
|
1556
|
+
if (!active || !ws || ws.readyState !== WebSocket.OPEN) return;
|
|
1557
|
+
ws.send(JSON.stringify({ type: 'input', data: text + '\\r' }));
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// ── Start session form ────────────────────────────────────────────────
|
|
1561
|
+
document.getElementById('kind').addEventListener('change', syncStartModeOptions);
|
|
1562
|
+
document.getElementById('startMode').addEventListener('change', syncStartModeOptions);
|
|
1563
|
+
document.getElementById('resumeSelect').addEventListener('change', toggleResumeCustom);
|
|
1564
|
+
syncStartModeOptions();
|
|
1565
|
+
|
|
1566
|
+
document.getElementById('startForm').addEventListener('submit', async (event) => {
|
|
1567
|
+
event.preventDefault();
|
|
1568
|
+
const kind = document.getElementById('kind').value;
|
|
1569
|
+
const mode = document.getElementById('startMode').value;
|
|
1570
|
+
const sel = document.getElementById('resumeSelect');
|
|
1571
|
+
const resume = (sel.value && sel.value !== '__custom__')
|
|
1572
|
+
? sel.value
|
|
1573
|
+
: document.getElementById('resumeArg').value.trim();
|
|
1574
|
+
if (mode === 'resume' && !resume) return;
|
|
1575
|
+
const payload = { kind, mode };
|
|
1576
|
+
if (mode === 'resume') payload.resume = resume;
|
|
1577
|
+
const data = await api('/api/sessions', { method: 'POST', body: JSON.stringify(payload) });
|
|
1578
|
+
await refreshSessions();
|
|
1579
|
+
connectManaged(data.session.id);
|
|
1580
|
+
});
|
|
1581
|
+
|
|
1582
|
+
function activePeerInfo() {
|
|
1583
|
+
if (!active) return null;
|
|
1584
|
+
const session = sessions.find((s) => s.id === active) || { id: active, kind: 'other', role: 'peer' };
|
|
1585
|
+
return {
|
|
1586
|
+
session,
|
|
1587
|
+
peerId: sessionPeerId(session) || active
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
function terminalCommandForAction(action, info) {
|
|
1592
|
+
const session = info.session || {};
|
|
1593
|
+
const peerId = info.peerId;
|
|
1594
|
+
const lines = {
|
|
1595
|
+
register: \`hcc register --peer \${peerId} --kind \${session.kind || 'other'} --role \${session.role || 'peer'}\`,
|
|
1596
|
+
inbox: \`hcc msg inbox --peer \${peerId}\`,
|
|
1597
|
+
'task-next': \`hcc task next --peer \${peerId}\`,
|
|
1598
|
+
state: \`hcc state --peer \${peerId}\`,
|
|
1599
|
+
status: \`hcc status --peer \${peerId}\`,
|
|
1600
|
+
heartbeat:\`hcc heartbeat --peer \${peerId} --renew-locks\`
|
|
1601
|
+
};
|
|
1602
|
+
return lines[action] || lines.status;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
function formatActionResult(result) {
|
|
1606
|
+
if (!result) return '';
|
|
1607
|
+
const data = result.data || {};
|
|
1608
|
+
if (result.action === 'status') {
|
|
1609
|
+
const tasks = (data.tasks || []).map((row) => row.status + ':' + row.n).join(', ') || 'none';
|
|
1610
|
+
return [
|
|
1611
|
+
data.root,
|
|
1612
|
+
'peers active=' + data.active_peers + ' stale=' + data.stale_peers,
|
|
1613
|
+
'tasks ' + tasks,
|
|
1614
|
+
'locks active=' + data.active_locks,
|
|
1615
|
+
'unread ' + (data.unread ?? 0)
|
|
1616
|
+
].filter(Boolean).join('\\n');
|
|
1617
|
+
}
|
|
1618
|
+
if (result.action === 'state') {
|
|
1619
|
+
const automation = data.automation || {};
|
|
1620
|
+
const next = automation.next_action || {};
|
|
1621
|
+
return [
|
|
1622
|
+
'phase: ' + (automation.phase || 'idle'),
|
|
1623
|
+
'next: ' + (next.command || next.kind || 'none'),
|
|
1624
|
+
'why: ' + (next.reason || tr('noImmediateAction')),
|
|
1625
|
+
...(automation.warnings || []).map((w) => 'warning: ' + w)
|
|
1626
|
+
].join('\\n');
|
|
1627
|
+
}
|
|
1628
|
+
if (result.action === 'inbox') {
|
|
1629
|
+
const messages = data.messages || [];
|
|
1630
|
+
return messages.length
|
|
1631
|
+
? messages.map((m) => '#' + m.id + ' ' + m.sender + ' -> ' + (m.recipient || 'all') + ': ' + m.body).join('\\n')
|
|
1632
|
+
: tr('noMessages');
|
|
1633
|
+
}
|
|
1634
|
+
if (result.action === 'task-next') {
|
|
1635
|
+
return data.task
|
|
1636
|
+
? (data.current ? 'current ' : 'claimed ') + '#' + data.task.id + ' ' + data.task.title + ' (' + data.task.status + ')'
|
|
1637
|
+
: result.summary;
|
|
1638
|
+
}
|
|
1639
|
+
if (result.action === 'task-takeover') {
|
|
1640
|
+
return data.task ? '#' + data.task.id + ' ' + data.task.title + ' -> ' + data.task.owner : result.summary;
|
|
1641
|
+
}
|
|
1642
|
+
if (result.action === 'lock-acquire') {
|
|
1643
|
+
return data.lock ? 'locked ' + lockLabel(data.lock) + ' by ' + data.lock.owner : result.summary;
|
|
1644
|
+
}
|
|
1645
|
+
if (result.action === 'lock-release') {
|
|
1646
|
+
return result.summary || JSON.stringify(data.result || data, null, 2);
|
|
1647
|
+
}
|
|
1648
|
+
return result.summary || JSON.stringify(result, null, 2);
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
function showActionResult(result) {
|
|
1652
|
+
const panel = document.getElementById('actionResult');
|
|
1653
|
+
document.getElementById('actionResultTitle').textContent = (result.action || tr('actionResult')) + ' · ' + (result.peer || '');
|
|
1654
|
+
document.getElementById('actionResultBody').textContent = formatActionResult(result);
|
|
1655
|
+
panel.hidden = false;
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
async function runPeerAction(action) {
|
|
1659
|
+
const info = activePeerInfo();
|
|
1660
|
+
if (!info) return;
|
|
1661
|
+
const payload = action === 'register'
|
|
1662
|
+
? { kind: info.session.kind || 'other', role: info.session.role || 'peer', worktree: info.session.cwd || currentProject }
|
|
1663
|
+
: action === 'heartbeat'
|
|
1664
|
+
? { renew_locks: true }
|
|
1665
|
+
: {};
|
|
1666
|
+
const readOnly = ['status', 'state', 'inbox'].includes(action);
|
|
1667
|
+
const result = await api('/api/peers/' + encodeURIComponent(info.peerId) + '/actions/' + encodeURIComponent(action), readOnly
|
|
1668
|
+
? {}
|
|
1669
|
+
: { method: 'POST', body: JSON.stringify(payload) });
|
|
1670
|
+
showActionResult(result);
|
|
1671
|
+
await Promise.all([refreshSessions(), refreshDetected(), refreshCurrentState()]);
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
document.querySelectorAll('[data-action]').forEach((button) => {
|
|
1675
|
+
button.addEventListener('click', () => {
|
|
1676
|
+
closeActionsMenu();
|
|
1677
|
+
runPeerAction(button.dataset.action).catch((err) => {
|
|
1678
|
+
showActionResult({ action: button.dataset.action, peer: activePeerInfo()?.peerId || '', summary: err.message, data: { error: err.message } });
|
|
1679
|
+
});
|
|
1680
|
+
});
|
|
1681
|
+
});
|
|
1682
|
+
document.querySelectorAll('[data-terminal-action]').forEach((button) => {
|
|
1683
|
+
button.addEventListener('click', () => {
|
|
1684
|
+
const info = activePeerInfo();
|
|
1685
|
+
if (!info) return;
|
|
1686
|
+
sendLine(terminalCommandForAction(button.dataset.terminalAction, info));
|
|
1687
|
+
closeActionsMenu();
|
|
1688
|
+
});
|
|
1689
|
+
});
|
|
1690
|
+
document.getElementById('actionResultClose').addEventListener('click', () => {
|
|
1691
|
+
document.getElementById('actionResult').hidden = true;
|
|
1692
|
+
});
|
|
1693
|
+
|
|
1694
|
+
// ── Actions dropdown (declutters the toolbar) ─────────────────────────
|
|
1695
|
+
const actionsBtn = document.getElementById('actionsBtn');
|
|
1696
|
+
const actionsMenu = document.getElementById('actionsMenu');
|
|
1697
|
+
function closeActionsMenu() {
|
|
1698
|
+
actionsMenu.hidden = true;
|
|
1699
|
+
actionsBtn.setAttribute('aria-expanded', 'false');
|
|
1700
|
+
}
|
|
1701
|
+
actionsBtn.addEventListener('click', (e) => {
|
|
1702
|
+
e.stopPropagation();
|
|
1703
|
+
const open = actionsMenu.hidden;
|
|
1704
|
+
actionsMenu.hidden = !open;
|
|
1705
|
+
actionsBtn.setAttribute('aria-expanded', String(open));
|
|
1706
|
+
if (open) {
|
|
1707
|
+
// Position as a fixed layer so it escapes the toolbar/main overflow clip.
|
|
1708
|
+
const r = actionsBtn.getBoundingClientRect();
|
|
1709
|
+
actionsMenu.style.top = (r.bottom + 6) + 'px';
|
|
1710
|
+
actionsMenu.style.left = Math.max(8, r.right - actionsMenu.offsetWidth) + 'px';
|
|
1711
|
+
}
|
|
1712
|
+
});
|
|
1713
|
+
document.addEventListener('click', (e) => {
|
|
1714
|
+
if (!actionsMenu.hidden && !e.target.closest('.menu-wrap')) closeActionsMenu();
|
|
1715
|
+
});
|
|
1716
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeActionsMenu(); });
|
|
1717
|
+
|
|
1718
|
+
// ── Collapsible side panels ───────────────────────────────────────────
|
|
1719
|
+
const appEl = document.querySelector('.app');
|
|
1720
|
+
const toggleLeftBtn = document.getElementById('toggleLeft');
|
|
1721
|
+
const toggleRightBtn = document.getElementById('toggleRight');
|
|
1722
|
+
const resizeLeftHandle = document.getElementById('resizeLeft');
|
|
1723
|
+
const resizeRightHandle = document.getElementById('resizeRight');
|
|
1724
|
+
const sideDefaults = { left: 320, right: 360 };
|
|
1725
|
+
const sideMin = { left: 220, right: 240 };
|
|
1726
|
+
const sideMax = { left: 560, right: 640 };
|
|
1727
|
+
const sideWidthKey = { left: 'hcc.sidebar.left.width', right: 'hcc.sidebar.right.width' };
|
|
1728
|
+
const dragState = { side: null, startX: 0, startWidth: 0, moved: false, suppressClick: false };
|
|
1729
|
+
function sideIsCollapsed(side) {
|
|
1730
|
+
return appEl.classList.contains(side + '-collapsed') || localStorage.getItem('hcc.collapse.' + side) === '1';
|
|
1731
|
+
}
|
|
1732
|
+
function storedSideWidth(side) {
|
|
1733
|
+
const raw = Number(localStorage.getItem(sideWidthKey[side]));
|
|
1734
|
+
return Number.isFinite(raw) && raw > 0 ? raw : sideDefaults[side];
|
|
1735
|
+
}
|
|
1736
|
+
function effectiveOppositeWidth(side) {
|
|
1737
|
+
const opposite = side === 'left' ? 'right' : 'left';
|
|
1738
|
+
return sideIsCollapsed(opposite) ? 0 : storedSideWidth(opposite);
|
|
1739
|
+
}
|
|
1740
|
+
function clampSideWidth(side, width) {
|
|
1741
|
+
const viewport = Math.max(640, window.innerWidth || appEl.clientWidth || 0);
|
|
1742
|
+
const opposite = effectiveOppositeWidth(side);
|
|
1743
|
+
const maxByViewport = Math.max(sideMin[side], viewport - opposite - 280);
|
|
1744
|
+
return Math.max(sideMin[side], Math.min(sideMax[side], maxByViewport, Math.round(width)));
|
|
1745
|
+
}
|
|
1746
|
+
function readSideWidth(side) {
|
|
1747
|
+
return clampSideWidth(side, storedSideWidth(side));
|
|
1748
|
+
}
|
|
1749
|
+
function setSideWidth(side, width, persist = true) {
|
|
1750
|
+
const value = clampSideWidth(side, width);
|
|
1751
|
+
appEl.style.setProperty('--' + side + '-width', value + 'px');
|
|
1752
|
+
if (persist) localStorage.setItem(sideWidthKey[side], String(value));
|
|
1753
|
+
return value;
|
|
1754
|
+
}
|
|
1755
|
+
function applySideWidths() {
|
|
1756
|
+
setSideWidth('left', readSideWidth('left'), false);
|
|
1757
|
+
setSideWidth('right', readSideWidth('right'), false);
|
|
1758
|
+
}
|
|
1759
|
+
function syncToggleIcons() {
|
|
1760
|
+
const l = appEl.classList.contains('left-collapsed');
|
|
1761
|
+
const r = appEl.classList.contains('right-collapsed');
|
|
1762
|
+
toggleLeftBtn.textContent = l ? '⟩' : '⟨';
|
|
1763
|
+
toggleLeftBtn.title = (l ? tr('show') : tr('collapse')) + ' ' + tr('sidebar') + '; ' + tr('dragToResize');
|
|
1764
|
+
toggleRightBtn.textContent = r ? '⟨' : '⟩';
|
|
1765
|
+
toggleRightBtn.title = (r ? tr('show') : tr('collapse')) + ' ' + tr('statePanel') + '; ' + tr('dragToResize');
|
|
1766
|
+
}
|
|
1767
|
+
function applyCollapseState() {
|
|
1768
|
+
appEl.classList.toggle('left-collapsed', localStorage.getItem('hcc.collapse.left') === '1');
|
|
1769
|
+
appEl.classList.toggle('right-collapsed', localStorage.getItem('hcc.collapse.right') === '1');
|
|
1770
|
+
applySideWidths();
|
|
1771
|
+
syncToggleIcons();
|
|
1772
|
+
}
|
|
1773
|
+
function toggleSide(side) {
|
|
1774
|
+
const cls = side + '-collapsed';
|
|
1775
|
+
const on = appEl.classList.toggle(cls);
|
|
1776
|
+
localStorage.setItem('hcc.collapse.' + side, on ? '1' : '0');
|
|
1777
|
+
applySideWidths();
|
|
1778
|
+
syncToggleIcons();
|
|
1779
|
+
// Refit the terminal after the grid transition so xterm uses the new width.
|
|
1780
|
+
setTimeout(() => { try { resizeTerm(); } catch {} }, 200);
|
|
1781
|
+
}
|
|
1782
|
+
function beginSideDrag(side, event) {
|
|
1783
|
+
if (event.button !== undefined && event.button !== 0) return;
|
|
1784
|
+
dragState.side = side;
|
|
1785
|
+
dragState.startX = event.clientX;
|
|
1786
|
+
dragState.startWidth = readSideWidth(side);
|
|
1787
|
+
dragState.moved = false;
|
|
1788
|
+
dragState.suppressClick = false;
|
|
1789
|
+
appEl.classList.add('resizing');
|
|
1790
|
+
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
1791
|
+
event.preventDefault();
|
|
1792
|
+
}
|
|
1793
|
+
function moveSideDrag(event) {
|
|
1794
|
+
if (!dragState.side) return;
|
|
1795
|
+
const delta = event.clientX - dragState.startX;
|
|
1796
|
+
if (!dragState.moved && Math.abs(delta) <= 3) return;
|
|
1797
|
+
dragState.moved = true;
|
|
1798
|
+
const width = dragState.side === 'left'
|
|
1799
|
+
? dragState.startWidth + delta
|
|
1800
|
+
: dragState.startWidth - delta;
|
|
1801
|
+
setSideWidth(dragState.side, width);
|
|
1802
|
+
if (dragState.side === 'left') {
|
|
1803
|
+
appEl.classList.remove('left-collapsed');
|
|
1804
|
+
localStorage.setItem('hcc.collapse.left', '0');
|
|
1805
|
+
} else {
|
|
1806
|
+
appEl.classList.remove('right-collapsed');
|
|
1807
|
+
localStorage.setItem('hcc.collapse.right', '0');
|
|
1808
|
+
}
|
|
1809
|
+
syncToggleIcons();
|
|
1810
|
+
try { resizeTerm(); } catch {}
|
|
1811
|
+
event.preventDefault();
|
|
1812
|
+
}
|
|
1813
|
+
function endSideDrag(event) {
|
|
1814
|
+
if (!dragState.side) return;
|
|
1815
|
+
dragState.suppressClick = dragState.moved;
|
|
1816
|
+
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
|
1817
|
+
dragState.side = null;
|
|
1818
|
+
appEl.classList.remove('resizing');
|
|
1819
|
+
setTimeout(() => { dragState.suppressClick = false; }, 0);
|
|
1820
|
+
try { resizeTerm(); } catch {}
|
|
1821
|
+
}
|
|
1822
|
+
function bindSideHandle(handle, side, clickAction = null) {
|
|
1823
|
+
handle.addEventListener('pointerdown', (event) => beginSideDrag(side, event));
|
|
1824
|
+
handle.addEventListener('pointermove', moveSideDrag);
|
|
1825
|
+
handle.addEventListener('pointerup', endSideDrag);
|
|
1826
|
+
handle.addEventListener('pointercancel', endSideDrag);
|
|
1827
|
+
handle.addEventListener('click', (event) => {
|
|
1828
|
+
if (dragState.suppressClick) {
|
|
1829
|
+
event.preventDefault();
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
if (clickAction) clickAction(side);
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1835
|
+
bindSideHandle(resizeLeftHandle, 'left');
|
|
1836
|
+
bindSideHandle(resizeRightHandle, 'right');
|
|
1837
|
+
bindSideHandle(toggleLeftBtn, 'left', toggleSide);
|
|
1838
|
+
bindSideHandle(toggleRightBtn, 'right', toggleSide);
|
|
1839
|
+
window.addEventListener('resize', () => {
|
|
1840
|
+
applySideWidths();
|
|
1841
|
+
setTimeout(() => { try { resizeTerm(); } catch {} }, 50);
|
|
1842
|
+
});
|
|
1843
|
+
applyCollapseState();
|
|
1844
|
+
|
|
1845
|
+
document.getElementById('stopBtn').addEventListener('click', () => {
|
|
1846
|
+
if (!active) return;
|
|
1847
|
+
const session = sessions.find((s) => s.id === active) || {};
|
|
1848
|
+
document.getElementById('stopDialogTitle').textContent = tr('stop') + ' ' + (sessionPeerId(session) || active) + '?';
|
|
1849
|
+
stopKillCb.checked = false;
|
|
1850
|
+
const meta = sessionMetaText(session);
|
|
1851
|
+
document.getElementById('stopDialogMeta').textContent = meta || '';
|
|
1852
|
+
stopDialog._action = null; stopDialog._peerId = null;
|
|
1853
|
+
stopDialog.hidden = false;
|
|
1854
|
+
});
|
|
1855
|
+
document.getElementById('refreshBtn').addEventListener('click', () => {
|
|
1856
|
+
Promise.all([refreshSessions(), refreshDetected(), refreshCurrentState()]).catch(console.error);
|
|
1857
|
+
});
|
|
1858
|
+
|
|
1859
|
+
applyLanguage();
|
|
1860
|
+
loadProjects().then(() => Promise.all([refreshSessions(), refreshDetected(), refreshState()])).then(() => {
|
|
1861
|
+
// Auto-connect to first running managed session on load
|
|
1862
|
+
if (!active && !activeDetected) {
|
|
1863
|
+
const first = sessions.find(s => s.status === 'running');
|
|
1864
|
+
if (first) connectManaged(first.id);
|
|
1865
|
+
}
|
|
1866
|
+
}).catch((err) => {
|
|
1867
|
+
connText('error');
|
|
1868
|
+
console.error(err);
|
|
1869
|
+
});
|
|
1870
|
+
// ── Auto-poll state ──────────────────────────────────────────────────
|
|
1871
|
+
setInterval(() => {
|
|
1872
|
+
refreshSessions().catch(console.error);
|
|
1873
|
+
refreshDetected().catch(console.error);
|
|
1874
|
+
if (activeType === 'detected') refreshDetectedState().catch(console.error);
|
|
1875
|
+
else if (active) refreshState().catch(console.error);
|
|
1876
|
+
}, 3000);
|
|
1877
|
+
setInterval(() => {
|
|
1878
|
+
loadProjects().catch(console.error);
|
|
1879
|
+
}, 8000);
|
|
1880
|
+
|
|
1881
|
+
// ── Stop confirmation dialog ───────────────────────────────────────
|
|
1882
|
+
const stopDialog = document.getElementById('stopDialog');
|
|
1883
|
+
const stopConfirmBtn = document.getElementById('stopConfirmBtn');
|
|
1884
|
+
const stopCancelBtn = document.getElementById('stopCancelBtn');
|
|
1885
|
+
const stopKillCb = document.getElementById('stopKillCb');
|
|
1886
|
+
|
|
1887
|
+
stopCancelBtn.addEventListener('click', () => { stopDialog.hidden = true; });
|
|
1888
|
+
stopDialog.addEventListener('click', (e) => { if (e.target === stopDialog) stopDialog.hidden = true; });
|
|
1889
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !stopDialog.hidden) stopDialog.hidden = true; });
|
|
1890
|
+
|
|
1891
|
+
stopConfirmBtn.addEventListener('click', async () => {
|
|
1892
|
+
stopDialog.hidden = true;
|
|
1893
|
+
const killTmux = stopKillCb.checked;
|
|
1894
|
+
if (stopDialog._action === 'detected') {
|
|
1895
|
+
const peerId = stopDialog._peerId;
|
|
1896
|
+
stopDialog._action = null; stopDialog._peerId = null;
|
|
1897
|
+
if (!peerId) return;
|
|
1898
|
+
await api('/api/detected/' + encodeURIComponent(peerId) + '/stop', { method: 'POST', body: JSON.stringify({ kill_tmux: killTmux }) });
|
|
1899
|
+
await Promise.all([refreshSessions(), refreshDetected()]);
|
|
1900
|
+
} else {
|
|
1901
|
+
if (!active) return;
|
|
1902
|
+
await api('/api/sessions/' + encodeURIComponent(active) + '/stop', { method: 'POST', body: JSON.stringify({ kill_tmux: killTmux }) });
|
|
1903
|
+
await refreshSessions();
|
|
1904
|
+
}
|
|
1905
|
+
});
|
|
1906
|
+
</script>
|
|
1907
|
+
</body>
|
|
1908
|
+
</html>`;
|
|
1909
|
+
}
|