@logicseek/hello-cc 0.1.5 → 0.1.7

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.
Files changed (88) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +21 -3
  3. package/README.zh-CN.md +21 -3
  4. package/assets/screenshots/web-console.png +0 -0
  5. package/bin/hcc.mjs +1943 -3519
  6. package/docs/README.md +2 -0
  7. package/docs/README.zh-CN.md +3 -1
  8. package/docs/architecture-split-review.md +217 -0
  9. package/docs/architecture.md +385 -0
  10. package/docs/architecture.zh-CN.md +364 -0
  11. package/docs/layout-split-stack-audit.md +298 -0
  12. package/lib/automation.mjs +8 -0
  13. package/lib/cli-args.mjs +1 -1
  14. package/lib/cli-runtime.mjs +45 -0
  15. package/lib/coordination-state.mjs +218 -0
  16. package/lib/core/coordination/automation.mjs +222 -0
  17. package/lib/core/coordination/locks.mjs +46 -0
  18. package/lib/core/coordination/messages.mjs +116 -0
  19. package/lib/core/coordination/tasks.mjs +240 -0
  20. package/lib/core/coordination/teams.mjs +73 -0
  21. package/lib/core/coordination/timeline.mjs +196 -0
  22. package/lib/core/peers/bindings.mjs +83 -0
  23. package/lib/core/peers/format.mjs +13 -0
  24. package/lib/core/peers/identity.mjs +61 -0
  25. package/lib/core/peers/liveness.mjs +90 -0
  26. package/lib/core/peers/reconcile.mjs +238 -0
  27. package/lib/core/peers/session.mjs +14 -0
  28. package/lib/core/sessions/launch.mjs +89 -0
  29. package/lib/db/schema.mjs +324 -0
  30. package/lib/db/stores/peers.mjs +231 -0
  31. package/lib/db-schema.mjs +7 -324
  32. package/lib/errors.mjs +1 -7
  33. package/lib/handoff.mjs +26 -0
  34. package/lib/help.mjs +3 -0
  35. package/lib/integrations/hooks.mjs +171 -0
  36. package/lib/integrations/peers/identity.mjs +208 -0
  37. package/lib/integrations/peers/processes.mjs +151 -0
  38. package/lib/integrations/providers.mjs +282 -0
  39. package/lib/integrations/shims/script.mjs +384 -0
  40. package/lib/integrations/shims.mjs +106 -0
  41. package/lib/json-file.mjs +1 -14
  42. package/lib/locks.mjs +10 -0
  43. package/lib/messages.mjs +3 -0
  44. package/lib/package-meta.mjs +6 -24
  45. package/lib/peer-bindings.mjs +14 -0
  46. package/lib/peer-format.mjs +4 -0
  47. package/lib/peer-identity.mjs +15 -0
  48. package/lib/project-context.mjs +6 -0
  49. package/lib/project-registry.mjs +7 -0
  50. package/lib/provider-commands.mjs +13 -0
  51. package/lib/release/package-meta.mjs +31 -0
  52. package/lib/release/release-notes.mjs +40 -0
  53. package/lib/release-notes.mjs +6 -40
  54. package/lib/runtime/client.mjs +42 -0
  55. package/lib/runtime/paths.mjs +45 -0
  56. package/lib/runtime/project-context.mjs +32 -0
  57. package/lib/runtime/projects.mjs +76 -0
  58. package/lib/runtime/state.mjs +128 -0
  59. package/lib/runtime-client.mjs +3 -0
  60. package/lib/runtime-paths.mjs +11 -45
  61. package/lib/runtime-state.mjs +11 -0
  62. package/lib/session-launch.mjs +17 -0
  63. package/lib/setup.mjs +17 -293
  64. package/lib/shared/errors.mjs +7 -0
  65. package/lib/shared/json-file.mjs +14 -0
  66. package/lib/shim-script.mjs +1 -384
  67. package/lib/state-render.mjs +5 -0
  68. package/lib/task-cli.mjs +45 -0
  69. package/lib/task-liveness.mjs +8 -0
  70. package/lib/task-store.mjs +3 -0
  71. package/lib/team-planning.mjs +7 -0
  72. package/lib/terminal/tmux.mjs +372 -0
  73. package/lib/timeline.mjs +12 -0
  74. package/lib/tmux.mjs +18 -0
  75. package/lib/ui/help.mjs +419 -0
  76. package/lib/ui/state-render.mjs +61 -0
  77. package/lib/web/http.mjs +55 -0
  78. package/lib/web/peer-actions.mjs +306 -0
  79. package/lib/web/runtime.mjs +154 -0
  80. package/lib/web/ui-template.mjs +2057 -0
  81. package/lib/web-http.mjs +7 -55
  82. package/lib/web-peer-actions.mjs +3 -0
  83. package/lib/web-runtime.mjs +17 -108
  84. package/lib/web-ui-template.mjs +3 -1909
  85. package/package.json +1 -1
  86. package/scripts/github-release.mjs +2 -2
  87. package/scripts/regression.mjs +2901 -79
  88. package/scripts/release-notes.mjs +2 -2
@@ -0,0 +1,2057 @@
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: 0; color: var(--muted); padding: 6px 10px 2px; display: flex; align-items: center; gap: 6px; }
524
+ .sec-spacer { flex: 1 1 auto; }
525
+ .sec-label button { height: 22px; padding: 0 7px; font-size: 10px; text-transform: none; letter-spacing: 0; }
526
+ #terminal { display: flex; flex-direction: column; }
527
+ </style>
528
+ </head>
529
+ <body>
530
+ <div class="app">
531
+ <aside class="sidebar">
532
+ <div class="brand">
533
+ <div>
534
+ <strong>hello-cc</strong>
535
+ <div class="path" id="rootPath"></div>
536
+ </div>
537
+ <span id="connState" data-i18n="conn.offline">offline</span>
538
+ </div>
539
+ <div class="form" style="padding-top:10px;padding-bottom:10px">
540
+ <div class="grid2">
541
+ <label><span data-i18n="project">Project</span><select id="projectSelect"></select></label>
542
+ <label><span data-i18n="language">Language</span><select id="langSelect"><option value="en">English</option><option value="zh">中文</option></select></label>
543
+ </div>
544
+ <label><span data-i18n="projectPath">Project path</span><input id="projectPath" data-i18n-placeholder="projectPathPlaceholder" placeholder="/path/to/project"></label>
545
+ <button id="addProjectBtn" type="button" data-i18n="registerProject">Register Project</button>
546
+ </div>
547
+ <form class="form" id="startForm">
548
+ <div class="start-row">
549
+ <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>
550
+ <button class="primary" type="submit" data-i18n="start">Start</button>
551
+ </div>
552
+ <div class="start-options">
553
+ <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>
554
+ <label data-resume-field><span data-i18n="session">Session</span><select id="resumeSelect"></select></label>
555
+ <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>
556
+ </div>
557
+ </form>
558
+ <div class="session-header">
559
+ <strong data-i18n="sessions">Sessions</strong>
560
+ <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>
561
+ </div>
562
+ <div class="sessions" id="sessions"></div>
563
+ </aside>
564
+
565
+ <main class="main">
566
+ <div class="toolbar">
567
+ <div class="title">
568
+ <strong id="activeTitle" data-i18n="noSessionSelected">No session selected</strong>
569
+ <span class="path" id="activeMeta" data-i18n="startOrSelect">Start or select a session from the left panel</span>
570
+ </div>
571
+ <div class="quick" id="quickBar">
572
+ <div class="menu-wrap">
573
+ <button class="menu-btn" id="actionsBtn" type="button" aria-haspopup="true" aria-expanded="false"><span data-i18n="actions">Actions</span> ▾</button>
574
+ <div class="menu" id="actionsMenu" hidden>
575
+ <button data-action="state" data-i18n="action.state">state</button>
576
+ <button data-action="status" data-i18n="action.status">status</button>
577
+ <button data-action="inbox" data-i18n="action.inbox">inbox</button>
578
+ <button data-action="task-next" data-i18n="action.claimNextTask">claim next task</button>
579
+ <button data-action="heartbeat" data-i18n="action.renewHeartbeat">renew heartbeat</button>
580
+ <div class="divider" role="separator"></div>
581
+ <button data-action="register" data-i18n="action.reregister">re-register peer</button>
582
+ <button data-terminal-action="status" data-i18n="action.runStatusTerminal">run status in terminal</button>
583
+ </div>
584
+ </div>
585
+ <button class="danger" id="stopBtn" type="button" data-i18n="stop">stop</button>
586
+ </div>
587
+ </div>
588
+ <div id="terminal" style="min-height:0;flex:1"></div>
589
+ <div id="detectedPanel" style="display:none;overflow:auto;flex:1"></div>
590
+ </main>
591
+
592
+ <section class="action-result" id="actionResult" hidden aria-live="polite">
593
+ <header>
594
+ <strong id="actionResultTitle"></strong>
595
+ <button id="actionResultClose" type="button" aria-label="Close">×</button>
596
+ </header>
597
+ <pre id="actionResultBody"></pre>
598
+ </section>
599
+
600
+ <aside class="inspector">
601
+ <div class="brand">
602
+ <strong data-i18n="projectState">Project State</strong>
603
+ <button id="refreshBtn" type="button" data-i18n="refresh">Refresh</button>
604
+ </div>
605
+ <div class="state" id="state"></div>
606
+ </aside>
607
+
608
+ <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>
609
+ <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>
610
+ <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>
611
+ <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>
612
+ </div>
613
+
614
+ <div class="dialog-overlay" id="stopDialog" hidden>
615
+ <div class="dialog">
616
+ <h3 id="stopDialogTitle">Stop session?</h3>
617
+ <div class="path" id="stopDialogMeta" style="font-size:12px"></div>
618
+ <div class="row"><label><input type="checkbox" id="stopKillCb"> <span id="stopKillLabel" data-i18n="dialog.killTmux">Also kill tmux session</span></label></div>
619
+ <div class="btns">
620
+ <button id="stopCancelBtn" type="button" data-i18n="dialog.cancel">Cancel</button>
621
+ <button class="danger" id="stopConfirmBtn" type="button" data-i18n="stop">Stop</button>
622
+ </div>
623
+ </div>
624
+ </div>
625
+
626
+ <script src="/assets/xterm.js"></script>
627
+ <script>
628
+ const initialParams = new URLSearchParams(location.search);
629
+ const token = initialParams.get('token') || '';
630
+ const headers = token ? { Authorization: 'Bearer ' + token } : {};
631
+ let currentProject = initialParams.get('project') || initialParams.get('root') || '';
632
+ let projects = [];
633
+ let sessionKindFilter = initialParams.get('kind') || 'all';
634
+ let sessions = []; // managed (PTY) sessions
635
+ let detected = []; // coordination-only peers (from hooks/watcher)
636
+ let resumableCache = []; // provider sessions available to resume (from /api/resumable)
637
+ let active = null; // active managed session id
638
+ let activeDetected = null; // active detected peer id
639
+ let activeType = 'managed'; // 'managed' | 'detected'
640
+ let showStaleDetected = localStorage.getItem('hcc.showStaleDetected') === '1';
641
+ let activePeerTtl = 600;
642
+ let lastStateNow = 0;
643
+ let lastStateRoot = '';
644
+ let ws = null;
645
+ let wsReconnectTimer = null;
646
+ let terminalHasContent = false;
647
+ let terminalLastDataAt = 0;
648
+ let terminalLastResizeAt = 0;
649
+ let terminalLastReplaceAt = 0;
650
+ let autoPollInFlight = false;
651
+ let projectPollInFlight = false;
652
+ const i18n = {
653
+ en: {
654
+ language: 'Language',
655
+ project: 'Project',
656
+ projectPath: 'Project path',
657
+ projectPathPlaceholder: '/path/to/project',
658
+ registerProject: 'Register Project',
659
+ newSession: 'New session',
660
+ start: 'Start',
661
+ mode: 'Mode',
662
+ 'mode.new': 'new',
663
+ 'mode.resume': 'resume',
664
+ 'mode.last': 'last',
665
+ 'mode.continue': 'continue',
666
+ session: 'Session',
667
+ sessionId: 'Session id',
668
+ sessionIdPlaceholder: 'session id or name',
669
+ sessions: 'Sessions',
670
+ view: 'View',
671
+ 'kind.all': 'all',
672
+ 'kind.shell': 'shell',
673
+ 'kind.other': 'other',
674
+ noSessionSelected: 'No session selected',
675
+ startOrSelect: 'Start or select a session from the left panel',
676
+ actions: 'Actions',
677
+ 'action.state': 'state',
678
+ 'action.inbox': 'inbox',
679
+ 'action.claimNextTask': 'claim next task',
680
+ 'action.status': 'status',
681
+ 'action.renewHeartbeat': 'renew heartbeat',
682
+ 'action.reregister': 're-register peer',
683
+ 'action.runStatusTerminal': 'run status in terminal',
684
+ actionResult: 'Action Result',
685
+ stop: 'stop',
686
+ projectState: 'Project State',
687
+ refresh: 'Refresh',
688
+ managed: 'Managed',
689
+ detected: 'Detected',
690
+ running: 'running',
691
+ noActiveSessions: 'No active sessions.',
692
+ startOneAbove: 'Start one above',
693
+ orRunTerminal: 'or run in any terminal:',
694
+ noDetectedPeers: 'No detected peers.',
695
+ viewFilter: 'View filter',
696
+ noTasks: 'No tasks.',
697
+ noPeers: 'No peers.',
698
+ noActiveLocks: 'No active locks.',
699
+ noMessages: 'No messages.',
700
+ noTimelineItems: 'No timeline items.',
701
+ nextAction: 'Next Action',
702
+ timeline: 'Timeline',
703
+ messages: 'Messages',
704
+ peers: 'Peers',
705
+ tasks: 'Tasks',
706
+ locks: 'Locks',
707
+ owner: 'owner',
708
+ assignee: 'assignee',
709
+ task: 'task',
710
+ all: 'all',
711
+ next: 'next',
712
+ finish: 'finish',
713
+ warnings: 'warnings',
714
+ noImmediateAction: 'No immediate coordination action',
715
+ detectedSession: 'Detected Session',
716
+ sendMessage: 'Send Message',
717
+ messageHelp: "Message will appear in the peer's <code>hcc msg inbox</code> and be injected on next hook fire.",
718
+ messageBodyPlaceholder: 'Message body...',
719
+ send: 'Send',
720
+ peer: 'peer',
721
+ kind: 'kind',
722
+ status: 'status',
723
+ cwd: 'cwd',
724
+ pid: 'pid',
725
+ unknown: 'unknown',
726
+ lastSeen: 'last seen',
727
+ secondsAgo: 's ago',
728
+ age: 'age',
729
+ branch: 'branch',
730
+ runtime: 'runtime',
731
+ command: 'command',
732
+ providerSession: 'provider session',
733
+ activeDetected: 'Active detected',
734
+ staleDetected: 'Stale detected',
735
+ noActiveDetectedPeers: 'No active detected peers.',
736
+ showStale: 'show stale',
737
+ hideStale: 'hide stale',
738
+ thread: 'thread',
739
+ reply: 'reply',
740
+ 'status.active': 'active',
741
+ 'status.stale': 'stale',
742
+ 'status.unknown': 'unknown',
743
+ 'status.running': 'running',
744
+ 'status.idle': 'idle',
745
+ 'status.exited': 'exited',
746
+ 'status.claimed': 'claimed',
747
+ 'status.review': 'review',
748
+ 'status.blocked': 'blocked',
749
+ 'status.done': 'done',
750
+ 'status.abandoned': 'abandoned',
751
+ 'status.pending': 'pending',
752
+ 'conn.offline': 'offline',
753
+ 'conn.online': 'online',
754
+ 'conn.attached': 'attached',
755
+ 'conn.reconnecting': 'reconnecting...',
756
+ 'conn.coordinationOnly': 'coordination only',
757
+ 'conn.error': 'error',
758
+ show: 'Show',
759
+ collapse: 'Collapse',
760
+ sidebar: 'sidebar',
761
+ statePanel: 'state panel',
762
+ dragToResize: 'drag to resize',
763
+ collapseSidebar: 'Collapse sidebar',
764
+ collapseStatePanel: 'Collapse state panel',
765
+ toggleLeftSidebar: 'Toggle left sidebar',
766
+ toggleRightPanel: 'Toggle right panel',
767
+ resizeLeftSidebar: 'Resize left sidebar',
768
+ resizeRightPanel: 'Resize right panel',
769
+ customSession: 'custom session id...',
770
+ 'dialog.cancel': 'Cancel',
771
+ 'dialog.killTmux': 'Also kill tmux session',
772
+ 'action.stopPeer': 'Stop peer',
773
+ 'action.restartPeer': 'Restart peer',
774
+ detectedPeer: 'detected peer'
775
+ },
776
+ zh: {
777
+ language: '语言',
778
+ project: '项目',
779
+ projectPath: '项目路径',
780
+ projectPathPlaceholder: '/项目/路径',
781
+ registerProject: '注册项目',
782
+ newSession: '新会话',
783
+ start: '启动',
784
+ mode: '模式',
785
+ 'mode.new': '新建',
786
+ 'mode.resume': '恢复',
787
+ 'mode.last': '最近',
788
+ 'mode.continue': '继续',
789
+ session: '会话',
790
+ sessionId: '会话 ID',
791
+ sessionIdPlaceholder: '会话 ID 或名称',
792
+ sessions: '会话',
793
+ view: '视图',
794
+ 'kind.all': '全部',
795
+ 'kind.shell': 'shell',
796
+ 'kind.other': '其他',
797
+ noSessionSelected: '未选择会话',
798
+ startOrSelect: '从左侧面板启动或选择一个会话',
799
+ actions: '操作',
800
+ 'action.state': '状态详情',
801
+ 'action.inbox': '收件箱',
802
+ 'action.claimNextTask': '认领下个任务',
803
+ 'action.status': '状态',
804
+ 'action.renewHeartbeat': '续期心跳',
805
+ 'action.reregister': '重新注册协作方',
806
+ 'action.runStatusTerminal': '在终端运行状态命令',
807
+ actionResult: '操作结果',
808
+ stop: '停止',
809
+ projectState: '项目状态',
810
+ refresh: '刷新',
811
+ managed: '托管',
812
+ detected: '检测到',
813
+ running: '运行中',
814
+ noActiveSessions: '没有活动会话。',
815
+ startOneAbove: '在上方启动一个会话',
816
+ orRunTerminal: '或在任意终端运行:',
817
+ noDetectedPeers: '没有检测到的协作方。',
818
+ viewFilter: '视图过滤',
819
+ noTasks: '没有任务。',
820
+ noPeers: '没有协作方。',
821
+ noActiveLocks: '没有活动锁。',
822
+ noMessages: '没有消息。',
823
+ noTimelineItems: '没有时间线条目。',
824
+ nextAction: '下一步',
825
+ timeline: '时间线',
826
+ messages: '消息',
827
+ peers: '协作方',
828
+ tasks: '任务',
829
+ locks: '锁',
830
+ owner: '负责人',
831
+ assignee: '执行者',
832
+ task: '任务',
833
+ all: '全部',
834
+ next: '下一步',
835
+ finish: '收尾',
836
+ warnings: '警告',
837
+ noImmediateAction: '当前没有需要立即处理的协作动作',
838
+ detectedSession: '检测到的会话',
839
+ sendMessage: '发送消息',
840
+ messageHelp: '消息会出现在该 peer 的 <code>hcc msg inbox</code> 中,并在下一次 hook 触发时注入。',
841
+ messageBodyPlaceholder: '消息内容...',
842
+ send: '发送',
843
+ peer: '协作方',
844
+ kind: '类型',
845
+ status: '状态',
846
+ cwd: '工作目录',
847
+ pid: 'PID',
848
+ unknown: '未知',
849
+ lastSeen: '最后出现',
850
+ secondsAgo: ' 秒前',
851
+ age: '时长',
852
+ branch: '分支',
853
+ runtime: '运行时',
854
+ command: '命令',
855
+ providerSession: '提供方会话',
856
+ activeDetected: '活跃检测',
857
+ staleDetected: '过期检测',
858
+ noActiveDetectedPeers: '没有活跃的检测协作方。',
859
+ showStale: '显示过期',
860
+ hideStale: '隐藏过期',
861
+ thread: '线程',
862
+ reply: '回复',
863
+ 'status.active': '活跃',
864
+ 'status.stale': '过期',
865
+ 'status.unknown': '未知',
866
+ 'status.running': '运行中',
867
+ 'status.idle': '空闲',
868
+ 'status.exited': '已退出',
869
+ 'status.claimed': '已认领',
870
+ 'status.review': '审查中',
871
+ 'status.blocked': '阻塞',
872
+ 'status.done': '完成',
873
+ 'status.abandoned': '已放弃',
874
+ 'status.pending': '待处理',
875
+ 'conn.offline': '离线',
876
+ 'conn.online': '在线',
877
+ 'conn.attached': '已连接',
878
+ 'conn.reconnecting': '重连中...',
879
+ 'conn.coordinationOnly': '仅协作',
880
+ 'conn.error': '错误',
881
+ show: '显示',
882
+ collapse: '折叠',
883
+ sidebar: '侧栏',
884
+ statePanel: '状态面板',
885
+ dragToResize: '拖动调整大小',
886
+ collapseSidebar: '折叠侧栏',
887
+ collapseStatePanel: '折叠状态面板',
888
+ toggleLeftSidebar: '切换左侧栏',
889
+ toggleRightPanel: '切换右侧面板',
890
+ resizeLeftSidebar: '调整左侧栏宽度',
891
+ resizeRightPanel: '调整右侧面板宽度',
892
+ customSession: '自定义会话 ID...',
893
+ 'dialog.cancel': '取消',
894
+ 'dialog.killTmux': '同时终止 tmux 会话',
895
+ 'action.stopPeer': '停止协作方',
896
+ 'action.restartPeer': '恢复协作方',
897
+ detectedPeer: '检测到的协作方'
898
+ }
899
+ };
900
+ let lang = localStorage.getItem('hcc.lang') || ((navigator.language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en');
901
+ if (!i18n[lang]) lang = 'en';
902
+
903
+ function tr(key, fallback = '') {
904
+ return i18n[lang]?.[key] || i18n.en[key] || fallback || key;
905
+ }
906
+
907
+ function setText(id, key) {
908
+ const el = document.getElementById(id);
909
+ if (el) el.textContent = tr(key);
910
+ }
911
+
912
+ function connText(key) {
913
+ const el = document.getElementById('connState');
914
+ if (el) {
915
+ el.dataset.stateKey = key;
916
+ el.textContent = tr('conn.' + key);
917
+ }
918
+ }
919
+
920
+ function applyLanguage() {
921
+ document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en';
922
+ localStorage.setItem('hcc.lang', lang);
923
+ const sel = document.getElementById('langSelect');
924
+ if (sel) sel.value = lang;
925
+ document.querySelectorAll('[data-i18n]').forEach((el) => { el.textContent = tr(el.dataset.i18n); });
926
+ document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => { el.placeholder = tr(el.dataset.i18nPlaceholder); });
927
+ document.querySelectorAll('[data-i18n-title]').forEach((el) => { el.title = tr(el.dataset.i18nTitle); });
928
+ document.querySelectorAll('[data-i18n-aria]').forEach((el) => { el.setAttribute('aria-label', tr(el.dataset.i18nAria)); });
929
+ if (!active && !activeDetected) {
930
+ setText('activeTitle', 'noSessionSelected');
931
+ setText('activeMeta', 'startOrSelect');
932
+ }
933
+ const stateKey = document.getElementById('connState')?.dataset.stateKey || 'offline';
934
+ connText(stateKey);
935
+ syncStartModeOptions();
936
+ renderSections();
937
+ if (activeType === 'detected' && activeDetected) {
938
+ const draft = document.getElementById('detMsg')?.value || '';
939
+ const peer = detected.find((p) => p.id === activeDetected) || { id: activeDetected };
940
+ document.getElementById('activeTitle').textContent = activeDetected + ' (' + tr('detected') + ')';
941
+ renderDetectedPanel(peer);
942
+ const detMsg = document.getElementById('detMsg');
943
+ if (detMsg && draft) detMsg.value = draft;
944
+ } else if (activeType === 'managed' && active) {
945
+ const meta = sessions.find((s) => s.id === active);
946
+ if (meta) {
947
+ document.getElementById('activeTitle').textContent = sessionPeerId(meta) || active;
948
+ document.getElementById('activeMeta').textContent = sessionMetaText(meta);
949
+ }
950
+ }
951
+ if (lastStateRoot) refreshCurrentState().catch(console.error);
952
+ syncToggleIcons();
953
+ }
954
+
955
+ function requestQuery(extra = {}) {
956
+ const params = new URLSearchParams();
957
+ if (token) params.set('token', token);
958
+ if (currentProject) params.set('root', currentProject);
959
+ for (const [key, value] of Object.entries(extra)) {
960
+ if (value !== undefined && value !== null && value !== '') params.set(key, value);
961
+ }
962
+ const text = params.toString();
963
+ return text ? '?' + text : '';
964
+ }
965
+
966
+ function updateLocationProject() {
967
+ const params = new URLSearchParams(location.search);
968
+ if (token) params.set('token', token);
969
+ if (currentProject) params.set('project', currentProject);
970
+ if (sessionKindFilter && sessionKindFilter !== 'all') params.set('kind', sessionKindFilter);
971
+ history.replaceState(null, '', location.pathname + '?' + params.toString());
972
+ }
973
+
974
+ const term = new Terminal({
975
+ cursorBlink: true,
976
+ cursorInactiveStyle: 'outline',
977
+ cursorStyle: 'bar',
978
+ convertEol: true,
979
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
980
+ fontSize: 13,
981
+ theme: { background: '#0b0d10', foreground: '#eef2f6', cursor: '#7dd3fc', cursorAccent: '#0b0d10' }
982
+ });
983
+ term.open(document.getElementById('terminal'));
984
+
985
+ // Accurate terminal sizing via character measurement
986
+ function measureCharSize() {
987
+ const el = document.getElementById('terminal');
988
+ const canvas = document.createElement('canvas');
989
+ const ctx2d = canvas.getContext('2d');
990
+ ctx2d.font = '13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';
991
+ const m = ctx2d.measureText('M');
992
+ return { w: m.width || 8, h: 17 };
993
+ }
994
+
995
+ function resizeTerm() {
996
+ const el = document.getElementById('terminal');
997
+ const { w, h } = measureCharSize();
998
+ const cols = Math.max(60, Math.floor((el.clientWidth - 16) / w));
999
+ const rows = Math.max(10, Math.floor((el.clientHeight - 16) / h));
1000
+ term.resize(cols, rows);
1001
+ if (ws && ws.readyState === WebSocket.OPEN) {
1002
+ terminalLastResizeAt = Date.now();
1003
+ ws.send(JSON.stringify({ type: 'resize', cols, rows }));
1004
+ }
1005
+ }
1006
+ window.addEventListener('resize', resizeTerm);
1007
+ setTimeout(resizeTerm, 80);
1008
+
1009
+ term.onData((data) => {
1010
+ if (ws && ws.readyState === WebSocket.OPEN) {
1011
+ ws.send(JSON.stringify({ type: 'input', data }));
1012
+ }
1013
+ });
1014
+
1015
+ async function api(path, options = {}) {
1016
+ const res = await fetch(path + (path.includes('?') ? '&' : '?') + requestQuery().slice(1), {
1017
+ ...options,
1018
+ headers: { 'Content-Type': 'application/json', ...headers, ...(options.headers || {}) }
1019
+ });
1020
+ const json = await res.json();
1021
+ if (!res.ok) throw new Error(json.error?.message || json.message || 'request failed');
1022
+ return json;
1023
+ }
1024
+
1025
+ function renderProjects() {
1026
+ const select = document.getElementById('projectSelect');
1027
+ select.innerHTML = projects.map((p) =>
1028
+ '<option value="' + esc(p.root) + '">' + esc((p.name || p.root) + ' · ' + p.root) + '</option>'
1029
+ ).join('');
1030
+ if (currentProject) select.value = currentProject;
1031
+ document.getElementById('sessionKindFilter').value = sessionKindFilter;
1032
+ }
1033
+
1034
+ function kindMatches(item) {
1035
+ const kind = ['claude', 'codex', 'shell'].includes(item.kind) ? item.kind : 'other';
1036
+ return sessionKindFilter === 'all' || kind === sessionKindFilter;
1037
+ }
1038
+
1039
+ function syncStartModeOptions() {
1040
+ const kind = document.getElementById('kind').value;
1041
+ const modeSelect = document.getElementById('startMode');
1042
+ const current = modeSelect.value;
1043
+ const modes = kind === 'claude'
1044
+ ? [['new', 'new'], ['resume', 'resume'], ['continue', 'continue']]
1045
+ : kind === 'codex'
1046
+ ? [['new', 'new'], ['resume', 'resume'], ['last', 'last']]
1047
+ : [['new', 'new']];
1048
+ modeSelect.innerHTML = modes.map(([value]) => '<option value="' + value + '">' + tr('mode.' + value, value) + '</option>').join('');
1049
+ modeSelect.value = modes.some(([value]) => value === current) ? current : 'new';
1050
+ const isResume = modeSelect.value === 'resume';
1051
+ document.querySelector('[data-resume-field]:not([data-resume-custom])').style.display = isResume ? '' : 'none';
1052
+ if (isResume) loadResumable();
1053
+ else document.querySelector('[data-resume-custom]').style.display = 'none';
1054
+ }
1055
+
1056
+ // Fetch provider sessions hcc knows about and fill the resume dropdown.
1057
+ async function loadResumable() {
1058
+ try { const d = await api('/api/resumable'); resumableCache = d.resumable || []; }
1059
+ catch { resumableCache = []; }
1060
+ populateResumeSelect();
1061
+ }
1062
+ function populateResumeSelect() {
1063
+ const kind = document.getElementById('kind').value;
1064
+ const sel = document.getElementById('resumeSelect');
1065
+ const prev = sel.value;
1066
+ const items = resumableCache.filter((r) => r.provider === kind);
1067
+ const opts = items.map((r) => {
1068
+ const resume = r.resume || r.session_id || r.session_name || '';
1069
+ const shortResume = resume.length > 14 ? resume.slice(0, 10) + '…' : resume;
1070
+ const label = (r.name && r.name !== resume ? r.name + ' · ' : '') + shortResume + ' (' + r.peer + ')';
1071
+ return '<option value="' + esc(resume) + '">' + esc(label) + '</option>';
1072
+ });
1073
+ opts.push('<option value="__custom__">' + esc(tr('customSession')) + '</option>');
1074
+ sel.innerHTML = opts.join('');
1075
+ if (prev && [...sel.options].some((o) => o.value === prev)) sel.value = prev;
1076
+ toggleResumeCustom();
1077
+ }
1078
+ function toggleResumeCustom() {
1079
+ const sel = document.getElementById('resumeSelect');
1080
+ document.querySelector('[data-resume-custom]').style.display = sel.value === '__custom__' ? '' : 'none';
1081
+ }
1082
+
1083
+ async function loadProjects() {
1084
+ const data = await api('/api/projects');
1085
+ projects = data.projects || [];
1086
+ if (!currentProject) currentProject = data.current?.root || projects[0]?.root || '';
1087
+ renderProjects();
1088
+ updateLocationProject();
1089
+ }
1090
+
1091
+ async function switchProject(root) {
1092
+ currentProject = root;
1093
+ updateLocationProject();
1094
+ active = null;
1095
+ activeDetected = null;
1096
+ activeType = 'managed';
1097
+ if (ws) { clearTimeout(wsReconnectTimer); ws.close(); ws = null; }
1098
+ term.reset();
1099
+ document.getElementById('activeTitle').textContent = tr('noSessionSelected');
1100
+ document.getElementById('activeMeta').textContent = tr('startOrSelect');
1101
+ await Promise.all([refreshSessions(), refreshDetected(), refreshState()]);
1102
+ const first = sessions.find(s => s.status === 'running');
1103
+ if (first) connectManaged(first.id);
1104
+ }
1105
+
1106
+ function esc(text) {
1107
+ return String(text ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]));
1108
+ }
1109
+
1110
+ function fmtTime(ts) {
1111
+ if (!ts) return '';
1112
+ return new Date(ts * 1000).toLocaleTimeString();
1113
+ }
1114
+
1115
+ function badgeClass(status) {
1116
+ return String(status || '').toLowerCase().replace(/[^a-z0-9_-]/g, '');
1117
+ }
1118
+
1119
+ function fmtAge(age) {
1120
+ const n = Number(age);
1121
+ if (!Number.isFinite(n)) return '?';
1122
+ return Math.max(0, Math.round(n)) + (lang === 'zh' ? '秒' : 's');
1123
+ }
1124
+
1125
+ function sessionPeerId(session) {
1126
+ return session?.peer_id || session?.id || '';
1127
+ }
1128
+
1129
+ function sessionRuntimeNote(session) {
1130
+ const target = sessionRuntimeTarget(session);
1131
+ return target ? tr('runtime') + '=' + target : '';
1132
+ }
1133
+
1134
+ function sessionBinding(session) {
1135
+ return session?.binding || {};
1136
+ }
1137
+
1138
+ function sessionRuntimeTarget(session) {
1139
+ const binding = sessionBinding(session);
1140
+ return binding.runtime_target || session?.pane || session?.id || '';
1141
+ }
1142
+
1143
+ function runtimeTargetText(session) {
1144
+ return tr('runtime') + '=' + (sessionRuntimeTarget(session) || tr('unknown'));
1145
+ }
1146
+
1147
+ function sessionProvider(session) {
1148
+ const binding = sessionBinding(session);
1149
+ return binding.provider || session?.kind || 'other';
1150
+ }
1151
+
1152
+ function sessionProviderSessionValue(session) {
1153
+ const binding = sessionBinding(session);
1154
+ return session?.provider_session_label || binding.provider_session_id || binding.provider_session_name || '';
1155
+ }
1156
+
1157
+ function providerSessionKnown(session) {
1158
+ return session?.provider_session_known === true || Boolean(sessionProviderSessionValue(session));
1159
+ }
1160
+
1161
+ function sessionProviderSessionText(session) {
1162
+ const value = sessionProviderSessionValue(session);
1163
+ return tr('providerSession') + '=' + sessionProvider(session) + ':' + (value || tr('unknown'));
1164
+ }
1165
+
1166
+ function sessionCardDetailText(session) {
1167
+ return [
1168
+ runtimeTargetText(session),
1169
+ sessionProviderSessionText(session),
1170
+ session?.cwd || ''
1171
+ ].filter(Boolean).join(' · ');
1172
+ }
1173
+
1174
+ function sessionMetaText(session) {
1175
+ return [
1176
+ runtimeTargetText(session),
1177
+ sessionProviderSessionText(session),
1178
+ session?.command ? tr('command') + '=' + session.command : '',
1179
+ session?.cwd || ''
1180
+ ].filter(Boolean).join(' · ');
1181
+ }
1182
+
1183
+ function statusText(status) {
1184
+ const value = String(status || 'unknown');
1185
+ return tr('status.' + value.toLowerCase(), value);
1186
+ }
1187
+
1188
+ function lockLabel(lock) {
1189
+ const base = lock.base_resource || lock.resource || '';
1190
+ const scope = lock.scope || '*';
1191
+ return scope === '*' ? base : base + ' [' + scope + ']';
1192
+ }
1193
+
1194
+ function managedPeerId(id) {
1195
+ return sessionPeerId(sessions.find((s) => s.id === id)) || id;
1196
+ }
1197
+
1198
+ function peerIsActive(peer, basisNow = lastStateNow) {
1199
+ const age = Number(peer?.age_sec);
1200
+ if (Number.isFinite(age)) return age <= activePeerTtl;
1201
+ const seen = Number(peer?.last_seen_at || 0);
1202
+ const t = Number(basisNow || 0) || Math.floor(Date.now() / 1000);
1203
+ return seen > 0 && (t - seen) <= activePeerTtl;
1204
+ }
1205
+
1206
+ function detectedPeerCanStop(peer) {
1207
+ const status = String(peer?.status || '').toLowerCase();
1208
+ if (['exited', 'detached'].includes(status)) return false;
1209
+ return peerIsActive(peer);
1210
+ }
1211
+
1212
+ function peerStateView(peer, runtime = null, basisNow = lastStateNow) {
1213
+ const activity = peer?.status || 'unknown';
1214
+ const liveness = peerIsActive(peer, basisNow) ? 'active' : 'stale';
1215
+ const age = fmtAge(peer?.age_sec);
1216
+ const branch = peer?.branch ? ' ' + tr('branch') + '=' + peer.branch : '';
1217
+ if (runtime) {
1218
+ return {
1219
+ label: runtime.status || 'running',
1220
+ detail: tr('peer') + '=' + statusText(activity) + ' ' + statusText(liveness) + ' ' + tr('age') + '=' + age + branch
1221
+ };
1222
+ }
1223
+ if (liveness === 'stale') {
1224
+ return {
1225
+ label: 'stale',
1226
+ detail: tr('lastSeen') + '=' + statusText(activity) + ' ' + tr('age') + '=' + age + branch
1227
+ };
1228
+ }
1229
+ return {
1230
+ label: activity,
1231
+ detail: statusText(liveness) + ' ' + tr('age') + '=' + age + branch
1232
+ };
1233
+ }
1234
+
1235
+ function taskOwnerStateText(task) {
1236
+ if (!task?.owner) return '';
1237
+ if (task.owner_stale) {
1238
+ if (task.takeover_ready) return 'owner_state=stale/no-lock';
1239
+ const locks = Number(task.related_lock_count || 0);
1240
+ return locks ? 'owner_state=stale/locks=' + locks : 'owner_state=stale';
1241
+ }
1242
+ if (task.owner_active) return 'owner_state=active';
1243
+ return '';
1244
+ }
1245
+
1246
+ function bodyPinned(el) {
1247
+ if (!el) return false;
1248
+ return el.scrollTop + el.clientHeight >= el.scrollHeight - 8;
1249
+ }
1250
+
1251
+ function stateCardCollapsed(section) {
1252
+ return localStorage.getItem('hcc.stateCard.' + section + '.collapsed') === '1';
1253
+ }
1254
+
1255
+ function stateCardHtml(section, title, count, bodyHtml) {
1256
+ const collapsed = stateCardCollapsed(section);
1257
+ return \`
1258
+ <div class="card state-card \${collapsed ? 'state-card-collapsed' : ''}" data-section="\${esc(section)}">
1259
+ <button class="state-card-toggle" type="button" aria-expanded="\${collapsed ? 'false' : 'true'}">
1260
+ <span class="state-card-toggle-title"><strong>\${esc(title)}</strong> <span class="badge">\${esc(count)}</span></span>
1261
+ <span class="state-card-chevron">⌄</span>
1262
+ </button>
1263
+ <div class="body">\${bodyHtml}</div>
1264
+ </div>\`;
1265
+ }
1266
+
1267
+ function bindStateCardToggles() {
1268
+ document.querySelectorAll('.state-card[data-section] .state-card-toggle').forEach((button) => {
1269
+ button.addEventListener('click', () => {
1270
+ const card = button.closest('.state-card[data-section]');
1271
+ if (!card) return;
1272
+ const section = card.dataset.section || '';
1273
+ const collapsed = !card.classList.contains('state-card-collapsed');
1274
+ card.classList.toggle('state-card-collapsed', collapsed);
1275
+ button.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
1276
+ localStorage.setItem('hcc.stateCard.' + section + '.collapsed', collapsed ? '1' : '0');
1277
+ });
1278
+ });
1279
+ }
1280
+
1281
+ function renderTimelineItem(item) {
1282
+ const meta = [
1283
+ item.source + ':' + item.source_id,
1284
+ item.task_id ? tr('task') + ' #' + item.task_id : '',
1285
+ item.thread_id ? tr('thread') + ' #' + item.thread_id : '',
1286
+ item.direction || '',
1287
+ fmtTime(item.ts)
1288
+ ].filter(Boolean).join(' · ');
1289
+ return \`
1290
+ <div class="item timeline-item">
1291
+ <strong>\${esc(item.title || item.kind || item.source)} <span class="badge">\${esc(item.kind || item.source)}</span></strong>
1292
+ <span>\${esc(meta)}</span>
1293
+ \${item.text ? '<span>' + esc(item.text) + '</span>' : ''}
1294
+ </div>\`;
1295
+ }
1296
+
1297
+ document.getElementById('projectSelect').addEventListener('change', (event) => {
1298
+ switchProject(event.target.value).catch(console.error);
1299
+ });
1300
+ document.getElementById('langSelect').addEventListener('change', (event) => {
1301
+ lang = event.target.value === 'zh' ? 'zh' : 'en';
1302
+ applyLanguage();
1303
+ });
1304
+ document.getElementById('sessionKindFilter').addEventListener('change', (event) => {
1305
+ sessionKindFilter = event.target.value || 'all';
1306
+ updateLocationProject();
1307
+ renderSections();
1308
+ });
1309
+ document.getElementById('addProjectBtn').addEventListener('click', async () => {
1310
+ const input = document.getElementById('projectPath');
1311
+ const root = input.value.trim();
1312
+ if (!root) return;
1313
+ await api('/api/projects', { method: 'POST', body: JSON.stringify({ root }) });
1314
+ input.value = '';
1315
+ await loadProjects();
1316
+ await switchProject(root);
1317
+ });
1318
+
1319
+ // ── Sessions rendering (managed + detected) ──────────────────────────
1320
+ function renderSections() {
1321
+ const box = document.getElementById('sessions');
1322
+ const visibleSessions = sessions.filter(kindMatches);
1323
+ const visibleDetected = detected.filter(kindMatches);
1324
+ const activeDetectedPeers = visibleDetected.filter((p) => peerIsActive(p));
1325
+ const staleDetectedPeers = visibleDetected.filter((p) => !peerIsActive(p));
1326
+ const filterNote = sessionKindFilter === 'all' ? '' : '<br><br>' + esc(tr('viewFilter')) + ': ' + esc(sessionKindFilter);
1327
+ const shortSessionValue = (value) => {
1328
+ const text = String(value || '');
1329
+ return text.length > 14 ? text.slice(0, 10) + '...' : text;
1330
+ };
1331
+ const manHtml = visibleSessions.length
1332
+ ? visibleSessions.map((s) => {
1333
+ const peerId = sessionPeerId(s);
1334
+ const providerText = sessionProvider(s) + ':' + (providerSessionKnown(s) ? shortSessionValue(sessionProviderSessionValue(s)) : tr('unknown'));
1335
+ return \`
1336
+ <div class="session \${active === s.id && activeType === 'managed' ? 'active' : ''}" data-id="\${esc(s.id)}" data-type="managed">
1337
+ <div class="row"><strong>\${esc(peerId)}</strong><span class="badge \${badgeClass(s.status)}">\${esc(statusText(s.status))}</span></div>
1338
+ <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><span class="badge \${providerSessionKnown(s) ? '' : 'stale'}" title="\${esc(sessionProviderSessionText(s))}">\${esc(providerText)}</span></div>
1339
+ <div class="path" title="\${esc(sessionMetaText(s))}">\${esc(sessionCardDetailText(s))}</div>
1340
+ </div>\`;
1341
+ }).join('')
1342
+ : '<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>';
1343
+
1344
+ const renderDetectedPeer = (p) => {
1345
+ const state = peerStateView(p);
1346
+ const canStop = detectedPeerCanStop(p);
1347
+ return \`
1348
+ <div class="session \${activeDetected === p.id && activeType === 'detected' ? 'active' : ''}" data-id="\${esc(p.id)}" data-type="detected">
1349
+ <div class="row">
1350
+ <strong>\${esc(p.id)}</strong>
1351
+ <div style="display:flex;gap:6px;align-items:center">
1352
+ <span class="badge" style="color:var(--warn);border-color:#6b5a20">\${esc(tr('detected'))}</span>
1353
+ \${canStop ? \`
1354
+ <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>
1355
+ \` : \`
1356
+ <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>
1357
+ \`}
1358
+ </div>
1359
+ </div>
1360
+ <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>
1361
+ <div class="path" title="\${esc(p.worktree || '')}">\${esc((p.worktree || '').split('/').slice(-2).join('/'))}</div>
1362
+ </div>\`;
1363
+ };
1364
+ const activeDetectedHtml = activeDetectedPeers.length
1365
+ ? activeDetectedPeers.map(renderDetectedPeer).join('')
1366
+ : '<div class="empty">' + esc(tr('noActiveDetectedPeers')) + filterNote + '</div>';
1367
+ const staleToggleLabel = showStaleDetected ? tr('hideStale') : tr('showStale');
1368
+ const staleDetectedHtml = showStaleDetected
1369
+ ? (staleDetectedPeers.length ? staleDetectedPeers.map(renderDetectedPeer).join('') : '<div class="empty">' + esc(tr('noDetectedPeers')) + '</div>')
1370
+ : '';
1371
+
1372
+ const savedScroll = box.scrollTop;
1373
+ box.innerHTML = \`
1374
+ <div class="sec-label">\${esc(tr('managed'))} <span class="badge">\${visibleSessions.filter(s=>s.status==='running').length} \${esc(tr('running'))}</span></div>
1375
+ \${manHtml}
1376
+ <div class="sec-label" style="margin-top:10px">\${esc(tr('activeDetected'))} <span class="badge" style="color:var(--warn)">\${activeDetectedPeers.length}</span></div>
1377
+ \${activeDetectedHtml}
1378
+ <div class="sec-label" style="margin-top:10px">\${esc(tr('staleDetected'))} <span class="badge">\${staleDetectedPeers.length}</span><span class="sec-spacer"></span><button id="toggleStaleDetected" type="button">\${esc(staleToggleLabel)}</button></div>
1379
+ \${staleDetectedHtml}
1380
+ \`;
1381
+ box.scrollTop = savedScroll;
1382
+ const staleToggle = document.getElementById('toggleStaleDetected');
1383
+ if (staleToggle) {
1384
+ staleToggle.addEventListener('click', () => {
1385
+ showStaleDetected = !showStaleDetected;
1386
+ localStorage.setItem('hcc.showStaleDetected', showStaleDetected ? '1' : '0');
1387
+ renderSections();
1388
+ });
1389
+ }
1390
+ box.querySelectorAll('.session[data-type="managed"]').forEach((el) => {
1391
+ el.addEventListener('click', () => connectManaged(el.dataset.id));
1392
+ });
1393
+ box.querySelectorAll('.session[data-type="detected"]').forEach((el) => {
1394
+ el.addEventListener('click', (e) => {
1395
+ if (e.target.closest('[data-action]')) return;
1396
+ connectDetected(el.dataset.id);
1397
+ });
1398
+ });
1399
+ box.querySelectorAll('[data-action="stop-detected"]').forEach((btn) => {
1400
+ btn.addEventListener('click', (e) => {
1401
+ e.stopPropagation();
1402
+ const peerId = btn.dataset.id;
1403
+ document.getElementById('stopDialogTitle').textContent = tr('stop') + ' ' + peerId + '?';
1404
+ stopKillCb.checked = false;
1405
+ document.getElementById('stopDialogMeta').textContent = tr('detectedPeer');
1406
+ stopDialog._action = 'detected';
1407
+ stopDialog._peerId = peerId;
1408
+ stopDialog.hidden = false;
1409
+ });
1410
+ });
1411
+ box.querySelectorAll('[data-action="restart-detected"]').forEach((btn) => {
1412
+ btn.addEventListener('click', async (e) => {
1413
+ e.stopPropagation();
1414
+ const peerId = btn.dataset.id;
1415
+ await api('/api/detected/' + encodeURIComponent(peerId) + '/restart', { method: 'POST', body: '{}' });
1416
+ await Promise.all([refreshSessions(), refreshDetected()]);
1417
+ });
1418
+ });
1419
+ }
1420
+
1421
+ async function refreshSessions() {
1422
+ const data = await api('/api/sessions');
1423
+ sessions = data.sessions || [];
1424
+ renderSections();
1425
+ if (active && activeType === 'managed') {
1426
+ const meta = sessions.find((s) => s.id === active);
1427
+ if (meta) {
1428
+ document.getElementById('activeTitle').textContent = sessionPeerId(meta) || active;
1429
+ document.getElementById('activeMeta').textContent = sessionMetaText(meta);
1430
+ }
1431
+ }
1432
+ connText('online');
1433
+ }
1434
+
1435
+ async function refreshDetected() {
1436
+ try {
1437
+ const data = await api('/api/detected');
1438
+ activePeerTtl = Number(data.active_peer_ttl || activePeerTtl);
1439
+ lastStateNow = Number(data.now || lastStateNow);
1440
+ detected = data.detected || [];
1441
+ renderSections();
1442
+ } catch {}
1443
+ }
1444
+
1445
+ // ── Project state panel ───────────────────────────────────────────────
1446
+ function renderState(data) {
1447
+ const stateRoot = data.root || '';
1448
+ document.getElementById('rootPath').textContent = stateRoot;
1449
+ activePeerTtl = Number(data.active_peer_ttl || activePeerTtl);
1450
+ lastStateNow = Number(data.now || lastStateNow);
1451
+ const state = document.getElementById('state');
1452
+ const preserveScroll = lastStateRoot === stateRoot;
1453
+ const savedStateScroll = preserveScroll ? state.scrollTop : 0;
1454
+ const savedCardScroll = preserveScroll
1455
+ ? new Map(
1456
+ [...state.querySelectorAll('.state-card[data-section]')].map((card) => [
1457
+ card.dataset.section,
1458
+ {
1459
+ top: card.querySelector('.body')?.scrollTop || 0,
1460
+ pinned: bodyPinned(card.querySelector('.body'))
1461
+ }
1462
+ ])
1463
+ )
1464
+ : new Map();
1465
+ const runtimeById = new Map();
1466
+ for (const session of sessions || []) {
1467
+ runtimeById.set(session.id, session);
1468
+ const peerId = sessionPeerId(session);
1469
+ if (peerId) runtimeById.set(peerId, session);
1470
+ }
1471
+ const tasksData = data.tasks || [];
1472
+ const peersData = data.peers || [];
1473
+ const locksData = data.locks || [];
1474
+ const messagesData = data.messages || [];
1475
+ const timelineData = data.timeline || [];
1476
+ const automation = data.automation || {};
1477
+ const nextAction = automation.next_action || {};
1478
+ const tasks = tasksData.map((t) => \`
1479
+ <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>
1480
+ \`).join('') || '<div class="empty">' + esc(tr('noTasks')) + '</div>';
1481
+ const peers = peersData.map((a) => {
1482
+ const peerRuntime = runtimeById.get(a.id);
1483
+ const peerState = peerStateView(a, peerRuntime, data.now);
1484
+ return \`
1485
+ <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>
1486
+ \`;
1487
+ }).join('') || '<div class="empty">' + esc(tr('noPeers')) + '</div>';
1488
+ const locks = locksData.map((l) => \`
1489
+ <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>
1490
+ \`).join('') || '<div class="empty">' + esc(tr('noActiveLocks')) + '</div>';
1491
+ const messages = messagesData.map((m) => \`
1492
+ <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>
1493
+ \`).join('') || '<div class="empty">' + esc(tr('noMessages')) + '</div>';
1494
+ const timeline = timelineData.map(renderTimelineItem).join('') || '<div class="empty">' + esc(tr('noTimelineItems')) + '</div>';
1495
+ const actionLines = [
1496
+ '<div class="item"><strong>' + esc(statusText(automation.phase || 'idle')) + '</strong><span>' + esc(nextAction.reason || tr('noImmediateAction')) + '</span></div>',
1497
+ nextAction.command ? '<div class="item"><strong>' + esc(tr('next')) + '</strong><span class="mono">' + esc(nextAction.command) + '</span></div>' : '',
1498
+ (automation.finish_actions || []).length ? '<div class="item"><strong>' + esc(tr('finish')) + '</strong><span>' + esc(automation.finish_actions.map((a) => a.command).join(' | ')) + '</span></div>' : '',
1499
+ (automation.warnings || []).length ? '<div class="item"><strong>' + esc(tr('warnings')) + '</strong><span>' + esc(automation.warnings.join(' | ')) + '</span></div>' : ''
1500
+ ].filter(Boolean).join('');
1501
+ state.innerHTML = [
1502
+ stateCardHtml('automation', tr('nextAction'), statusText(automation.phase || 'idle'), actionLines),
1503
+ stateCardHtml('timeline', tr('timeline'), timelineData.length, timeline),
1504
+ stateCardHtml('messages', tr('messages'), messagesData.length, messages),
1505
+ stateCardHtml('peers', tr('peers'), peersData.length, peers),
1506
+ stateCardHtml('tasks', tr('tasks'), tasksData.length, tasks),
1507
+ stateCardHtml('locks', tr('locks'), locksData.length, locks)
1508
+ ].join('');
1509
+ state.scrollTop = savedStateScroll;
1510
+ for (const [section, saved] of savedCardScroll) {
1511
+ const body = state.querySelector('.state-card[data-section="' + section + '"] .body');
1512
+ if (!body) continue;
1513
+ if (section === 'timeline' && saved.pinned) body.scrollTop = body.scrollHeight;
1514
+ else body.scrollTop = saved.top;
1515
+ }
1516
+ bindStateCardToggles();
1517
+ lastStateRoot = stateRoot;
1518
+ }
1519
+
1520
+ async function refreshState() {
1521
+ const peer = active ? managedPeerId(active) : null;
1522
+ const p = peer ? '/api/state?peer=' + encodeURIComponent(peer) : '/api/state';
1523
+ const data = await api(p);
1524
+ renderState(data);
1525
+ }
1526
+
1527
+ async function refreshDetectedState() {
1528
+ if (!activeDetected) return;
1529
+ const data = await api('/api/state?peer=' + encodeURIComponent(activeDetected));
1530
+ renderState(data);
1531
+ }
1532
+
1533
+ async function refreshCurrentState() {
1534
+ if (activeType === 'detected') return refreshDetectedState();
1535
+ return refreshState();
1536
+ }
1537
+
1538
+ async function refreshVisibleData() {
1539
+ if (autoPollInFlight) return;
1540
+ autoPollInFlight = true;
1541
+ try {
1542
+ await Promise.all([
1543
+ refreshSessions(),
1544
+ refreshDetected(),
1545
+ activeType === 'detected' ? refreshDetectedState() : (active ? refreshState() : Promise.resolve())
1546
+ ]);
1547
+ } finally {
1548
+ autoPollInFlight = false;
1549
+ }
1550
+ }
1551
+
1552
+ async function refreshProjectsQuietly() {
1553
+ if (projectPollInFlight) return;
1554
+ projectPollInFlight = true;
1555
+ try {
1556
+ await loadProjects();
1557
+ } finally {
1558
+ projectPollInFlight = false;
1559
+ }
1560
+ }
1561
+
1562
+ // ── Connect to managed (PTY) session ─────────────────────────────────
1563
+ function connectManaged(id) {
1564
+ const meta = sessions.find((s) => s.id === id);
1565
+ const peerId = sessionPeerId(meta) || id;
1566
+ active = id;
1567
+ activeDetected = null;
1568
+ activeType = 'managed';
1569
+ renderSections();
1570
+ document.getElementById('activeTitle').textContent = peerId;
1571
+ document.getElementById('activeMeta').textContent = meta ? sessionMetaText(meta) : '';
1572
+ document.getElementById('terminal').style.display = '';
1573
+ document.getElementById('detectedPanel').style.display = 'none';
1574
+ document.getElementById('quickBar').style.display = '';
1575
+
1576
+ if (ws) { clearTimeout(wsReconnectTimer); ws.close(); }
1577
+ term.reset();
1578
+ terminalHasContent = false;
1579
+ terminalLastDataAt = 0;
1580
+ terminalLastReplaceAt = 0;
1581
+ terminalLastResizeAt = Date.now();
1582
+ openWs(id);
1583
+ refreshState().catch(console.error);
1584
+ }
1585
+
1586
+ function terminalPinned() {
1587
+ try {
1588
+ const b = term.buffer.active;
1589
+ return b.viewportY >= b.baseY;
1590
+ } catch {
1591
+ return true;
1592
+ }
1593
+ }
1594
+
1595
+ function writeTerminalSnapshot(data, pinned = true) {
1596
+ term.reset();
1597
+ terminalHasContent = Boolean(data);
1598
+ terminalLastReplaceAt = Date.now();
1599
+ term.write(data || '', pinned ? () => { term.scrollToBottom(); } : undefined);
1600
+ }
1601
+
1602
+ function shouldApplyTerminalReplace() {
1603
+ const t = Date.now();
1604
+ if (!terminalHasContent) return true;
1605
+ if (t - terminalLastResizeAt <= 1500) return true;
1606
+ // Server fallback replace frames are useful for recovery, but applying
1607
+ // them during normal idle chat causes visible rollback/flicker.
1608
+ return document.visibilityState !== 'visible' &&
1609
+ t - terminalLastDataAt > 30000 &&
1610
+ t - terminalLastReplaceAt > 15000;
1611
+ }
1612
+
1613
+ function openWs(id) {
1614
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
1615
+ ws = new WebSocket(proto + '://' + location.host + '/ws/terminal/' + encodeURIComponent(id) + requestQuery());
1616
+ ws.onopen = () => {
1617
+ resizeTerm();
1618
+ connText('attached');
1619
+ };
1620
+ ws.onmessage = (event) => {
1621
+ const msg = JSON.parse(event.data);
1622
+ const pinned = terminalPinned();
1623
+ // The server streams the tmux pane's raw output, so xterm renders
1624
+ // incrementally (no reset/redraw → no flicker) and the program's own
1625
+ // escape sequences carry the cursor.
1626
+ if (msg.type === 'snapshot') { writeTerminalSnapshot(msg.data || '', true); }
1627
+ if (msg.type === 'data') {
1628
+ terminalHasContent = true;
1629
+ terminalLastDataAt = Date.now();
1630
+ term.write(msg.data || '', pinned ? () => { term.scrollToBottom(); } : undefined);
1631
+ }
1632
+ if (msg.type === 'replace' && shouldApplyTerminalReplace()) {
1633
+ writeTerminalSnapshot(msg.data || '', pinned);
1634
+ }
1635
+ if (msg.type === 'exit') { refreshSessions().catch(console.error); }
1636
+ };
1637
+ ws.onclose = () => {
1638
+ connText('reconnecting');
1639
+ // Auto-reconnect if session is still in the list and running
1640
+ wsReconnectTimer = setTimeout(() => {
1641
+ const s = sessions.find((s) => s.id === id);
1642
+ if (s && s.status === 'running' && active === id) openWs(id);
1643
+ else connText('online');
1644
+ }, 2000);
1645
+ };
1646
+ }
1647
+
1648
+ // ── Connect to detected (coordination-only) peer ──────────────────────
1649
+ function connectDetected(id) {
1650
+ const peer = detected.find((p) => p.id === id);
1651
+ active = null;
1652
+ activeDetected = id;
1653
+ activeType = 'detected';
1654
+ if (ws) { clearTimeout(wsReconnectTimer); ws.close(); ws = null; }
1655
+ renderSections();
1656
+ document.getElementById('activeTitle').textContent = id + ' (' + tr('detected') + ')';
1657
+ document.getElementById('activeMeta').textContent = peer ? peer.kind + ' · ' + (peer.worktree || '') : '';
1658
+ document.getElementById('terminal').style.display = 'none';
1659
+ document.getElementById('detectedPanel').style.display = '';
1660
+ document.getElementById('quickBar').style.display = 'none';
1661
+ connText('coordinationOnly');
1662
+ renderDetectedPanel(peer || { id });
1663
+ refreshDetectedState().catch(console.error);
1664
+ }
1665
+
1666
+ function renderDetectedPanel(peer) {
1667
+ const dp = document.getElementById('detectedPanel');
1668
+ dp.innerHTML = \`
1669
+ <div style="padding:16px;display:grid;gap:12px">
1670
+ <div class="card">
1671
+ <h2>\${esc(tr('detectedSession'))}</h2>
1672
+ <div class="body">
1673
+ <div class="item"><strong>\${esc(tr('peer'))}</strong><span class="mono">\${esc(peer.id)}</span></div>
1674
+ <div class="item"><strong>\${esc(tr('kind'))}</strong><span>\${esc(peer.kind || '')}</span></div>
1675
+ <div class="item"><strong>\${esc(tr('status'))}</strong><span>\${esc(peer.status || '')}</span></div>
1676
+ <div class="item"><strong>\${esc(tr('cwd'))}</strong><span class="mono" style="font-size:11px">\${esc(peer.worktree || '')}</span></div>
1677
+ <div class="item"><strong>\${esc(tr('pid'))}</strong><span>\${esc(peer.pid || tr('unknown'))}</span></div>
1678
+ <div class="item"><strong>\${esc(tr('lastSeen'))}</strong><span>\${peer.age_sec != null ? esc(peer.age_sec + tr('secondsAgo')) : ''}</span></div>
1679
+ </div>
1680
+ </div>
1681
+ <div class="card">
1682
+ <h2>\${esc(tr('sendMessage'))}</h2>
1683
+ <div class="body" style="gap:8px">
1684
+ <div style="font-size:12px;color:var(--muted)">\${tr('messageHelp')}</div>
1685
+ <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>
1686
+ <button class="primary" id="sendDetMsg">\${esc(tr('send'))}</button>
1687
+ </div>
1688
+ </div>
1689
+ </div>
1690
+ \`;
1691
+ document.getElementById('sendDetMsg').addEventListener('click', async () => {
1692
+ const body = document.getElementById('detMsg').value.trim();
1693
+ if (!body) return;
1694
+ await api('/api/detected/' + encodeURIComponent(peer.id) + '/msg', {
1695
+ method: 'POST',
1696
+ body: JSON.stringify({ body })
1697
+ });
1698
+ document.getElementById('detMsg').value = '';
1699
+ await refreshDetectedState();
1700
+ });
1701
+ }
1702
+
1703
+ // ── Helpers ───────────────────────────────────────────────────────────
1704
+ function sendLine(text) {
1705
+ if (!active || !ws || ws.readyState !== WebSocket.OPEN) return;
1706
+ ws.send(JSON.stringify({ type: 'input', data: text + '\\r' }));
1707
+ }
1708
+
1709
+ // ── Start session form ────────────────────────────────────────────────
1710
+ document.getElementById('kind').addEventListener('change', syncStartModeOptions);
1711
+ document.getElementById('startMode').addEventListener('change', syncStartModeOptions);
1712
+ document.getElementById('resumeSelect').addEventListener('change', toggleResumeCustom);
1713
+ syncStartModeOptions();
1714
+
1715
+ document.getElementById('startForm').addEventListener('submit', async (event) => {
1716
+ event.preventDefault();
1717
+ const kind = document.getElementById('kind').value;
1718
+ const mode = document.getElementById('startMode').value;
1719
+ const sel = document.getElementById('resumeSelect');
1720
+ const resume = (sel.value && sel.value !== '__custom__')
1721
+ ? sel.value
1722
+ : document.getElementById('resumeArg').value.trim();
1723
+ if (mode === 'resume' && !resume) return;
1724
+ const payload = { kind, mode };
1725
+ if (mode === 'resume') payload.resume = resume;
1726
+ const data = await api('/api/sessions', { method: 'POST', body: JSON.stringify(payload) });
1727
+ await refreshSessions();
1728
+ connectManaged(data.session.id);
1729
+ });
1730
+
1731
+ function activePeerInfo() {
1732
+ if (!active) return null;
1733
+ const session = sessions.find((s) => s.id === active) || { id: active, kind: 'other', role: 'peer' };
1734
+ return {
1735
+ session,
1736
+ peerId: sessionPeerId(session) || active,
1737
+ actionToken: session.action_token || ''
1738
+ };
1739
+ }
1740
+
1741
+ function terminalCommandForAction(action, info) {
1742
+ const session = info.session || {};
1743
+ const peerId = info.peerId;
1744
+ const lines = {
1745
+ register: \`hcc register --peer \${peerId} --kind \${session.kind || 'other'} --role \${session.role || 'peer'}\`,
1746
+ inbox: \`hcc msg inbox --peer \${peerId}\`,
1747
+ 'task-next': \`hcc task next --peer \${peerId}\`,
1748
+ state: \`hcc state --peer \${peerId}\`,
1749
+ status: \`hcc status --peer \${peerId}\`,
1750
+ heartbeat:\`hcc heartbeat --peer \${peerId} --renew-locks\`
1751
+ };
1752
+ return lines[action] || lines.status;
1753
+ }
1754
+
1755
+ function formatActionResult(result) {
1756
+ if (!result) return '';
1757
+ const data = result.data || {};
1758
+ if (result.action === 'status') {
1759
+ const tasks = (data.tasks || []).map((row) => row.status + ':' + row.n).join(', ') || 'none';
1760
+ return [
1761
+ data.root,
1762
+ 'peers active=' + data.active_peers + ' stale=' + data.stale_peers,
1763
+ 'tasks ' + tasks,
1764
+ 'locks active=' + data.active_locks,
1765
+ 'unread ' + (data.unread ?? 0)
1766
+ ].filter(Boolean).join('\\n');
1767
+ }
1768
+ if (result.action === 'state') {
1769
+ const automation = data.automation || {};
1770
+ const next = automation.next_action || {};
1771
+ return [
1772
+ 'phase: ' + (automation.phase || 'idle'),
1773
+ 'next: ' + (next.command || next.kind || 'none'),
1774
+ 'why: ' + (next.reason || tr('noImmediateAction')),
1775
+ ...(automation.warnings || []).map((w) => 'warning: ' + w)
1776
+ ].join('\\n');
1777
+ }
1778
+ if (result.action === 'inbox') {
1779
+ const messages = data.messages || [];
1780
+ return messages.length
1781
+ ? messages.map((m) => '#' + m.id + ' ' + m.sender + ' -> ' + (m.recipient || 'all') + ': ' + m.body).join('\\n')
1782
+ : tr('noMessages');
1783
+ }
1784
+ if (result.action === 'task-next') {
1785
+ return data.task
1786
+ ? (data.current ? 'current ' : 'claimed ') + '#' + data.task.id + ' ' + data.task.title + ' (' + data.task.status + ')'
1787
+ : result.summary;
1788
+ }
1789
+ if (result.action === 'task-takeover') {
1790
+ return data.task ? '#' + data.task.id + ' ' + data.task.title + ' -> ' + data.task.owner : result.summary;
1791
+ }
1792
+ if (result.action === 'lock-acquire') {
1793
+ return data.lock ? 'locked ' + lockLabel(data.lock) + ' by ' + data.lock.owner : result.summary;
1794
+ }
1795
+ if (result.action === 'lock-release') {
1796
+ return result.summary || JSON.stringify(data.result || data, null, 2);
1797
+ }
1798
+ return result.summary || JSON.stringify(result, null, 2);
1799
+ }
1800
+
1801
+ function showActionResult(result) {
1802
+ const panel = document.getElementById('actionResult');
1803
+ document.getElementById('actionResultTitle').textContent = (result.action || tr('actionResult')) + ' · ' + (result.peer || '');
1804
+ document.getElementById('actionResultBody').textContent = formatActionResult(result);
1805
+ panel.hidden = false;
1806
+ }
1807
+
1808
+ async function runPeerAction(action) {
1809
+ const info = activePeerInfo();
1810
+ if (!info) return;
1811
+ const readOnly = ['status', 'state', 'inbox'].includes(action);
1812
+ const payload = action === 'register'
1813
+ ? { kind: info.session.kind || 'other', role: info.session.role || 'peer', worktree: info.session.cwd || currentProject }
1814
+ : action === 'heartbeat'
1815
+ ? { renew_locks: true }
1816
+ : {};
1817
+ if (!readOnly) payload.action_token = info.actionToken;
1818
+ const result = await api('/api/peers/' + encodeURIComponent(info.peerId) + '/actions/' + encodeURIComponent(action), readOnly
1819
+ ? {}
1820
+ : { method: 'POST', body: JSON.stringify(payload) });
1821
+ showActionResult(result);
1822
+ await Promise.all([refreshSessions(), refreshDetected(), refreshCurrentState()]);
1823
+ }
1824
+
1825
+ document.querySelectorAll('[data-action]').forEach((button) => {
1826
+ button.addEventListener('click', () => {
1827
+ closeActionsMenu();
1828
+ runPeerAction(button.dataset.action).catch((err) => {
1829
+ showActionResult({ action: button.dataset.action, peer: activePeerInfo()?.peerId || '', summary: err.message, data: { error: err.message } });
1830
+ });
1831
+ });
1832
+ });
1833
+ document.querySelectorAll('[data-terminal-action]').forEach((button) => {
1834
+ button.addEventListener('click', () => {
1835
+ const info = activePeerInfo();
1836
+ if (!info) return;
1837
+ sendLine(terminalCommandForAction(button.dataset.terminalAction, info));
1838
+ closeActionsMenu();
1839
+ });
1840
+ });
1841
+ document.getElementById('actionResultClose').addEventListener('click', () => {
1842
+ document.getElementById('actionResult').hidden = true;
1843
+ });
1844
+
1845
+ // ── Stop confirmation dialog ───────────────────────────────────────
1846
+ const stopDialog = document.getElementById('stopDialog');
1847
+ const stopConfirmBtn = document.getElementById('stopConfirmBtn');
1848
+ const stopCancelBtn = document.getElementById('stopCancelBtn');
1849
+ const stopKillCb = document.getElementById('stopKillCb');
1850
+
1851
+ // ── Actions dropdown (declutters the toolbar) ─────────────────────────
1852
+ const actionsBtn = document.getElementById('actionsBtn');
1853
+ const actionsMenu = document.getElementById('actionsMenu');
1854
+ function closeActionsMenu() {
1855
+ actionsMenu.hidden = true;
1856
+ actionsBtn.setAttribute('aria-expanded', 'false');
1857
+ }
1858
+ actionsBtn.addEventListener('click', (e) => {
1859
+ e.stopPropagation();
1860
+ const open = actionsMenu.hidden;
1861
+ actionsMenu.hidden = !open;
1862
+ actionsBtn.setAttribute('aria-expanded', String(open));
1863
+ if (open) {
1864
+ // Position as a fixed layer so it escapes the toolbar/main overflow clip.
1865
+ const r = actionsBtn.getBoundingClientRect();
1866
+ actionsMenu.style.top = (r.bottom + 6) + 'px';
1867
+ actionsMenu.style.left = Math.max(8, r.right - actionsMenu.offsetWidth) + 'px';
1868
+ }
1869
+ });
1870
+ document.addEventListener('click', (e) => {
1871
+ if (!actionsMenu.hidden && !e.target.closest('.menu-wrap')) closeActionsMenu();
1872
+ });
1873
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeActionsMenu(); });
1874
+
1875
+ // ── Collapsible side panels ───────────────────────────────────────────
1876
+ const appEl = document.querySelector('.app');
1877
+ const toggleLeftBtn = document.getElementById('toggleLeft');
1878
+ const toggleRightBtn = document.getElementById('toggleRight');
1879
+ const resizeLeftHandle = document.getElementById('resizeLeft');
1880
+ const resizeRightHandle = document.getElementById('resizeRight');
1881
+ const sideDefaults = { left: 320, right: 360 };
1882
+ const sideMin = { left: 220, right: 240 };
1883
+ const sideMax = { left: 560, right: 640 };
1884
+ const sideWidthKey = { left: 'hcc.sidebar.left.width', right: 'hcc.sidebar.right.width' };
1885
+ const dragState = { side: null, startX: 0, startWidth: 0, moved: false, suppressClick: false };
1886
+ function sideIsCollapsed(side) {
1887
+ return appEl.classList.contains(side + '-collapsed') || localStorage.getItem('hcc.collapse.' + side) === '1';
1888
+ }
1889
+ function storedSideWidth(side) {
1890
+ const raw = Number(localStorage.getItem(sideWidthKey[side]));
1891
+ return Number.isFinite(raw) && raw > 0 ? raw : sideDefaults[side];
1892
+ }
1893
+ function effectiveOppositeWidth(side) {
1894
+ const opposite = side === 'left' ? 'right' : 'left';
1895
+ return sideIsCollapsed(opposite) ? 0 : storedSideWidth(opposite);
1896
+ }
1897
+ function clampSideWidth(side, width) {
1898
+ const viewport = Math.max(640, window.innerWidth || appEl.clientWidth || 0);
1899
+ const opposite = effectiveOppositeWidth(side);
1900
+ const maxByViewport = Math.max(sideMin[side], viewport - opposite - 280);
1901
+ return Math.max(sideMin[side], Math.min(sideMax[side], maxByViewport, Math.round(width)));
1902
+ }
1903
+ function readSideWidth(side) {
1904
+ return clampSideWidth(side, storedSideWidth(side));
1905
+ }
1906
+ function setSideWidth(side, width, persist = true) {
1907
+ const value = clampSideWidth(side, width);
1908
+ appEl.style.setProperty('--' + side + '-width', value + 'px');
1909
+ if (persist) localStorage.setItem(sideWidthKey[side], String(value));
1910
+ return value;
1911
+ }
1912
+ function applySideWidths() {
1913
+ setSideWidth('left', readSideWidth('left'), false);
1914
+ setSideWidth('right', readSideWidth('right'), false);
1915
+ }
1916
+ function syncToggleIcons() {
1917
+ const l = appEl.classList.contains('left-collapsed');
1918
+ const r = appEl.classList.contains('right-collapsed');
1919
+ toggleLeftBtn.textContent = l ? '⟩' : '⟨';
1920
+ toggleLeftBtn.title = (l ? tr('show') : tr('collapse')) + ' ' + tr('sidebar') + '; ' + tr('dragToResize');
1921
+ toggleRightBtn.textContent = r ? '⟨' : '⟩';
1922
+ toggleRightBtn.title = (r ? tr('show') : tr('collapse')) + ' ' + tr('statePanel') + '; ' + tr('dragToResize');
1923
+ }
1924
+ function applyCollapseState() {
1925
+ appEl.classList.toggle('left-collapsed', localStorage.getItem('hcc.collapse.left') === '1');
1926
+ appEl.classList.toggle('right-collapsed', localStorage.getItem('hcc.collapse.right') === '1');
1927
+ applySideWidths();
1928
+ syncToggleIcons();
1929
+ }
1930
+ function toggleSide(side) {
1931
+ const cls = side + '-collapsed';
1932
+ const on = appEl.classList.toggle(cls);
1933
+ localStorage.setItem('hcc.collapse.' + side, on ? '1' : '0');
1934
+ applySideWidths();
1935
+ syncToggleIcons();
1936
+ // Refit the terminal after the grid transition so xterm uses the new width.
1937
+ setTimeout(() => { try { resizeTerm(); } catch {} }, 200);
1938
+ }
1939
+ function beginSideDrag(side, event) {
1940
+ if (event.button !== undefined && event.button !== 0) return;
1941
+ dragState.side = side;
1942
+ dragState.startX = event.clientX;
1943
+ dragState.startWidth = readSideWidth(side);
1944
+ dragState.moved = false;
1945
+ dragState.suppressClick = false;
1946
+ appEl.classList.add('resizing');
1947
+ event.currentTarget.setPointerCapture?.(event.pointerId);
1948
+ event.preventDefault();
1949
+ }
1950
+ function moveSideDrag(event) {
1951
+ if (!dragState.side) return;
1952
+ const delta = event.clientX - dragState.startX;
1953
+ if (!dragState.moved && Math.abs(delta) <= 3) return;
1954
+ dragState.moved = true;
1955
+ const width = dragState.side === 'left'
1956
+ ? dragState.startWidth + delta
1957
+ : dragState.startWidth - delta;
1958
+ setSideWidth(dragState.side, width);
1959
+ if (dragState.side === 'left') {
1960
+ appEl.classList.remove('left-collapsed');
1961
+ localStorage.setItem('hcc.collapse.left', '0');
1962
+ } else {
1963
+ appEl.classList.remove('right-collapsed');
1964
+ localStorage.setItem('hcc.collapse.right', '0');
1965
+ }
1966
+ syncToggleIcons();
1967
+ try { resizeTerm(); } catch {}
1968
+ event.preventDefault();
1969
+ }
1970
+ function endSideDrag(event) {
1971
+ if (!dragState.side) return;
1972
+ dragState.suppressClick = dragState.moved;
1973
+ event.currentTarget.releasePointerCapture?.(event.pointerId);
1974
+ dragState.side = null;
1975
+ appEl.classList.remove('resizing');
1976
+ setTimeout(() => { dragState.suppressClick = false; }, 0);
1977
+ try { resizeTerm(); } catch {}
1978
+ }
1979
+ function bindSideHandle(handle, side, clickAction = null) {
1980
+ handle.addEventListener('pointerdown', (event) => beginSideDrag(side, event));
1981
+ handle.addEventListener('pointermove', moveSideDrag);
1982
+ handle.addEventListener('pointerup', endSideDrag);
1983
+ handle.addEventListener('pointercancel', endSideDrag);
1984
+ handle.addEventListener('click', (event) => {
1985
+ if (dragState.suppressClick) {
1986
+ event.preventDefault();
1987
+ return;
1988
+ }
1989
+ if (clickAction) clickAction(side);
1990
+ });
1991
+ }
1992
+ bindSideHandle(resizeLeftHandle, 'left');
1993
+ bindSideHandle(resizeRightHandle, 'right');
1994
+ bindSideHandle(toggleLeftBtn, 'left', toggleSide);
1995
+ bindSideHandle(toggleRightBtn, 'right', toggleSide);
1996
+ window.addEventListener('resize', () => {
1997
+ applySideWidths();
1998
+ setTimeout(() => { try { resizeTerm(); } catch {} }, 50);
1999
+ });
2000
+ applyCollapseState();
2001
+
2002
+ document.getElementById('stopBtn').addEventListener('click', () => {
2003
+ if (!active) return;
2004
+ const session = sessions.find((s) => s.id === active) || {};
2005
+ document.getElementById('stopDialogTitle').textContent = tr('stop') + ' ' + (sessionPeerId(session) || active) + '?';
2006
+ stopKillCb.checked = false;
2007
+ const meta = sessionMetaText(session);
2008
+ document.getElementById('stopDialogMeta').textContent = meta || '';
2009
+ stopDialog._action = null; stopDialog._peerId = null;
2010
+ stopDialog.hidden = false;
2011
+ });
2012
+ document.getElementById('refreshBtn').addEventListener('click', () => {
2013
+ refreshVisibleData().catch(console.error);
2014
+ });
2015
+
2016
+ applyLanguage();
2017
+ loadProjects().then(() => Promise.all([refreshSessions(), refreshDetected(), refreshState()])).then(() => {
2018
+ // Auto-connect to first running managed session on load
2019
+ if (!active && !activeDetected) {
2020
+ const first = sessions.find(s => s.status === 'running');
2021
+ if (first) connectManaged(first.id);
2022
+ }
2023
+ }).catch((err) => {
2024
+ connText('error');
2025
+ console.error(err);
2026
+ });
2027
+ // ── Auto-poll state ──────────────────────────────────────────────────
2028
+ setInterval(() => {
2029
+ refreshVisibleData().catch(console.error);
2030
+ }, 3000);
2031
+ setInterval(() => {
2032
+ refreshProjectsQuietly().catch(console.error);
2033
+ }, 8000);
2034
+
2035
+ stopCancelBtn.addEventListener('click', () => { stopDialog.hidden = true; });
2036
+ stopDialog.addEventListener('click', (e) => { if (e.target === stopDialog) stopDialog.hidden = true; });
2037
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !stopDialog.hidden) stopDialog.hidden = true; });
2038
+
2039
+ stopConfirmBtn.addEventListener('click', async () => {
2040
+ stopDialog.hidden = true;
2041
+ const killTmux = stopKillCb.checked;
2042
+ if (stopDialog._action === 'detected') {
2043
+ const peerId = stopDialog._peerId;
2044
+ stopDialog._action = null; stopDialog._peerId = null;
2045
+ if (!peerId) return;
2046
+ await api('/api/detected/' + encodeURIComponent(peerId) + '/stop', { method: 'POST', body: JSON.stringify({ kill_tmux: killTmux }) });
2047
+ await Promise.all([refreshSessions(), refreshDetected()]);
2048
+ } else {
2049
+ if (!active) return;
2050
+ await api('/api/sessions/' + encodeURIComponent(active) + '/stop', { method: 'POST', body: JSON.stringify({ kill_tmux: killTmux }) });
2051
+ await refreshSessions();
2052
+ }
2053
+ });
2054
+ </script>
2055
+ </body>
2056
+ </html>`;
2057
+ }