@logicseek/hello-cc 1.0.1 → 1.0.2

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 (97) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +4 -0
  3. package/README.zh-CN.md +4 -0
  4. package/docs/README.md +9 -0
  5. package/docs/README.zh-CN.md +9 -0
  6. package/docs/commands.md +53 -0
  7. package/docs/commands.zh-CN.md +48 -0
  8. package/docs/dsh.md +137 -0
  9. package/docs/dsh.zh-CN.md +139 -0
  10. package/docs/mcp.md +9 -0
  11. package/docs/mcp.zh-CN.md +23 -0
  12. package/docs/native.md +295 -0
  13. package/docs/native.zh-CN.md +253 -0
  14. package/docs/web-handoff.zh-CN.md +53 -0
  15. package/lib/cli/app.mjs +25 -6
  16. package/lib/cli/commands/doctor.mjs +17 -2
  17. package/lib/cli/commands/dsh.mjs +79 -0
  18. package/lib/cli/commands/hook.mjs +241 -90
  19. package/lib/cli/commands/lock.mjs +34 -19
  20. package/lib/cli/commands/mcp.mjs +28 -0
  21. package/lib/cli/commands/native.mjs +130 -0
  22. package/lib/cli/commands/run.mjs +66 -21
  23. package/lib/cli/commands/task.mjs +6 -3
  24. package/lib/cli-args.mjs +4 -0
  25. package/lib/coordination-state.mjs +15 -1
  26. package/lib/core/coordination/event-retention.mjs +14 -2
  27. package/lib/core/coordination/gc-plan.mjs +3 -13
  28. package/lib/core/coordination/locks.mjs +45 -0
  29. package/lib/core/coordination/results.mjs +79 -0
  30. package/lib/core/peers/bindings.mjs +9 -3
  31. package/lib/diagnostics/codex.mjs +121 -0
  32. package/lib/integrations/codex-interactions.mjs +114 -0
  33. package/lib/integrations/dsh-collaboration.mjs +170 -0
  34. package/lib/integrations/dsh-cordis.d.ts +19 -0
  35. package/lib/integrations/dsh-cordis.mjs +177 -0
  36. package/lib/integrations/dsh.bundle.yml +7 -0
  37. package/lib/integrations/dsh.mjs +287 -0
  38. package/lib/integrations/native/claude.mjs +451 -0
  39. package/lib/integrations/native/codex.mjs +242 -0
  40. package/lib/integrations/native/dsh-acp.mjs +343 -0
  41. package/lib/integrations/native/index.mjs +19 -0
  42. package/lib/integrations/native/interactions.mjs +53 -0
  43. package/lib/integrations/native/jsonrpc.mjs +185 -0
  44. package/lib/integrations/peers/identity.mjs +36 -10
  45. package/lib/integrations/peers/processes.mjs +22 -8
  46. package/lib/integrations/providers.mjs +6 -2
  47. package/lib/mcp/scope.mjs +104 -0
  48. package/lib/mcp/stdio.mjs +80 -0
  49. package/lib/mcp/tools.mjs +197 -0
  50. package/lib/process/identity.mjs +3 -1
  51. package/lib/release/release-notes.mjs +9 -0
  52. package/lib/runtime/native/client.mjs +38 -0
  53. package/lib/runtime/native/service.mjs +511 -0
  54. package/lib/runtime/native/store.mjs +121 -0
  55. package/lib/ui/help.mjs +5 -2
  56. package/lib/web/adoption-state.mjs +55 -0
  57. package/lib/web/auto-attach.mjs +18 -2
  58. package/lib/web/codex-app-server.mjs +557 -0
  59. package/lib/web/codex-sessions.mjs +287 -0
  60. package/lib/web/control-lease.mjs +204 -0
  61. package/lib/web/http-routes.mjs +197 -15
  62. package/lib/web/http.mjs +5 -4
  63. package/lib/web/native-sessions.mjs +286 -0
  64. package/lib/web/project-contexts.mjs +2 -1
  65. package/lib/web/pty-sessions.mjs +60 -33
  66. package/lib/web/runtime-main.mjs +98 -6
  67. package/lib/web/session-serialize.mjs +0 -0
  68. package/lib/web/task-results.mjs +76 -0
  69. package/lib/web/tmux-sessions.mjs +92 -11
  70. package/lib/web/tmux-stream.mjs +2 -2
  71. package/lib/web/ui-codex.mjs +550 -0
  72. package/lib/web/ui-command-palette.mjs +119 -0
  73. package/lib/web/ui-handoff.mjs +102 -0
  74. package/lib/web/ui-history.mjs +118 -0
  75. package/lib/web/ui-i18n.mjs +841 -0
  76. package/lib/web/ui-interactions.mjs +78 -0
  77. package/lib/web/ui-login.mjs +101 -0
  78. package/lib/web/ui-native.mjs +165 -0
  79. package/lib/web/ui-preferences.mjs +278 -0
  80. package/lib/web/ui-review.mjs +132 -0
  81. package/lib/web/ui-template.mjs +1106 -576
  82. package/lib/web/ui-terminal-find.mjs +98 -0
  83. package/lib/web/ui-workbench.mjs +94 -0
  84. package/lib/web/ui-workspace.mjs +222 -0
  85. package/package.json +11 -3
  86. package/scripts/check-free-identifiers.mjs +67 -8
  87. package/scripts/codex-diagnostics-installed-acceptance.mjs +72 -0
  88. package/scripts/dsh-cordis-acceptance.mjs +200 -0
  89. package/scripts/dsh-installed-acceptance.mjs +247 -0
  90. package/scripts/dsh-official-acceptance.mjs +180 -0
  91. package/scripts/dsh-web-startup-acceptance.mjs +87 -0
  92. package/scripts/github-release.mjs +3 -1
  93. package/scripts/native-live-acceptance.mjs +369 -0
  94. package/scripts/regression.mjs +243 -89
  95. package/scripts/web-handoff-installed-acceptance.mjs +98 -0
  96. package/scripts/web-native-interaction-acceptance.mjs +204 -0
  97. package/scripts/web-native-model-acceptance.mjs +138 -0
@@ -1,4 +1,15 @@
1
1
  import { API_VERSION } from './api-version.mjs';
2
+ import { uiPreferencesCss, uiPreferencesScript, preferenceControlsHtml, TERMINAL_THEMES } from './ui-preferences.mjs';
3
+ import { renderWebLoginPage } from './ui-login.mjs';
4
+ import { handoffCss, handoffBarHtml, terminalComposerHtml, uiHandoffScript } from './ui-handoff.mjs';
5
+ import { codexPanelHtml, codexPanelScript } from './ui-codex.mjs';
6
+ import { workbenchCss, workbenchControlsHtml, workbenchScript } from './ui-workbench.mjs';
7
+ import { codexHistoryHtml, codexHistoryScript } from './ui-history.mjs';
8
+ import { auxiliaryPanelsCss, reviewPanelHtml, reviewPanelScript } from './ui-review.mjs';
9
+ import { nativePanelHtml, nativePanelScript } from './ui-native.mjs';
10
+ import { commandPaletteCss, commandPaletteHtml, commandPaletteScript } from './ui-command-palette.mjs';
11
+ import { terminalFindCss, terminalFindHtml, terminalFindScript } from './ui-terminal-find.mjs';
12
+ import { workspaceCss, workspaceBarHtml, workspaceSecondaryHtml, workspaceScript } from './ui-workspace.mjs';
2
13
 
3
14
  function nonceAttribute(nonce) {
4
15
  const value = String(nonce || '');
@@ -8,7 +19,7 @@ function nonceAttribute(nonce) {
8
19
  return ` nonce="${value}"`;
9
20
  }
10
21
 
11
- export function webIndexHtml({ nonce } = {}) {
22
+ export function webIndexHtml({ nonce, pane = false } = {}) {
12
23
  const inlineScriptNonce = nonceAttribute(nonce);
13
24
  return `<!doctype html>
14
25
  <html lang="en">
@@ -16,23 +27,16 @@ export function webIndexHtml({ nonce } = {}) {
16
27
  <meta charset="utf-8">
17
28
  <meta name="viewport" content="width=device-width, initial-scale=1">
18
29
  <title>hello-cc</title>
30
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23087b65'/%3E%3Cpath d='m14 20 14 12-14 12m20 0h16' fill='none' stroke='white' stroke-width='5'/%3E%3C/svg%3E">
31
+ <script${inlineScriptNonce}>window.hccDraftScope = ${pane ? '"auxiliary"' : '""'}; document.documentElement.classList.toggle('session-pane', Boolean(window.hccDraftScope)); ${uiPreferencesScript()}</script>
19
32
  <link rel="stylesheet" href="/assets/xterm.css">
20
33
  <style>
21
- :root {
22
- color-scheme: dark;
23
- --bg: #101214;
24
- --panel: #181b1f;
25
- --panel-2: #20242a;
26
- --border: #303640;
27
- --text: #eef2f6;
28
- --muted: #a3adba;
29
- --accent: #40c4aa;
30
- --warn: #f2bb4f;
31
- --danger: #ff6b6b;
32
- --ok: #75d17c;
33
- --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
34
- --sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
35
- }
34
+ ${uiPreferencesCss}
35
+ ${handoffCss}
36
+ ${workbenchCss}
37
+ ${auxiliaryPanelsCss}
38
+ ${commandPaletteCss}
39
+ ${terminalFindCss}
36
40
  * { box-sizing: border-box; }
37
41
  body {
38
42
  margin: 0;
@@ -49,19 +53,21 @@ export function webIndexHtml({ nonce } = {}) {
49
53
  border: 1px solid var(--border);
50
54
  background: var(--panel-2);
51
55
  color: var(--text);
52
- height: 32px;
56
+ height: var(--control-height);
53
57
  border-radius: 6px;
54
58
  padding: 0 10px;
55
59
  cursor: pointer;
56
60
  }
57
61
  button:hover { border-color: var(--accent); }
58
- button.primary { background: #1b5f54; border-color: #267f71; }
59
- button.danger { background: #5d252a; border-color: #8c333d; }
62
+ button.primary { background: var(--primary-bg); border-color: var(--primary-border); }
63
+ button.danger { background: var(--danger-bg); border-color: var(--danger-border); }
64
+ button:disabled { opacity: .55; cursor: default; }
65
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
60
66
  input, select {
61
67
  width: 100%;
62
- height: 32px;
68
+ height: var(--control-height);
63
69
  border: 1px solid var(--border);
64
- background: #0d0f12;
70
+ background: var(--input-bg);
65
71
  color: var(--text);
66
72
  border-radius: 6px;
67
73
  padding: 0 9px;
@@ -75,12 +81,12 @@ export function webIndexHtml({ nonce } = {}) {
75
81
  }
76
82
  .app {
77
83
  position: relative;
78
- height: 100vh;
84
+ height: calc(100dvh - 58px);
79
85
  display: grid;
80
86
  --left-width: 320px;
81
87
  --right-width: 360px;
82
88
  grid-template-columns: var(--left-width) minmax(0, 1fr) var(--right-width);
83
- min-width: 640px;
89
+ min-width: 0;
84
90
  transition: grid-template-columns .18s ease;
85
91
  }
86
92
  .app.resizing { transition: none; }
@@ -135,7 +141,7 @@ export function webIndexHtml({ nonce } = {}) {
135
141
  transition: left .18s ease, right .18s ease, color .12s, border-color .12s, background .12s;
136
142
  }
137
143
  .app.resizing .edge-toggle { transition: none; }
138
- .edge-toggle:hover { color: var(--text); border-color: var(--accent); background: #1b1f26; }
144
+ .edge-toggle:hover { color: var(--text); border-color: var(--accent); background: var(--hover-bg); }
139
145
  .edge-left { left: var(--left-width); transform: translate(-50%, -50%); }
140
146
  .edge-right { right: var(--right-width); transform: translate(50%, -50%); }
141
147
  .app.left-collapsed .edge-left { left: 0; transform: translate(0, -50%); }
@@ -148,12 +154,12 @@ export function webIndexHtml({ nonce } = {}) {
148
154
  background: var(--panel);
149
155
  border-right: 1px solid var(--border);
150
156
  display: grid;
151
- grid-template-rows: auto auto auto auto 1fr;
157
+ grid-template-rows: auto auto auto minmax(0, 1fr);
152
158
  }
153
159
  .inspector {
154
160
  border-right: 0;
155
161
  border-left: 1px solid var(--border);
156
- grid-template-rows: auto 1fr;
162
+ grid-template-rows: auto auto auto minmax(0, 1fr);
157
163
  }
158
164
  .sidebar > *, .inspector > * {
159
165
  min-width: 0;
@@ -214,9 +220,7 @@ export function webIndexHtml({ nonce } = {}) {
214
220
  .session-header {
215
221
  border-bottom: 1px solid var(--border);
216
222
  padding: 8px 10px;
217
- display: flex;
218
- align-items: center;
219
- justify-content: space-between;
223
+ display: grid;
220
224
  gap: 8px;
221
225
  min-height: 48px;
222
226
  }
@@ -224,9 +228,32 @@ export function webIndexHtml({ nonce } = {}) {
224
228
  font-size: 13px;
225
229
  font-weight: 600;
226
230
  }
227
- .session-header label {
228
- width: 118px;
229
- }
231
+ .project-picker { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 8px; }
232
+ .project-picker button { width: var(--control-height); padding: 0; font-size: 20px; }
233
+ .session-filters { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
234
+ .session-list-count { color: var(--muted); font-size: 11px; min-height: 15px; }
235
+ .session-title { flex: 1; min-width: 0; }
236
+ .session-subtitle { display: flex; align-items: center; gap: 7px; min-width: 0; color: var(--muted); font-size: 11px; }
237
+ .session-subtitle .path { flex: 1; min-width: 0; }
238
+ .session-provider { flex: 0 0 auto; font-family: var(--mono); }
239
+ .session-details { min-width: 0; color: var(--muted); font-size: 11px; cursor: auto; }
240
+ .session-details summary { width: fit-content; max-width: 100%; cursor: pointer; padding: 3px 0; }
241
+ .session-details[open] summary { margin-bottom: 6px; }
242
+ .session-details dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 5px 8px; margin: 0; }
243
+ .session-details dt { color: var(--muted); }
244
+ .session-details dd { margin: 0; color: var(--text); font-family: var(--mono); overflow-wrap: anywhere; }
245
+ .session-action { flex: 0 0 auto; width: 36px; height: 36px; padding: 0; display: inline-flex; align-items: center; justify-content: center; color: var(--muted); background: transparent; }
246
+ .session-action[data-action="restart-detected"] { color: var(--ok); }
247
+ .dialog-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
248
+ .dialog-heading button { width: 36px; height: 36px; padding: 0; font-size: 20px; }
249
+ .dialog-help { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
250
+ .dialog .form { padding: 0; border: 0; gap: 14px; }
251
+ #startForm [hidden], .dialog-help[hidden] { display: none; }
252
+ .dialog-help[role="alert"] { color: var(--danger); }
253
+ .dialog-target { font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
254
+ .session-empty { display: grid; gap: 12px; text-align: center; }
255
+ .session-empty p { margin: 0; }
256
+ @media (pointer: coarse) { .session-action { width: 44px; height: 44px; } .session-details summary { padding: 10px 0; } }
230
257
  .sessions, .state {
231
258
  min-height: 0;
232
259
  overflow-y: auto;
@@ -244,7 +271,7 @@ export function webIndexHtml({ nonce } = {}) {
244
271
  .sessions::-webkit-scrollbar-thumb, .state::-webkit-scrollbar-thumb { background: #3a3f4a; border-radius: 4px; }
245
272
  .session {
246
273
  border: 1px solid var(--border);
247
- background: #111418;
274
+ background: var(--card-bg);
248
275
  border-radius: 8px;
249
276
  padding: 9px;
250
277
  display: grid;
@@ -253,6 +280,8 @@ export function webIndexHtml({ nonce } = {}) {
253
280
  cursor: pointer;
254
281
  }
255
282
  .session.active { border-color: var(--accent); }
283
+ .session-select { min-width: 0; height: auto; padding: 0; border: 0; background: transparent; color: var(--text); text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
284
+ .session-select strong { font-size: var(--body-font); }
256
285
  .row {
257
286
  display: flex;
258
287
  align-items: center;
@@ -282,9 +311,9 @@ export function webIndexHtml({ nonce } = {}) {
282
311
  min-height: 0;
283
312
  min-width: 0;
284
313
  overflow: hidden;
285
- display: grid;
286
- grid-template-rows: auto 1fr;
287
- background: #0b0d10;
314
+ display: flex;
315
+ flex-direction: column;
316
+ background: var(--terminal-bg);
288
317
  }
289
318
  .toolbar {
290
319
  min-height: 48px;
@@ -356,7 +385,7 @@ export function webIndexHtml({ nonce } = {}) {
356
385
  border-radius: 6px;
357
386
  color: var(--text);
358
387
  }
359
- .menu button:hover { background: #1b1f26; }
388
+ .menu button:hover { background: var(--hover-bg); }
360
389
  .menu .divider {
361
390
  height: 1px;
362
391
  margin: 4px 2px;
@@ -396,19 +425,19 @@ export function webIndexHtml({ nonce } = {}) {
396
425
  font-family: var(--mono);
397
426
  font-size: 11px;
398
427
  color: var(--text);
399
- background: #0d0f12;
428
+ background: var(--input-bg);
400
429
  }
401
430
  /* Confirm dialog overlay */
402
431
  .dialog-overlay {
403
432
  position: fixed; inset: 0; z-index: 2000;
404
- background: rgba(0,0,0,0.55);
433
+ background: var(--overlay);
405
434
  display: flex; align-items: center; justify-content: center;
406
435
  }
407
436
  .dialog-overlay[hidden] { display: none; }
408
437
  .dialog {
409
438
  background: var(--panel); border: 1px solid var(--border);
410
439
  border-radius: 10px; padding: 20px 22px;
411
- min-width: 320px; max-width: 420px;
440
+ width: min(440px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); overflow-y: auto;
412
441
  display: grid; gap: 14px;
413
442
  box-shadow: 0 12px 40px rgba(0,0,0,.5);
414
443
  }
@@ -419,11 +448,14 @@ export function webIndexHtml({ nonce } = {}) {
419
448
  #terminal {
420
449
  min-height: 0;
421
450
  overflow: hidden;
422
- padding: 8px;
451
+ padding: 0;
423
452
  }
424
453
  #terminal .xterm {
425
454
  cursor: default;
455
+ padding: 8px;
456
+ background: inherit;
426
457
  }
458
+ #terminal .xterm-viewport { background: inherit; }
427
459
  /* The terminal mirrors a tmux pane; keep xterm's own hidden helper textarea
428
460
  off-screen, but DO show the rendered block cursor (positioned from tmux). */
429
461
  #terminal .xterm-helper-textarea {
@@ -439,7 +471,7 @@ export function webIndexHtml({ nonce } = {}) {
439
471
  .card {
440
472
  border: 1px solid var(--border);
441
473
  border-radius: 8px;
442
- background: #111418;
474
+ background: var(--card-bg);
443
475
  overflow: hidden;
444
476
  }
445
477
  .card.state-card {
@@ -478,7 +510,7 @@ export function webIndexHtml({ nonce } = {}) {
478
510
  cursor: pointer;
479
511
  text-align: left;
480
512
  }
481
- .state-card-toggle:hover { background: #151922; }
513
+ .state-card-toggle:hover { background: var(--hover-bg); }
482
514
  .state-card-toggle-title {
483
515
  min-width: 0;
484
516
  display: flex;
@@ -525,7 +557,7 @@ export function webIndexHtml({ nonce } = {}) {
525
557
  gap: 2px;
526
558
  font-size: 12px;
527
559
  color: var(--muted);
528
- border-bottom: 1px solid #242932;
560
+ border-bottom: 1px solid var(--border);
529
561
  padding-bottom: 6px;
530
562
  }
531
563
  .item:last-child { border-bottom: 0; padding-bottom: 0; }
@@ -537,54 +569,110 @@ export function webIndexHtml({ nonce } = {}) {
537
569
  .sec-spacer { flex: 1 1 auto; }
538
570
  .sec-label button { height: 22px; padding: 0 7px; font-size: 10px; text-transform: none; letter-spacing: 0; }
539
571
  #terminal { display: flex; flex-direction: column; }
572
+ body { font-size: var(--body-font); }
573
+ .path, .brand span, .badge, .action-result pre, .sec-label { font-size: var(--small-font); }
574
+ .form, .brand, .sessions, .state, .session { padding: var(--space); }
575
+ .form, .sessions, .state { gap: var(--space); }
576
+ .global-header { height: 58px; padding: 0 16px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--border); background: var(--panel); }
577
+ .global-header > strong { margin-right: auto; white-space: nowrap; font-size: 16px; letter-spacing: -.3px; }
578
+ .header-controls { display: flex; align-items: center; gap: 8px; }
579
+ .header-controls button { white-space: nowrap; }
580
+ .global-header #connState { max-width: none; font-size: var(--small-font); color: var(--muted); white-space: nowrap; }
581
+ .global-header #logoutBtn { width: 34px; padding: 0; }
582
+ .mobile-nav { display: none; }
583
+ .settings-fields { display: grid; gap: 16px; }
584
+ .settings-fields label { grid-template-columns: minmax(0, 1fr) minmax(150px, 1fr); align-items: center; gap: 12px; font-size: 13px; }
585
+ .settings-dialog header { display: flex; align-items: center; justify-content: space-between; }
586
+ .settings-dialog header button { width: 32px; padding: 0; }
587
+ .app.focus-mode { grid-template-columns: 0 minmax(0, 1fr) 0; }
588
+ .app.focus-mode .sidebar, .app.focus-mode .inspector { visibility: hidden; border: 0; }
589
+ .app.focus-mode .edge-toggle, .app.focus-mode .edge-resizer { display: none; }
590
+ .global-header button[aria-pressed="true"], .mobile-nav button[aria-current="page"] { background: var(--selection-bg); border-color: var(--accent); }
591
+ input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--accent); }
592
+ @media (max-width: 1099px) {
593
+ .global-header { padding: 0 12px; gap: 8px; }
594
+ .mobile-nav { height: 46px; display: flex; gap: 8px; padding: 6px 12px; border-bottom: 1px solid var(--border); background: var(--panel); }
595
+ .mobile-nav button { flex: 1; height: 32px; }
596
+ .app, .app.left-collapsed, .app.right-collapsed, .app.left-collapsed.right-collapsed, .app.focus-mode { height: calc(100dvh - 104px); grid-template-columns: minmax(0, 1fr); }
597
+ .app .sidebar, .app .main, .app .inspector { display: none; border: 0; visibility: visible; }
598
+ .app[data-view="sessions"] .sidebar, .app[data-view="state"] .inspector { display: grid; }
599
+ .app[data-view="terminal"] .main { display:flex; }
600
+ .app .edge-toggle, .app .edge-resizer { display: none; }
601
+ #focusBtn { display: none; }
602
+ .sidebar { grid-template-rows: auto auto auto minmax(0, 1fr); }
603
+ }
604
+ @media (max-width: 480px) {
605
+ .global-header { padding: 0 10px; }
606
+ .global-header > strong { font-size: 15px; }
607
+ .header-controls { gap: 6px; }
608
+ .global-header #connState { max-width: 88px; overflow: hidden; text-overflow: ellipsis; }
609
+ .toolbar { flex-wrap: wrap; padding: 8px 10px; }
610
+ .toolbar .title { flex-basis: 100%; }
611
+ .quick { margin-left: auto; }
612
+ .settings-fields label { grid-template-columns: 1fr; gap: 6px; }
613
+ }
614
+ @media (prefers-reduced-motion: reduce) { .app, .edge-toggle, .edge-resizer { transition: none; } }
615
+ ${workspaceCss}
540
616
  </style>
541
617
  </head>
542
618
  <body>
543
- <div class="app">
619
+ <header class="global-header">
620
+ <strong>hello-cc</strong>
621
+ <div class="header-controls">
622
+ <span id="connState" role="status">offline</span>
623
+ <button id="commandsBtn" type="button" data-i18n="commands.button" data-i18n-title="commands.shortcutHint" aria-haspopup="dialog" aria-controls="commandDialog" aria-keyshortcuts="Control+Shift+P Meta+Shift+P">Commands</button>
624
+ <button id="splitBtn" type="button" data-i18n="workspace.split" aria-pressed="false" disabled>Split</button>
625
+ <button id="focusBtn" type="button" data-i18n="focusMode" aria-pressed="false">Focus</button>
626
+ <button id="settingsBtn" type="button" data-i18n="settings" aria-haspopup="dialog" aria-controls="settingsDialog">Settings</button>
627
+ <button id="logoutBtn" type="button" data-i18n-title="logout" data-i18n-aria="logout" title="Log out" aria-label="Log out">↪</button>
628
+ </div>
629
+ </header>
630
+ <nav class="mobile-nav" data-i18n-aria="navigation" aria-label="Navigation">
631
+ <button type="button" data-view="sessions" data-i18n="sessions">Sessions</button>
632
+ <button type="button" data-view="terminal" data-i18n="terminal" aria-current="page">Terminal</button>
633
+ <button type="button" data-view="state" data-i18n="projectState">Project State</button>
634
+ </nav>
635
+ <div class="app" data-view="terminal">
544
636
  <aside class="sidebar">
545
637
  <div class="brand">
546
638
  <div>
547
- <strong>hello-cc</strong>
639
+ <strong data-i18n="project">Project</strong>
548
640
  <div class="path" id="rootPath"></div>
549
641
  </div>
550
- <div class="brand-actions">
551
- <span id="connState" data-i18n="conn.offline">offline</span>
552
- <button id="logoutBtn" type="button" data-i18n-title="logout" data-i18n-aria="logout" title="Log out" aria-label="Log out">↪</button>
553
- </div>
554
642
  </div>
555
643
  <div class="form" style="padding-top:10px;padding-bottom:10px">
556
- <div class="grid2">
644
+ <div class="project-picker">
557
645
  <label><span data-i18n="project">Project</span><select id="projectSelect"></select></label>
558
- <label><span data-i18n="language">Language</span><select id="langSelect"><option value="en">English</option><option value="zh">中文</option></select></label>
646
+ <button id="openProjectDialog" type="button" aria-haspopup="dialog" aria-controls="projectDialog" data-i18n-title="addProject" data-i18n-aria="addProject" title="Add project" aria-label="Add project">+</button>
559
647
  </div>
560
- <label><span data-i18n="projectPath">Project path</span><input id="projectPath" data-i18n-placeholder="projectPathPlaceholder" placeholder="/path/to/project"></label>
561
- <button id="addProjectBtn" type="button" data-i18n="registerProject">Register Project</button>
648
+ <button id="openStartDialog" class="primary" type="button" data-i18n="newSession" aria-haspopup="dialog" aria-controls="startDialog">New session</button>
649
+ <button id="openHistoryDialog" type="button" data-i18n="aux.history" aria-haspopup="dialog" aria-controls="historyDialog">Codex history</button>
562
650
  </div>
563
- <form class="form" id="startForm">
564
- <div class="start-row">
565
- <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>
566
- <button class="primary" type="submit" data-i18n="start">Start</button>
567
- </div>
568
- <div class="start-options">
569
- <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>
570
- <label data-resume-field><span data-i18n="session">Session</span><select id="resumeSelect"></select></label>
571
- <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>
572
- </div>
573
- </form>
574
651
  <div class="session-header">
575
652
  <strong data-i18n="sessions">Sessions</strong>
576
- <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>
653
+ <input id="sessionSearch" type="search" data-i18n-placeholder="sessionSearchPlaceholder" data-i18n-aria="sessionSearch" aria-label="Search sessions" placeholder="Name, ID, directory or command…" autocomplete="off">
654
+ <div class="session-filters">
655
+ <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="dsh">dsh</option><option value="shell" data-i18n="kind.shell">shell</option><option value="other" data-i18n="kind.other">other</option></select></label>
656
+ <label><span data-i18n="sessionStatusFilter">Session availability</span><select id="sessionStatusFilter"><option value="all" data-i18n="sessionFilterAll">All sessions</option><option value="active" data-i18n="sessionFilterActive">Active sessions</option></select></label>
657
+ </div>
658
+ <span class="session-list-count" id="sessionListCount" role="status" aria-live="polite"></span>
577
659
  </div>
578
660
  <div class="sessions" id="sessions"></div>
579
661
  </aside>
580
662
 
581
- <main class="main">
663
+ <section class="workspace" id="workspace" data-active-pane="primary">
664
+ ${workspaceBarHtml()}
665
+ <div class="workspace-panes" id="workspacePanes">
666
+ <main class="main" id="workspacePrimaryPane">
582
667
  <div class="toolbar">
583
668
  <div class="title">
584
- <strong id="activeTitle" data-i18n="noSessionSelected">No session selected</strong>
585
- <span class="path" id="activeMeta" data-i18n="startOrSelect">Start or select a session from the left panel</span>
669
+ <strong id="activeTitle">No session selected</strong>
670
+ <span class="path" id="activeMeta">Start or select a session from the left panel</span>
671
+ <span id="activeTask" hidden></span>
586
672
  </div>
587
673
  <div class="quick" id="quickBar">
674
+ <button id="openReviewDialog" type="button" data-i18n="aux.review" aria-haspopup="dialog" aria-controls="reviewDialog" disabled>Review results</button>
675
+ <button id="terminalFindBtn" type="button" data-i18n="find.button" data-i18n-title="find.shortcutHint" aria-controls="terminalFind" aria-expanded="false" aria-keyshortcuts="Control+Shift+F Meta+Shift+F" disabled>Find</button>
588
676
  <div class="menu-wrap">
589
677
  <button class="menu-btn" id="actionsBtn" type="button" aria-haspopup="true" aria-expanded="false"><span data-i18n="actions">Actions</span> ▾</button>
590
678
  <div class="menu" id="actionsMenu" hidden>
@@ -598,17 +686,25 @@ export function webIndexHtml({ nonce } = {}) {
598
686
  <button data-terminal-action="status" data-i18n="action.runStatusTerminal">run status in terminal</button>
599
687
  </div>
600
688
  </div>
601
- <button class="danger" id="stopBtn" type="button" data-i18n="stop">stop</button>
689
+ <button class="danger" id="stopBtn" type="button" data-i18n="handoff.detach">Pause Web attachment</button>
602
690
  </div>
603
691
  </div>
692
+ ${handoffBarHtml()}
693
+ ${terminalFindHtml()}
604
694
  <div id="terminal" style="min-height:0;flex:1"></div>
695
+ ${codexPanelHtml()}
696
+ ${nativePanelHtml()}
697
+ ${terminalComposerHtml()}
605
698
  <div id="detectedPanel" style="display:none;overflow:auto;flex:1"></div>
606
699
  </main>
700
+ ${workspaceSecondaryHtml()}
701
+ </div>
702
+ </section>
607
703
 
608
704
  <section class="action-result" id="actionResult" hidden aria-live="polite">
609
705
  <header>
610
706
  <strong id="actionResultTitle"></strong>
611
- <button id="actionResultClose" type="button" aria-label="Close">×</button>
707
+ <button id="actionResultClose" type="button" data-i18n-aria="close" aria-label="Close">×</button>
612
708
  </header>
613
709
  <pre id="actionResultBody"></pre>
614
710
  </section>
@@ -618,16 +714,54 @@ export function webIndexHtml({ nonce } = {}) {
618
714
  <strong data-i18n="projectState">Project State</strong>
619
715
  <button id="refreshBtn" type="button" data-i18n="refresh">Refresh</button>
620
716
  </div>
717
+ ${workbenchControlsHtml()}
621
718
  <div class="state" id="state"></div>
622
719
  </aside>
623
720
 
624
721
  <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>
625
722
  <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>
626
- <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>
627
- <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>
723
+ <div class="edge-resizer edge-resizer-left" id="resizeLeft" tabindex="0" role="separator" aria-orientation="vertical" data-i18n-aria="resizeLeftSidebar" data-i18n-title="resizeLeftSidebar" aria-label="Resize left sidebar" title="Resize left sidebar"></div>
724
+ <div class="edge-resizer edge-resizer-right" id="resizeRight" tabindex="0" role="separator" aria-orientation="vertical" data-i18n-aria="resizeRightPanel" data-i18n-title="resizeRightPanel" aria-label="Resize right panel" title="Resize right panel"></div>
628
725
  </div>
629
726
 
630
- <div class="dialog-overlay" id="stopDialog" hidden>
727
+ <div class="dialog-overlay" id="projectDialog" role="dialog" aria-modal="true" aria-labelledby="projectDialogTitle" hidden>
728
+ <div class="dialog">
729
+ <header class="dialog-heading"><h3 id="projectDialogTitle" data-i18n="addProject">Add project</h3><button id="projectDialogClose" type="button" data-i18n-aria="close" aria-label="Close">×</button></header>
730
+ <p class="dialog-help" data-i18n="projectDialogHelp">Register a local project directory to view its sessions and shared state.</p>
731
+ <p class="dialog-help" id="projectDialogError" role="alert" hidden></p>
732
+ <form id="projectForm" class="form">
733
+ <label><span data-i18n="projectPath">Project path</span><input id="projectPath" data-i18n-placeholder="projectPathPlaceholder" placeholder="/path/to/project" required autocomplete="off"></label>
734
+ <div class="btns"><button id="projectCancelBtn" type="button" data-i18n="dialog.cancel">Cancel</button><button id="addProjectBtn" class="primary" type="submit" data-i18n="registerProject">Register Project</button></div>
735
+ </form>
736
+ </div>
737
+ </div>
738
+ <div class="dialog-overlay" id="startDialog" role="dialog" aria-modal="true" aria-labelledby="startDialogTitle" hidden>
739
+ <div class="dialog">
740
+ <header class="dialog-heading"><h3 id="startDialogTitle" data-i18n="newSession">New session</h3><button id="startDialogClose" type="button" data-i18n-aria="close" aria-label="Close">×</button></header>
741
+ <p class="dialog-help" data-i18n="startDialogHelp">Choose a CLI and start a new session or resume existing history.</p>
742
+ <p class="dialog-help" id="startDialogError" role="alert" hidden></p>
743
+ <div class="dialog-target"><span data-i18n="startProject">Target project</span>: <span id="startProjectPath"></span></div>
744
+ <form class="form" id="startForm">
745
+ <label><span data-i18n="kind">Kind</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>
746
+ <label id="transportField"><span data-i18n="handoff.transport">Codex interface</span><select id="transport"><option value="tmux" data-i18n="handoff.cli">Terminal CLI</option><option value="app-server" data-i18n="handoff.appServer">Structured App Server (opt in)</option></select></label>
747
+ <div class="start-options">
748
+ <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>
749
+ <label data-resume-field><span data-i18n="session">Session</span><select id="resumeSelect"></select></label>
750
+ <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>
751
+ </div>
752
+ <label id="appServerResumeField" hidden><span style="display:flex;align-items:flex-start;gap:8px"><input type="checkbox" id="appServerResumeConfirm"><span data-i18n="handoff.resumeConfirm">I have stopped the original executor. Resuming history starts a separate executor.</span></span></label>
753
+ <p class="dialog-help" data-i18n="dshLaunchHelp">For DeepSeek Harness, run hcc dsh web in the project. Its agents appear under Detected.</p>
754
+ <div class="btns"><button id="startCancelBtn" type="button" data-i18n="dialog.cancel">Cancel</button><button class="primary" type="submit" data-i18n="start">Start</button></div>
755
+ </form>
756
+ </div>
757
+ </div>
758
+ <div class="dialog-overlay" id="settingsDialog" role="dialog" aria-modal="true" aria-labelledby="settingsTitle" hidden>
759
+ <div class="dialog settings-dialog">
760
+ <header><h3 id="settingsTitle" data-i18n="settings">Settings</h3><button id="settingsClose" type="button" data-i18n-aria="close" aria-label="Close">×</button></header>
761
+ <div class="settings-fields">${preferenceControlsHtml({ terminal: true })}</div>
762
+ </div>
763
+ </div>
764
+ <div class="dialog-overlay" id="stopDialog" role="dialog" aria-modal="true" aria-labelledby="stopDialogTitle" hidden>
631
765
  <div class="dialog">
632
766
  <h3 id="stopDialogTitle">Stop session?</h3>
633
767
  <div class="path" id="stopDialogMeta" style="font-size:12px"></div>
@@ -639,10 +773,21 @@ export function webIndexHtml({ nonce } = {}) {
639
773
  </div>
640
774
  </div>
641
775
 
776
+ ${codexHistoryHtml()}
777
+ ${reviewPanelHtml()}
778
+ ${commandPaletteHtml()}
642
779
  <script src="/assets/xterm.js"></script>
780
+ <script src="/assets/addon-fit.js"></script>
781
+ <script src="/assets/addon-search.js"></script>
643
782
  <script${inlineScriptNonce}>
783
+ ${uiHandoffScript()}
784
+ ${workbenchScript()}
785
+ const hccUi = window.hccUi;
786
+ const paneMode = ${pane ? 'true' : 'false'};
787
+ const handoffStore = window.hccHandoffStore;
644
788
  const initialParams = new URLSearchParams(location.search);
645
- sessionStorage.removeItem('hcc_logged_out');
789
+ let loggedOut = false;
790
+ try { sessionStorage.removeItem('hcc_logged_out'); } catch {}
646
791
  const token = initialParams.get('token') || '';
647
792
  const runtimeApiVersion = ${API_VERSION};
648
793
  const headers = {
@@ -651,23 +796,34 @@ export function webIndexHtml({ nonce } = {}) {
651
796
  };
652
797
  let currentProject = initialParams.get('project') || initialParams.get('root') || '';
653
798
  let projects = [];
799
+ let startPending = false;
654
800
  let sessionKindFilter = initialParams.get('kind') || 'all';
801
+ let sessionSearchQuery = '';
802
+ let sessionStatusFilter = 'all';
655
803
  let sessions = []; // managed (PTY) sessions
804
+ let sessionsLoaded = false;
656
805
  let detected = []; // coordination-only peers (from hooks/watcher)
657
806
  let resumableCache = []; // provider sessions available to resume (from /api/resumable)
658
807
  // The current token belongs to one terminal WebSocket connection and is
659
808
  // replaced on every reconnect.
660
809
  const sessionActionTokens = new Map();
661
- const pendingTerminalInput = new Map();
810
+ const sessionControls = new Map();
811
+ let activeConnectionState = 'offline';
812
+ let activeLocalClients = null;
813
+ let draftReceipt = false;
814
+ let lastControlNotice = 0;
662
815
  let active = null; // active managed session id
663
816
  let activeDetected = null; // active detected peer id
664
817
  let activeType = 'managed'; // 'managed' | 'detected'
665
- let showStaleDetected = localStorage.getItem('hcc.showStaleDetected') === '1';
818
+ let showStaleDetected = hccUi.safeGet('hcc.showStaleDetected') === '1';
666
819
  let activePeerTtl = 600;
667
820
  let lastStateNow = 0;
668
821
  let lastStateRoot = '';
822
+ let lastStateData = null;
823
+ let lastActionResult = null;
669
824
  let ws = null;
670
825
  let wsReconnectTimer = null;
826
+ let lastSentTerminalSize = null;
671
827
  // ui-4: after logout (or repeated failed reconnects) the WS must not loop
672
828
  // forever every 2s against a server that now rejects the upgrade.
673
829
  let wsReconnectFailures = 0;
@@ -677,261 +833,10 @@ export function webIndexHtml({ nonce } = {}) {
677
833
  let terminalLastReplaceAt = 0;
678
834
  let autoPollInFlight = false;
679
835
  let projectPollInFlight = false;
680
- const i18n = {
681
- en: {
682
- language: 'Language',
683
- project: 'Project',
684
- projectPath: 'Project path',
685
- projectPathPlaceholder: '/path/to/project',
686
- registerProject: 'Register Project',
687
- newSession: 'New session',
688
- start: 'Start',
689
- mode: 'Mode',
690
- 'mode.new': 'new',
691
- 'mode.resume': 'resume',
692
- 'mode.last': 'last',
693
- 'mode.continue': 'continue',
694
- session: 'Session',
695
- sessionId: 'Session id',
696
- sessionIdPlaceholder: 'session id or name',
697
- sessions: 'Sessions',
698
- view: 'View',
699
- 'kind.all': 'all',
700
- 'kind.shell': 'shell',
701
- 'kind.other': 'other',
702
- noSessionSelected: 'No session selected',
703
- startOrSelect: 'Start or select a session from the left panel',
704
- actions: 'Actions',
705
- 'action.state': 'state',
706
- 'action.inbox': 'inbox',
707
- 'action.claimNextTask': 'claim next task',
708
- 'action.status': 'status',
709
- 'action.renewHeartbeat': 'renew heartbeat',
710
- 'action.reregister': 're-register peer',
711
- 'action.runStatusTerminal': 'run status in terminal',
712
- logout: 'log out',
713
- actionResult: 'Action Result',
714
- stop: 'stop',
715
- projectState: 'Project State',
716
- refresh: 'Refresh',
717
- managed: 'Managed',
718
- detected: 'Detected',
719
- running: 'running',
720
- noActiveSessions: 'No active sessions.',
721
- startOneAbove: 'Start one above',
722
- orRunTerminal: 'or run in any terminal:',
723
- noDetectedPeers: 'No detected peers.',
724
- viewFilter: 'View filter',
725
- noTasks: 'No tasks.',
726
- noPeers: 'No peers.',
727
- noActiveLocks: 'No active locks.',
728
- noMessages: 'No messages.',
729
- noTimelineItems: 'No timeline items.',
730
- nextAction: 'Next Action',
731
- timeline: 'Timeline',
732
- messages: 'Messages',
733
- peers: 'Peers',
734
- tasks: 'Tasks',
735
- locks: 'Locks',
736
- owner: 'owner',
737
- assignee: 'assignee',
738
- task: 'task',
739
- all: 'all',
740
- next: 'next',
741
- finish: 'finish',
742
- warnings: 'warnings',
743
- noImmediateAction: 'No immediate coordination action',
744
- detectedSession: 'Detected Session',
745
- sendMessage: 'Send Message',
746
- messageHelp: "Message will appear in the peer's <code>hcc msg inbox</code> and be injected on next hook fire.",
747
- messageBodyPlaceholder: 'Message body...',
748
- send: 'Send',
749
- peer: 'peer',
750
- kind: 'kind',
751
- status: 'status',
752
- cwd: 'cwd',
753
- pid: 'pid',
754
- unknown: 'unknown',
755
- lastSeen: 'last seen',
756
- secondsAgo: 's ago',
757
- age: 'age',
758
- branch: 'branch',
759
- runtime: 'runtime',
760
- command: 'command',
761
- providerSession: 'provider session',
762
- activeDetected: 'Active detected',
763
- staleDetected: 'Stale detected',
764
- noActiveDetectedPeers: 'No active detected peers.',
765
- showStale: 'show stale',
766
- hideStale: 'hide stale',
767
- thread: 'thread',
768
- reply: 'reply',
769
- 'status.active': 'active',
770
- 'status.stale': 'stale',
771
- 'status.unknown': 'unknown',
772
- 'status.running': 'running',
773
- 'status.idle': 'idle',
774
- 'status.exited': 'exited',
775
- 'status.claimed': 'claimed',
776
- 'status.review': 'review',
777
- 'status.blocked': 'blocked',
778
- 'status.done': 'done',
779
- 'status.abandoned': 'abandoned',
780
- 'status.pending': 'pending',
781
- 'conn.offline': 'offline',
782
- 'conn.online': 'online',
783
- 'conn.attached': 'attached',
784
- 'conn.reconnecting': 'reconnecting...',
785
- 'conn.coordinationOnly': 'coordination only',
786
- 'conn.error': 'error',
787
- show: 'Show',
788
- collapse: 'Collapse',
789
- sidebar: 'sidebar',
790
- statePanel: 'state panel',
791
- dragToResize: 'drag to resize',
792
- collapseSidebar: 'Collapse sidebar',
793
- collapseStatePanel: 'Collapse state panel',
794
- toggleLeftSidebar: 'Toggle left sidebar',
795
- toggleRightPanel: 'Toggle right panel',
796
- resizeLeftSidebar: 'Resize left sidebar',
797
- resizeRightPanel: 'Resize right panel',
798
- customSession: 'custom session id...',
799
- 'dialog.cancel': 'Cancel',
800
- 'dialog.killTmux': 'Also kill tmux session',
801
- 'action.stopPeer': 'Stop peer',
802
- 'action.restartPeer': 'Restart peer',
803
- detectedPeer: 'detected peer'
804
- },
805
- zh: {
806
- language: '语言',
807
- project: '项目',
808
- projectPath: '项目路径',
809
- projectPathPlaceholder: '/项目/路径',
810
- registerProject: '注册项目',
811
- newSession: '新会话',
812
- start: '启动',
813
- mode: '模式',
814
- 'mode.new': '新建',
815
- 'mode.resume': '恢复',
816
- 'mode.last': '最近',
817
- 'mode.continue': '继续',
818
- session: '会话',
819
- sessionId: '会话 ID',
820
- sessionIdPlaceholder: '会话 ID 或名称',
821
- sessions: '会话',
822
- view: '视图',
823
- 'kind.all': '全部',
824
- 'kind.shell': 'shell',
825
- 'kind.other': '其他',
826
- noSessionSelected: '未选择会话',
827
- startOrSelect: '从左侧面板启动或选择一个会话',
828
- actions: '操作',
829
- 'action.state': '状态详情',
830
- 'action.inbox': '收件箱',
831
- 'action.claimNextTask': '认领下个任务',
832
- 'action.status': '状态',
833
- 'action.renewHeartbeat': '续期心跳',
834
- 'action.reregister': '重新注册协作方',
835
- 'action.runStatusTerminal': '在终端运行状态命令',
836
- logout: '退出登录',
837
- actionResult: '操作结果',
838
- stop: '停止',
839
- projectState: '项目状态',
840
- refresh: '刷新',
841
- managed: '托管',
842
- detected: '检测到',
843
- running: '运行中',
844
- noActiveSessions: '没有活动会话。',
845
- startOneAbove: '在上方启动一个会话',
846
- orRunTerminal: '或在任意终端运行:',
847
- noDetectedPeers: '没有检测到的协作方。',
848
- viewFilter: '视图过滤',
849
- noTasks: '没有任务。',
850
- noPeers: '没有协作方。',
851
- noActiveLocks: '没有活动锁。',
852
- noMessages: '没有消息。',
853
- noTimelineItems: '没有时间线条目。',
854
- nextAction: '下一步',
855
- timeline: '时间线',
856
- messages: '消息',
857
- peers: '协作方',
858
- tasks: '任务',
859
- locks: '锁',
860
- owner: '负责人',
861
- assignee: '执行者',
862
- task: '任务',
863
- all: '全部',
864
- next: '下一步',
865
- finish: '收尾',
866
- warnings: '警告',
867
- noImmediateAction: '当前没有需要立即处理的协作动作',
868
- detectedSession: '检测到的会话',
869
- sendMessage: '发送消息',
870
- messageHelp: '消息会出现在该 peer 的 <code>hcc msg inbox</code> 中,并在下一次 hook 触发时注入。',
871
- messageBodyPlaceholder: '消息内容...',
872
- send: '发送',
873
- peer: '协作方',
874
- kind: '类型',
875
- status: '状态',
876
- cwd: '工作目录',
877
- pid: 'PID',
878
- unknown: '未知',
879
- lastSeen: '最后出现',
880
- secondsAgo: ' 秒前',
881
- age: '时长',
882
- branch: '分支',
883
- runtime: '运行时',
884
- command: '命令',
885
- providerSession: '提供方会话',
886
- activeDetected: '活跃检测',
887
- staleDetected: '过期检测',
888
- noActiveDetectedPeers: '没有活跃的检测协作方。',
889
- showStale: '显示过期',
890
- hideStale: '隐藏过期',
891
- thread: '线程',
892
- reply: '回复',
893
- 'status.active': '活跃',
894
- 'status.stale': '过期',
895
- 'status.unknown': '未知',
896
- 'status.running': '运行中',
897
- 'status.idle': '空闲',
898
- 'status.exited': '已退出',
899
- 'status.claimed': '已认领',
900
- 'status.review': '审查中',
901
- 'status.blocked': '阻塞',
902
- 'status.done': '完成',
903
- 'status.abandoned': '已放弃',
904
- 'status.pending': '待处理',
905
- 'conn.offline': '离线',
906
- 'conn.online': '在线',
907
- 'conn.attached': '已连接',
908
- 'conn.reconnecting': '重连中...',
909
- 'conn.coordinationOnly': '仅协作',
910
- 'conn.error': '错误',
911
- show: '显示',
912
- collapse: '折叠',
913
- sidebar: '侧栏',
914
- statePanel: '状态面板',
915
- dragToResize: '拖动调整大小',
916
- collapseSidebar: '折叠侧栏',
917
- collapseStatePanel: '折叠状态面板',
918
- toggleLeftSidebar: '切换左侧栏',
919
- toggleRightPanel: '切换右侧面板',
920
- resizeLeftSidebar: '调整左侧栏宽度',
921
- resizeRightPanel: '调整右侧面板宽度',
922
- customSession: '自定义会话 ID...',
923
- 'dialog.cancel': '取消',
924
- 'dialog.killTmux': '同时终止 tmux 会话',
925
- 'action.stopPeer': '停止协作方',
926
- 'action.restartPeer': '恢复协作方',
927
- detectedPeer: '检测到的协作方'
928
- }
929
- };
930
- let lang = localStorage.getItem('hcc.lang') || ((navigator.language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en');
931
- if (!i18n[lang]) lang = 'en';
836
+ let lang = hccUi.language;
932
837
 
933
838
  function tr(key, fallback = '') {
934
- return i18n[lang]?.[key] || i18n.en[key] || fallback || key;
839
+ return hccUi.tr(key, fallback);
935
840
  }
936
841
 
937
842
  function setText(id, key) {
@@ -948,14 +853,9 @@ export function webIndexHtml({ nonce } = {}) {
948
853
  }
949
854
 
950
855
  function applyLanguage() {
856
+ lang = hccUi.language;
951
857
  document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en';
952
- localStorage.setItem('hcc.lang', lang);
953
- const sel = document.getElementById('langSelect');
954
- if (sel) sel.value = lang;
955
- document.querySelectorAll('[data-i18n]').forEach((el) => { el.textContent = tr(el.dataset.i18n); });
956
- document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => { el.placeholder = tr(el.dataset.i18nPlaceholder); });
957
- document.querySelectorAll('[data-i18n-title]').forEach((el) => { el.title = tr(el.dataset.i18nTitle); });
958
- document.querySelectorAll('[data-i18n-aria]').forEach((el) => { el.setAttribute('aria-label', tr(el.dataset.i18nAria)); });
858
+ hccUi.translate();
959
859
  if (!active && !activeDetected) {
960
860
  setText('activeTitle', 'noSessionSelected');
961
861
  setText('activeMeta', 'startOrSelect');
@@ -964,6 +864,7 @@ export function webIndexHtml({ nonce } = {}) {
964
864
  connText(stateKey);
965
865
  syncStartModeOptions();
966
866
  renderSections();
867
+ renderHandoff();
967
868
  if (activeType === 'detected' && activeDetected) {
968
869
  const draft = document.getElementById('detMsg')?.value || '';
969
870
  const peer = detected.find((p) => p.id === activeDetected) || { id: activeDetected };
@@ -974,11 +875,13 @@ export function webIndexHtml({ nonce } = {}) {
974
875
  } else if (activeType === 'managed' && active) {
975
876
  const meta = sessions.find((s) => s.id === active);
976
877
  if (meta) {
977
- document.getElementById('activeTitle').textContent = sessionPeerId(meta) || active;
978
- document.getElementById('activeMeta').textContent = sessionMetaText(meta);
878
+ renderActiveSession(meta);
979
879
  }
980
880
  }
981
- if (lastStateRoot) refreshCurrentState().catch(console.error);
881
+ if (lastStateData) renderState(lastStateData);
882
+ if (lastActionResult && !document.getElementById('actionResult').hidden) showActionResult(lastActionResult);
883
+ if (!stopDialog.hidden) updateStopDialogTitle();
884
+ syncFocusButton();
982
885
  syncToggleIcons();
983
886
  }
984
887
 
@@ -997,80 +900,266 @@ export function webIndexHtml({ nonce } = {}) {
997
900
  const params = new URLSearchParams(location.search);
998
901
  if (token) params.set('token', token);
999
902
  if (currentProject) params.set('project', currentProject);
903
+ if (!paneMode) params.delete('session');
1000
904
  if (sessionKindFilter && sessionKindFilter !== 'all') params.set('kind', sessionKindFilter);
1001
905
  history.replaceState(null, '', location.pathname + '?' + params.toString());
1002
906
  }
1003
907
 
1004
908
  const term = new Terminal({
909
+ // Search match decorations use xterm's proposed decoration API.
910
+ allowProposedApi: true,
1005
911
  cursorBlink: true,
1006
912
  cursorInactiveStyle: 'outline',
1007
913
  cursorStyle: 'bar',
1008
914
  convertEol: true,
1009
915
  fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
1010
- fontSize: 13,
1011
- theme: { background: '#0b0d10', foreground: '#eef2f6', cursor: '#7dd3fc', cursorAccent: '#0b0d10' }
916
+ fontSize: hccUi.preferences.fontSize,
917
+ lineHeight: 1.15,
918
+ theme: ${JSON.stringify(TERMINAL_THEMES)}[hccUi.preferences.terminalTheme === 'system' ? hccUi.theme : hccUi.preferences.terminalTheme]
1012
919
  });
920
+ const fitAddon = new FitAddon.FitAddon();
921
+ term.loadAddon(fitAddon);
922
+ const searchAddon = new SearchAddon.SearchAddon({ highlightLimit: 1000 });
923
+ term.loadAddon(searchAddon);
1013
924
  term.open(document.getElementById('terminal'));
1014
925
 
1015
- // Accurate terminal sizing via character measurement
1016
- function measureCharSize() {
1017
- const el = document.getElementById('terminal');
1018
- const canvas = document.createElement('canvas');
1019
- const ctx2d = canvas.getContext('2d');
1020
- ctx2d.font = '13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';
1021
- const m = ctx2d.measureText('M');
1022
- return { w: m.width || 8, h: 17 };
1023
- }
1024
-
1025
926
  function resizeTerm() {
1026
927
  const el = document.getElementById('terminal');
1027
- const { w, h } = measureCharSize();
1028
- const cols = Math.max(60, Math.floor((el.clientWidth - 16) / w));
1029
- const rows = Math.max(10, Math.floor((el.clientHeight - 16) / h));
928
+ if (!el.getClientRects().length || el.clientWidth <= 16 || el.clientHeight <= 16) return;
929
+ const size = fitAddon.proposeDimensions();
930
+ if (!size || !Number.isFinite(size.cols) || !Number.isFinite(size.rows)) return;
931
+ const { cols, rows } = size;
1030
932
  term.resize(cols, rows);
1031
933
  const actionToken = sessionActionTokens.get(active) || '';
1032
- if (ws && ws.readyState === WebSocket.OPEN && actionToken) {
934
+ if (ws && ws.readyState === WebSocket.OPEN && actionToken && canControl() &&
935
+ (!lastSentTerminalSize || lastSentTerminalSize.socket !== ws || lastSentTerminalSize.cols !== cols || lastSentTerminalSize.rows !== rows)) {
936
+ lastSentTerminalSize = { socket: ws, cols, rows };
1033
937
  terminalLastResizeAt = Date.now();
1034
- ws.send(JSON.stringify({ type: 'resize', cols, rows, action_token: actionToken }));
938
+ ws.send(JSON.stringify({ type: 'resize', cols, rows, action_token: actionToken, epoch: controlEpoch() }));
1035
939
  }
1036
940
  }
1037
941
  window.addEventListener('resize', resizeTerm);
942
+ new ResizeObserver(() => resizeTerm()).observe(document.getElementById('terminal'));
943
+ document.fonts?.ready.then(() => resizeTerm());
1038
944
  setTimeout(resizeTerm, 80);
1039
945
 
946
+ function applyTerminalPreferences() {
947
+ const palette = ${JSON.stringify(TERMINAL_THEMES)};
948
+ const theme = hccUi.preferences.terminalTheme === 'system' ? hccUi.theme : hccUi.preferences.terminalTheme;
949
+ term.options.theme = palette[theme];
950
+ term.options.fontSize = hccUi.preferences.fontSize;
951
+ document.getElementById('terminal').style.backgroundColor = palette[theme].background;
952
+ requestAnimationFrame(() => resizeTerm());
953
+ }
954
+
1040
955
  term.onData((data) => {
1041
956
  sendTerminalInput(data);
1042
957
  });
1043
958
 
1044
- function sendTerminalInput(data) {
1045
- if (!active || !ws) return;
1046
- const actionToken = sessionActionTokens.get(active) || '';
1047
- if (ws.readyState === WebSocket.OPEN && actionToken) {
1048
- ws.send(JSON.stringify({ type: 'input', data, action_token: actionToken }));
1049
- return;
959
+ function sendTerminalInput(data, inputId = '') {
960
+ if (sessions.find(item => item.id === active)?.type === 'native' || sessions.find(item => item.id === active)?.type === 'app-server') return false;
961
+ if (!active || !ws || ws.readyState !== WebSocket.OPEN || !canControl()) {
962
+ if (Date.now() - lastControlNotice > 3000) {
963
+ lastControlNotice = Date.now();
964
+ document.getElementById('handoffStatus').textContent = tr('handoff.controlRequired');
965
+ }
966
+ return false;
1050
967
  }
1051
- if (ws.readyState !== WebSocket.CONNECTING && ws.readyState !== WebSocket.OPEN) return;
1052
- const queued = (pendingTerminalInput.get(active) || '') + String(data || '');
1053
- pendingTerminalInput.set(active, queued.slice(-65536));
1054
- }
1055
-
1056
- function flushPendingTerminalInput(id, socket) {
1057
- const data = pendingTerminalInput.get(id) || '';
1058
- const actionToken = sessionActionTokens.get(id) || '';
1059
- if (!data || active !== id || ws !== socket || socket.readyState !== WebSocket.OPEN || !actionToken) return;
1060
- pendingTerminalInput.delete(id);
1061
- socket.send(JSON.stringify({ type: 'input', data, action_token: actionToken }));
968
+ const actionToken = sessionActionTokens.get(active) || '';
969
+ if (!actionToken) return false;
970
+ ws.send(JSON.stringify({ type: 'input', data, action_token: actionToken, epoch: controlEpoch(), ...(inputId ? { input_id: inputId } : {}) }));
971
+ return true;
1062
972
  }
1063
973
 
1064
974
  async function api(path, options = {}) {
1065
975
  const res = await fetch(path + (path.includes('?') ? '&' : '?') + requestQuery().slice(1), {
1066
976
  ...options,
1067
977
  headers: { 'Content-Type': 'application/json', ...headers, ...(options.headers || {}) }
978
+ }).catch((cause) => {
979
+ const error = new Error(tr('networkError'));
980
+ error.detail = cause.message;
981
+ throw error;
1068
982
  });
1069
983
  const json = await res.json();
1070
- if (!res.ok) throw new Error(json.error?.message || json.message || 'request failed');
984
+ if (!res.ok) {
985
+ const error = new Error(tr('error.' + json.error?.code, tr('requestFailed')));
986
+ error.code = json.error?.code;
987
+ error.detail = json.error?.message || json.message || error.message;
988
+ throw error;
989
+ }
1071
990
  return json;
1072
991
  }
1073
992
 
993
+ function controlEpoch() { return Number(sessionControls.get(active)?.epoch || 0); }
994
+ function canControl() {
995
+ return Boolean(active && sessions.find((item) => item.id === active)?.status === 'running' && ws?.readyState === WebSocket.OPEN && sessionActionTokens.get(active) && sessionControls.get(active)?.can_control);
996
+ }
997
+
998
+ function renderActiveSession(meta) {
999
+ document.getElementById('activeTitle').textContent = sessionPeerId(meta) || active || '';
1000
+ document.getElementById('activeMeta').textContent = sessionMetaText(meta);
1001
+ const task = document.getElementById('activeTask');
1002
+ task.hidden = !meta?.task?.title;
1003
+ task.textContent = meta?.task?.title ? tr('handoff.task') + ' #' + meta.task.id + ': ' + meta.task.title + (meta.task.status ? ' · ' + statusText(meta.task.status) : '') : '';
1004
+ }
1005
+
1006
+ function renderDraftState() {
1007
+ const value = active ? handoffStore.draft(currentProject, active) : { text: '', pending: null };
1008
+ const note = document.getElementById('draftStatus');
1009
+ note.textContent = tr(value.pending ? value.pending.status === 'uncertain' ? 'handoff.uncertain' : 'handoff.pending' : draftReceipt ? 'handoff.received' : 'handoff.saved');
1010
+ note.title = tr('handoff.storageHint');
1011
+ document.getElementById('reviewDraftBtn').hidden = value.pending?.status !== 'uncertain';
1012
+ document.getElementById('sendDraftBtn').disabled = !canControl() || !value.text || Boolean(value.pending);
1013
+ }
1014
+
1015
+ function renderHandoff() {
1016
+ document.getElementById('handoffBar').hidden = !active;
1017
+ const control = sessionControls.get(active) || {};
1018
+ const writable = canControl();
1019
+ term.options.disableStdin = !writable;
1020
+ const copy = (en, zh) => lang === 'zh' ? zh : en;
1021
+ const connection = document.getElementById('handoffConnection');
1022
+ connection.textContent = copy('Web: ', '连接:') + (activeConnectionState === 'connected' ? copy('Connected', '已连接') : activeConnectionState === 'connecting' ? copy('Connecting…', '连接中…') : copy('Disconnected', '已断开'));
1023
+ connection.dataset.tone = activeConnectionState === 'connected' ? 'ok' : 'warn';
1024
+ const meta = sessions.find((item) => item.id === active);
1025
+ const processState = document.getElementById('handoffProcess');
1026
+ processState.textContent = copy('Process: ', '进程:') + statusText(meta?.status || 'unknown');
1027
+ processState.dataset.tone = meta?.status === 'running' ? 'ok' : meta?.status === 'exited' ? 'danger' : '';
1028
+ const access = document.getElementById('handoffAccess');
1029
+ access.textContent = writable ? copy('You control', '你正在控制') : control.has_controller ? control.controller_connected === false ? copy('Control retained · Read only', '控制权暂时保留 · 只读') : copy('Other window controls · Read only', '其他窗口控制 · 只读') : copy('Read only', '只读');
1030
+ access.dataset.tone = writable ? 'ok' : control.has_controller ? 'warn' : '';
1031
+ document.getElementById('handoffHelp').textContent = copy('Attachment details', '连接与离开说明');
1032
+ const claim = document.getElementById('claimControlBtn');
1033
+ claim.hidden = writable;
1034
+ claim.disabled = activeConnectionState !== 'connected' || !sessionActionTokens.get(active);
1035
+ claim.textContent = tr(control.has_controller ? 'handoff.takeover' : 'handoff.claim');
1036
+ document.getElementById('releaseControlBtn').hidden = !writable;
1037
+ const reconnect = document.getElementById('reconnectBtn');
1038
+ reconnect.disabled = !active || activeConnectionState === 'connecting';
1039
+ reconnect.hidden = activeConnectionState === 'connected';
1040
+ const clients = activeLocalClients ?? meta?.local_clients;
1041
+ const count = typeof clients === 'number' ? clients : Array.isArray(clients) ? clients.length : Number(clients?.count || 0);
1042
+ document.getElementById('handoffDetail').textContent = (meta?.type === 'tmux' ? (clients?.state === 'unknown' ? tr('handoff.localUnknown') : count ? tr('handoff.localClients') + ': ' + count : tr('handoff.noLocalClients')) + ' · ' + tr('handoff.localBoundary') + ' ' : '') + tr('handoff.leaveHelp');
1043
+ const stop = document.getElementById('stopBtn');
1044
+ stop.hidden = meta?.type === 'native';
1045
+ stop.disabled = !writable;
1046
+ stop.dataset.i18n = meta?.type === 'tmux' ? 'handoff.detach' : 'handoff.terminate';
1047
+ stop.textContent = tr(stop.dataset.i18n);
1048
+ document.querySelectorAll('[data-terminal-action]').forEach((button) => { button.disabled = !writable || meta?.type === 'app-server' || meta?.type === 'native'; });
1049
+ document.querySelectorAll('[data-action]').forEach((button) => {
1050
+ if (!['status', 'state', 'inbox'].includes(button.dataset.action)) button.disabled = !writable;
1051
+ });
1052
+ renderDraftState();
1053
+ window.hccCodex?.sync();
1054
+ window.hccNative?.sync();
1055
+ window.hccHistory?.sync();
1056
+ window.hccReview?.sync();
1057
+ window.hccTerminalFind?.sync();
1058
+ window.hccWorkspace?.sync();
1059
+ document.getElementById('openReviewDialog').disabled = !meta;
1060
+ }
1061
+
1062
+ function disconnectWebSocket() {
1063
+ clearTimeout(wsReconnectTimer);
1064
+ const previous = ws;
1065
+ ws = null;
1066
+ if (active) {
1067
+ handoffStore.uncertain(currentProject, active);
1068
+ sessionActionTokens.delete(active);
1069
+ sessionControls.delete(active);
1070
+ }
1071
+ activeConnectionState = 'offline';
1072
+ lastSentTerminalSize = null;
1073
+ previous?.close();
1074
+ }
1075
+
1076
+ function restoreSelection() {
1077
+ const explicit = initialParams.get('session');
1078
+ initialParams.delete('session');
1079
+ const selected = explicit ? { type: 'managed', id: explicit } : handoffStore.selected(currentProject);
1080
+ if (selected) {
1081
+ if (selected.type === 'managed' && sessions.some((item) => item.id === selected.id && item.status === 'running')) return connectManaged(selected.id);
1082
+ if (selected.type === 'detected' && detected.some((item) => item.id === selected.id)) return connectDetected(selected.id);
1083
+ document.getElementById('activeMeta').textContent = tr('handoff.missingSelection');
1084
+ return;
1085
+ }
1086
+ const first = sessions.find((item) => item.status === 'running');
1087
+ if (first) connectManaged(first.id);
1088
+ }
1089
+
1090
+ window.hccHandoff = {
1091
+ get active() { return active; },
1092
+ get projectRoot() { return currentProject; },
1093
+ get draftScope() { return window.hccDraftScope || ''; },
1094
+ get actionToken() { return sessionActionTokens.get(active) || ''; },
1095
+ get epoch() { return controlEpoch(); },
1096
+ get canControl() { return canControl(); },
1097
+ get session() { return sessions.find(item => item.id === active) || null; },
1098
+ get sessions() { return sessions; },
1099
+ refreshSessions: () => refreshSessions(), openManaged: id => connectManaged(id), openDialog, closeDialog,
1100
+ api: (...args) => api(...args), tr, esc
1101
+ };
1102
+ ${codexPanelScript()}
1103
+ ${codexHistoryScript()}
1104
+ ${reviewPanelScript()}
1105
+ ${nativePanelScript()}
1106
+ document.getElementById('claimControlBtn').addEventListener('click', () => {
1107
+ if (ws?.readyState !== WebSocket.OPEN) return;
1108
+ const target = active, socket = ws, epoch = controlEpoch();
1109
+ const takeover = Boolean(sessionControls.get(active)?.has_controller);
1110
+ if (takeover && !confirm(lang === 'zh' ? '接管后,其他浏览器窗口将转为只读。当前任务继续运行。是否接管?' : 'Taking control makes the other browser window read only. The current task keeps running. Continue?')) return;
1111
+ if (active !== target || ws !== socket) return;
1112
+ socket.send(JSON.stringify({ type: 'control', action: 'claim', action_token: sessionActionTokens.get(target) || '', epoch, force: takeover }));
1113
+ });
1114
+ document.getElementById('releaseControlBtn').addEventListener('click', () => {
1115
+ if (!canControl()) return;
1116
+ ws.send(JSON.stringify({ type: 'control', action: 'release', action_token: sessionActionTokens.get(active) || '', epoch: controlEpoch() }));
1117
+ });
1118
+ document.getElementById('reconnectBtn').addEventListener('click', () => {
1119
+ if (!active) return;
1120
+ const id = active;
1121
+ disconnectWebSocket();
1122
+ wsReconnectFailures = 0;
1123
+ openWs(id);
1124
+ });
1125
+ document.getElementById('terminalDraft').addEventListener('input', (event) => {
1126
+ if (!active) return;
1127
+ draftReceipt = false;
1128
+ handoffStore.saveDraft(currentProject, active, event.target.value);
1129
+ renderDraftState();
1130
+ });
1131
+ function submitTerminalDraft(event) {
1132
+ event.preventDefault();
1133
+ if (!active || !canControl()) return;
1134
+ const textarea = document.getElementById('terminalDraft');
1135
+ const text = textarea.value;
1136
+ if (!text || handoffStore.draft(currentProject, active).pending) return;
1137
+ const inputId = window.crypto?.randomUUID?.() || 'input-' + Date.now() + '-' + Math.random().toString(36).slice(2);
1138
+ handoffStore.saveDraft(currentProject, active, text);
1139
+ handoffStore.submit(currentProject, active, inputId, text);
1140
+ try {
1141
+ if (!sendTerminalInput(text + '\\r', inputId)) handoffStore.uncertain(currentProject, active);
1142
+ } catch {
1143
+ handoffStore.uncertain(currentProject, active);
1144
+ }
1145
+ renderDraftState();
1146
+ }
1147
+ document.getElementById('terminalComposer').addEventListener('submit', submitTerminalDraft);
1148
+ document.getElementById('terminalDraft').addEventListener('keydown', (event) => {
1149
+ if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') submitTerminalDraft(event);
1150
+ });
1151
+ document.getElementById('reviewDraftBtn').addEventListener('click', () => {
1152
+ if (!active) return;
1153
+ handoffStore.review(currentProject, active);
1154
+ draftReceipt = false;
1155
+ renderDraftState();
1156
+ });
1157
+ window.addEventListener('pagehide', () => disconnectWebSocket());
1158
+
1159
+ function handleUiError(error, action = '') {
1160
+ showActionResult({ action, peer: activePeerInfo()?.peerId || '', error: error.detail || error.message, errorCode: error.code });
1161
+ }
1162
+
1074
1163
  function renderProjects() {
1075
1164
  const select = document.getElementById('projectSelect');
1076
1165
  select.innerHTML = projects.map((p) =>
@@ -1078,25 +1167,30 @@ export function webIndexHtml({ nonce } = {}) {
1078
1167
  ).join('');
1079
1168
  if (currentProject) select.value = currentProject;
1080
1169
  document.getElementById('sessionKindFilter').value = sessionKindFilter;
1170
+ window.hccCommandPalette?.refresh();
1081
1171
  }
1082
1172
 
1083
1173
  function kindMatches(item) {
1084
- const kind = ['claude', 'codex', 'shell'].includes(item.kind) ? item.kind : 'other';
1174
+ const kind = ['claude', 'codex', 'dsh', 'shell'].includes(item.kind) ? item.kind : 'other';
1085
1175
  return sessionKindFilter === 'all' || kind === sessionKindFilter;
1086
1176
  }
1087
1177
 
1088
1178
  function syncStartModeOptions() {
1089
1179
  const kind = document.getElementById('kind').value;
1180
+ document.getElementById('transportField').hidden = kind !== 'codex';
1181
+ const appServer = kind === 'codex' && document.getElementById('transport').value === 'app-server';
1090
1182
  const modeSelect = document.getElementById('startMode');
1091
1183
  const current = modeSelect.value;
1092
1184
  const modes = kind === 'claude'
1093
1185
  ? [['new', 'new'], ['resume', 'resume'], ['continue', 'continue']]
1094
1186
  : kind === 'codex'
1095
- ? [['new', 'new'], ['resume', 'resume'], ['last', 'last']]
1187
+ ? (appServer ? [['new', 'new'], ['resume', 'resume']] : [['new', 'new'], ['resume', 'resume'], ['last', 'last']])
1096
1188
  : [['new', 'new']];
1097
1189
  modeSelect.innerHTML = modes.map(([value]) => '<option value="' + value + '">' + tr('mode.' + value, value) + '</option>').join('');
1098
1190
  modeSelect.value = modes.some(([value]) => value === current) ? current : 'new';
1099
1191
  const isResume = modeSelect.value === 'resume';
1192
+ document.getElementById('appServerResumeField').hidden = !(appServer && isResume);
1193
+ document.getElementById('resumeArg').required = false;
1100
1194
  document.querySelector('[data-resume-field]:not([data-resume-custom])').style.display = isResume ? '' : 'none';
1101
1195
  if (isResume) loadResumable();
1102
1196
  else document.querySelector('[data-resume-custom]').style.display = 'none';
@@ -1126,7 +1220,9 @@ export function webIndexHtml({ nonce } = {}) {
1126
1220
  }
1127
1221
  function toggleResumeCustom() {
1128
1222
  const sel = document.getElementById('resumeSelect');
1129
- document.querySelector('[data-resume-custom]').style.display = sel.value === '__custom__' ? '' : 'none';
1223
+ const custom = document.getElementById('startMode').value === 'resume' && sel.value === '__custom__';
1224
+ document.querySelector('[data-resume-custom]').style.display = custom ? '' : 'none';
1225
+ document.getElementById('resumeArg').required = custom;
1130
1226
  }
1131
1227
 
1132
1228
  async function loadProjects() {
@@ -1138,18 +1234,35 @@ export function webIndexHtml({ nonce } = {}) {
1138
1234
  }
1139
1235
 
1140
1236
  async function switchProject(root) {
1237
+ if (paneMode && root !== currentProject) return;
1238
+ window.hccWorkspace?.reset();
1239
+ disconnectWebSocket();
1240
+ window.hccHistory?.reset();
1241
+ window.hccReview?.reset();
1141
1242
  currentProject = root;
1142
1243
  updateLocationProject();
1143
1244
  active = null;
1144
1245
  activeDetected = null;
1145
1246
  activeType = 'managed';
1146
- if (ws) { clearTimeout(wsReconnectTimer); ws.close(); ws = null; }
1247
+ sessions = [];
1248
+ sessionsLoaded = false;
1249
+ detected = [];
1250
+ renderSections();
1251
+ document.getElementById('rootPath').textContent = root;
1252
+ lastStateData = null;
1253
+ lastStateRoot = '';
1254
+ window.hccWorkbench.reset();
1147
1255
  term.reset();
1256
+ document.getElementById('codexPanel').hidden = true;
1257
+ document.getElementById('nativePanel').hidden = true;
1258
+ document.getElementById('terminalComposer').hidden = true;
1259
+ document.getElementById('activeTask').hidden = true;
1260
+ renderHandoff();
1148
1261
  document.getElementById('activeTitle').textContent = tr('noSessionSelected');
1149
1262
  document.getElementById('activeMeta').textContent = tr('startOrSelect');
1150
1263
  await Promise.all([refreshSessions(), refreshDetected(), refreshState()]);
1151
- const first = sessions.find(s => s.status === 'running');
1152
- if (first) connectManaged(first.id);
1264
+ if (currentProject !== root) return;
1265
+ restoreSelection();
1153
1266
  }
1154
1267
 
1155
1268
  function esc(text) {
@@ -1158,7 +1271,7 @@ export function webIndexHtml({ nonce } = {}) {
1158
1271
 
1159
1272
  function fmtTime(ts) {
1160
1273
  if (!ts) return '';
1161
- return new Date(ts * 1000).toLocaleTimeString();
1274
+ return new Date(ts * 1000).toLocaleTimeString(lang === 'zh' ? 'zh-CN' : 'en');
1162
1275
  }
1163
1276
 
1164
1277
  function badgeClass(status) {
@@ -1224,6 +1337,7 @@ export function webIndexHtml({ nonce } = {}) {
1224
1337
  return [
1225
1338
  runtimeTargetText(session),
1226
1339
  sessionProviderSessionText(session),
1340
+ session?.capabilities?.structured_turns ? 'App Server' : '',
1227
1341
  session?.command ? tr('command') + '=' + session.command : '',
1228
1342
  session?.cwd || ''
1229
1343
  ].filter(Boolean).join(' · ');
@@ -1252,7 +1366,12 @@ export function webIndexHtml({ nonce } = {}) {
1252
1366
  return seen > 0 && (t - seen) <= activePeerTtl;
1253
1367
  }
1254
1368
 
1369
+ function dshCoordinationPeer(peer) {
1370
+ return (peer?.provider || peer?.kind) === 'dsh' && ['hook', 'cordis'].includes(peer?.transport);
1371
+ }
1372
+
1255
1373
  function detectedPeerCanStop(peer) {
1374
+ if (dshCoordinationPeer(peer)) return false;
1256
1375
  const status = String(peer?.status || '').toLowerCase();
1257
1376
  if (['exited', 'detached'].includes(status)) return false;
1258
1377
  return peerIsActive(peer);
@@ -1284,11 +1403,11 @@ export function webIndexHtml({ nonce } = {}) {
1284
1403
  function taskOwnerStateText(task) {
1285
1404
  if (!task?.owner) return '';
1286
1405
  if (task.owner_stale) {
1287
- if (task.takeover_ready) return 'owner_state=stale/no-lock';
1406
+ if (task.takeover_ready) return tr('ownerStaleNoLock');
1288
1407
  const locks = Number(task.related_lock_count || 0);
1289
- return locks ? 'owner_state=stale/locks=' + locks : 'owner_state=stale';
1408
+ return locks ? tr('ownerStaleLocks') + ': ' + locks : tr('ownerStale');
1290
1409
  }
1291
- if (task.owner_active) return 'owner_state=active';
1410
+ if (task.owner_active) return tr('ownerActive');
1292
1411
  return '';
1293
1412
  }
1294
1413
 
@@ -1298,7 +1417,7 @@ export function webIndexHtml({ nonce } = {}) {
1298
1417
  }
1299
1418
 
1300
1419
  function stateCardCollapsed(section) {
1301
- return localStorage.getItem('hcc.stateCard.' + section + '.collapsed') === '1';
1420
+ return hccUi.safeGet('hcc.stateCard.' + section + '.collapsed') === '1';
1302
1421
  }
1303
1422
 
1304
1423
  function stateCardHtml(section, title, count, bodyHtml) {
@@ -1322,7 +1441,7 @@ export function webIndexHtml({ nonce } = {}) {
1322
1441
  const collapsed = !card.classList.contains('state-card-collapsed');
1323
1442
  card.classList.toggle('state-card-collapsed', collapsed);
1324
1443
  button.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
1325
- localStorage.setItem('hcc.stateCard.' + section + '.collapsed', collapsed ? '1' : '0');
1444
+ hccUi.safeSet('hcc.stateCard.' + section + '.collapsed', collapsed ? '1' : '0');
1326
1445
  });
1327
1446
  });
1328
1447
  }
@@ -1344,51 +1463,103 @@ export function webIndexHtml({ nonce } = {}) {
1344
1463
  }
1345
1464
 
1346
1465
  document.getElementById('projectSelect').addEventListener('change', (event) => {
1347
- switchProject(event.target.value).catch(console.error);
1348
- });
1349
- document.getElementById('langSelect').addEventListener('change', (event) => {
1350
- lang = event.target.value === 'zh' ? 'zh' : 'en';
1351
- applyLanguage();
1466
+ switchProject(event.target.value).catch(handleUiError);
1352
1467
  });
1353
1468
  document.getElementById('sessionKindFilter').addEventListener('change', (event) => {
1354
1469
  sessionKindFilter = event.target.value || 'all';
1355
1470
  updateLocationProject();
1356
1471
  renderSections();
1357
1472
  });
1358
- document.getElementById('addProjectBtn').addEventListener('click', async () => {
1359
- const input = document.getElementById('projectPath');
1360
- const root = input.value.trim();
1361
- if (!root) return;
1362
- await api('/api/projects', { method: 'POST', body: JSON.stringify({ root }) });
1363
- input.value = '';
1364
- await loadProjects();
1365
- await switchProject(root);
1473
+ document.getElementById('sessionSearch').addEventListener('input', (event) => {
1474
+ sessionSearchQuery = event.target.value;
1475
+ renderSections();
1476
+ });
1477
+ document.getElementById('sessionStatusFilter').addEventListener('change', (event) => {
1478
+ sessionStatusFilter = event.target.value || 'all';
1479
+ renderSections();
1480
+ });
1481
+ document.getElementById('projectForm').addEventListener('submit', async (event) => {
1482
+ event.preventDefault();
1483
+ const button = document.getElementById('addProjectBtn');
1484
+ if (button.disabled) return;
1485
+ button.disabled = true;
1486
+ document.getElementById('openProjectDialog').disabled = true;
1487
+ const errorElement = document.getElementById('projectDialogError');
1488
+ const originalProject = currentProject;
1489
+ errorElement.hidden = true;
1490
+ try {
1491
+ const input = document.getElementById('projectPath');
1492
+ const root = input.value.trim();
1493
+ if (!root) return;
1494
+ await api('/api/projects', { method: 'POST', body: JSON.stringify({ root }) });
1495
+ if (input.value.trim() === root) input.value = '';
1496
+ await loadProjects();
1497
+ if (projectDialog.hidden || currentProject !== originalProject) return;
1498
+ await switchProject(root);
1499
+ if (!projectDialog.hidden) closeDialog(projectDialog);
1500
+ } catch (error) {
1501
+ errorElement.textContent = tr('error.' + error.code, error.detail || error.message);
1502
+ errorElement.hidden = false;
1503
+ } finally { button.disabled = false; document.getElementById('openProjectDialog').disabled = false; }
1366
1504
  });
1367
1505
 
1506
+ function clearSessionFilters() {
1507
+ sessionSearchQuery = '';
1508
+ sessionStatusFilter = 'all';
1509
+ sessionKindFilter = 'all';
1510
+ document.getElementById('sessionSearch').value = '';
1511
+ document.getElementById('sessionStatusFilter').value = 'all';
1512
+ document.getElementById('sessionKindFilter').value = 'all';
1513
+ updateLocationProject();
1514
+ renderSections();
1515
+ document.getElementById('sessionSearch').focus();
1516
+ }
1517
+
1518
+ function sessionMatchesSearch(item) {
1519
+ const words = sessionSearchQuery.trim().toLocaleLowerCase().split(/\\s+/).filter(Boolean);
1520
+ if (!words.length) return true;
1521
+ const searchable = [item.name, item.task?.title, item.id, item.peer_id, item.kind, item.provider, item.cwd, item.worktree, item.command,
1522
+ item.type, item.transport, item.branch, item.provider_session_label, item.binding?.runtime_target,
1523
+ item.binding?.provider, item.binding?.provider_session_id, item.binding?.provider_session_name]
1524
+ .filter(Boolean).join(' ').toLocaleLowerCase();
1525
+ return words.every((word) => searchable.includes(word));
1526
+ }
1527
+
1528
+ function sessionDetailsHtml(key, rows) {
1529
+ return '<details class="session-details" data-detail-key="' + esc(key) + '"><summary data-focus-key="detail:' + esc(key) + '">' + esc(tr('sessionDetails')) + '</summary><dl>' +
1530
+ rows.filter(([, value]) => value !== undefined && value !== null && value !== '').map(([label, value]) =>
1531
+ '<dt>' + esc(label) + '</dt><dd>' + esc(value) + '</dd>').join('') + '</dl></details>';
1532
+ }
1533
+
1368
1534
  // ── Sessions rendering (managed + detected) ──────────────────────────
1369
1535
  function renderSections() {
1370
1536
  const box = document.getElementById('sessions');
1371
- const visibleSessions = sessions.filter(kindMatches);
1372
- const visibleDetected = detected.filter(kindMatches);
1537
+ const filtered = Boolean(sessionSearchQuery.trim() || sessionStatusFilter !== 'all' || sessionKindFilter !== 'all');
1538
+ const searching = Boolean(sessionSearchQuery.trim());
1539
+ const visibleSessions = sessions.filter((s) => kindMatches(s) && sessionMatchesSearch(s) &&
1540
+ (sessionStatusFilter === 'all' || s.status === 'running'))
1541
+ .sort((a, b) => Number(b.status === 'running') - Number(a.status === 'running') ||
1542
+ String(a.name || sessionPeerId(a)).localeCompare(String(b.name || sessionPeerId(b))));
1543
+ const visibleDetected = detected.filter((p) => kindMatches(p) && sessionMatchesSearch(p) &&
1544
+ (sessionStatusFilter === 'all' || peerIsActive(p)))
1545
+ .sort((a, b) => Number(b.last_seen_at || 0) - Number(a.last_seen_at || 0) || String(a.id).localeCompare(String(b.id)));
1373
1546
  const activeDetectedPeers = visibleDetected.filter((p) => peerIsActive(p));
1374
1547
  const staleDetectedPeers = visibleDetected.filter((p) => !peerIsActive(p));
1375
- const filterNote = sessionKindFilter === 'all' ? '' : '<br><br>' + esc(tr('viewFilter')) + ': ' + esc(sessionKindFilter);
1376
- const shortSessionValue = (value) => {
1377
- const text = String(value || '');
1378
- return text.length > 14 ? text.slice(0, 10) + '...' : text;
1379
- };
1548
+ const noMatches = '<div class="empty session-empty"><p>' + esc(tr('noMatchingSessions')) + '</p><button type="button" data-navigation="clear-session-filters">' + esc(tr('clearSessionFilters')) + '</button></div>';
1549
+ const startEmpty = '<div class="empty session-empty"><p>' + esc(tr('noActiveSessions')) + '</p><button class="primary" type="button" data-navigation="open-start">' + esc(tr('createFirstSession')) + '</button></div>';
1380
1550
  const manHtml = visibleSessions.length
1381
1551
  ? visibleSessions.map((s) => {
1382
1552
  const peerId = sessionPeerId(s);
1383
- const providerText = sessionProvider(s) + ':' + (providerSessionKnown(s) ? shortSessionValue(sessionProviderSessionValue(s)) : tr('unknown'));
1553
+ const title = s.name || s.task?.title || peerId;
1554
+ const path = s.cwd || '';
1384
1555
  return \`
1385
1556
  <div class="session \${active === s.id && activeType === 'managed' ? 'active' : ''}" data-id="\${esc(s.id)}" data-type="managed">
1386
- <div class="row"><strong>\${esc(peerId)}</strong><span class="badge \${badgeClass(s.status)}">\${esc(statusText(s.status))}</span></div>
1387
- <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>
1388
- <div class="path" title="\${esc(sessionMetaText(s))}">\${esc(sessionCardDetailText(s))}</div>
1557
+ <div class="row"><button class="session-select session-title" type="button" data-focus-key="managed:\${esc(s.id)}" aria-label="\${esc(title)}" aria-pressed="\${active === s.id && activeType === 'managed'}" title="\${esc(title)}"><strong>\${esc(title)}</strong></button><span class="badge \${badgeClass(s.status)}">\${esc(statusText(s.status))}</span></div>
1558
+ <div class="session-subtitle"><span class="session-provider">\${esc(sessionProvider(s))}</span><span class="path" title="\${esc(path)}">\${esc(path.split('/').filter(Boolean).slice(-2).join('/'))}</span></div>
1559
+ \${sessionDetailsHtml('managed:' + s.id, [[tr('peer'), peerId], [tr('sessionId'), s.id], [tr('runtime'), sessionRuntimeTarget(s)], [tr('providerSession'), sessionProvider(s) + ':' + (sessionProviderSessionValue(s) || tr('unknown'))], [tr('handoff.transport'), s.type || 'pty'], [tr('command'), s.command], [tr('cwd'), path]])}
1389
1560
  </div>\`;
1390
1561
  }).join('')
1391
- : '<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>';
1562
+ : filtered ? '' : startEmpty;
1392
1563
 
1393
1564
  const renderDetectedPeer = (p) => {
1394
1565
  const state = peerStateView(p);
@@ -1396,52 +1567,65 @@ export function webIndexHtml({ nonce } = {}) {
1396
1567
  return \`
1397
1568
  <div class="session \${activeDetected === p.id && activeType === 'detected' ? 'active' : ''}" data-id="\${esc(p.id)}" data-type="detected">
1398
1569
  <div class="row">
1399
- <strong>\${esc(p.id)}</strong>
1570
+ <button class="session-select session-title" type="button" data-focus-key="detected:\${esc(p.id)}" aria-label="\${esc(p.name || p.id)}" aria-pressed="\${activeDetected === p.id && activeType === 'detected'}" title="\${esc(p.name || p.id)}"><strong>\${esc(p.name || p.id)}</strong></button>
1400
1571
  <div style="display:flex;gap:6px;align-items:center">
1401
- <span class="badge" style="color:var(--warn);border-color:#6b5a20">\${esc(tr('detected'))}</span>
1402
- \${canStop ? \`
1403
- <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>
1572
+ <span class="badge \${badgeClass(state.label)}" title="\${esc(state.detail)}">\${esc(statusText(state.label))}</span>
1573
+ \${dshCoordinationPeer(p) ? '' : canStop ? \`
1574
+ <button class="session-action stop-detected-btn" data-action="stop-detected" data-id="\${esc(p.id)}" data-focus-key="stop:\${esc(p.id)}" title="\${esc(tr('action.stopPeer'))}" aria-label="\${esc(tr('action.stopPeer')) + ' ' + esc(p.id)}" type="button">✕</button>
1404
1575
  \` : \`
1405
- <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>
1576
+ <button class="session-action" data-action="restart-detected" data-id="\${esc(p.id)}" data-focus-key="restart:\${esc(p.id)}" title="\${esc(tr('action.restartPeer'))}" aria-label="\${esc(tr('action.restartPeer')) + ' ' + esc(p.id)}" type="button">↻</button>
1406
1577
  \`}
1407
1578
  </div>
1408
1579
  </div>
1409
- <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>
1410
- <div class="path" title="\${esc(p.worktree || '')}">\${esc((p.worktree || '').split('/').slice(-2).join('/'))}</div>
1580
+ <div class="session-subtitle"><span class="session-provider">\${esc(p.provider || p.kind || 'other')}</span><span class="path" title="\${esc(p.worktree || p.cwd || '')}">\${esc((p.worktree || p.cwd || '').split('/').filter(Boolean).slice(-2).join('/'))}</span></div>
1581
+ \${sessionDetailsHtml('detected:' + p.id, [[tr('peer'), p.id], [tr('kind'), p.kind], [tr('status'), state.detail], [tr('cwd'), p.worktree || p.cwd], [tr('command'), p.command], [tr('branch'), p.branch], [tr('pid'), p.pid]])}
1411
1582
  </div>\`;
1412
1583
  };
1413
1584
  const activeDetectedHtml = activeDetectedPeers.length
1414
1585
  ? activeDetectedPeers.map(renderDetectedPeer).join('')
1415
- : '<div class="empty">' + esc(tr('noActiveDetectedPeers')) + filterNote + '</div>';
1586
+ : filtered ? '' : '<div class="empty">' + esc(tr('noActiveDetectedPeers')) + '</div>';
1416
1587
  const staleToggleLabel = showStaleDetected ? tr('hideStale') : tr('showStale');
1417
- const staleDetectedHtml = showStaleDetected
1588
+ const staleDetectedHtml = (showStaleDetected || searching)
1418
1589
  ? (staleDetectedPeers.length ? staleDetectedPeers.map(renderDetectedPeer).join('') : '<div class="empty">' + esc(tr('noDetectedPeers')) + '</div>')
1419
1590
  : '';
1420
1591
 
1421
1592
  const savedScroll = box.scrollTop;
1422
- box.innerHTML = \`
1593
+ const restoreFocus = preserveFocus(box);
1594
+ const summaryFocus = document.activeElement?.tagName === 'SUMMARY' && box.contains(document.activeElement) ? document.activeElement.dataset.focusKey : '';
1595
+ const openDetails = new Set([...box.querySelectorAll('details[open]')].map((el) => el.dataset.detailKey));
1596
+ document.getElementById('sessionListCount').textContent = tr('sessionListSummary').replace('{count}', visibleSessions.length + activeDetectedPeers.length + ((showStaleDetected || searching) ? staleDetectedPeers.length : 0));
1597
+ box.innerHTML = filtered && !visibleSessions.length && !visibleDetected.length ? noMatches : \`
1423
1598
  <div class="sec-label">\${esc(tr('managed'))} <span class="badge">\${visibleSessions.filter(s=>s.status==='running').length} \${esc(tr('running'))}</span></div>
1424
1599
  \${manHtml}
1425
1600
  <div class="sec-label" style="margin-top:10px">\${esc(tr('activeDetected'))} <span class="badge" style="color:var(--warn)">\${activeDetectedPeers.length}</span></div>
1426
1601
  \${activeDetectedHtml}
1427
- <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>
1602
+ <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" \${searching ? 'hidden' : ''}>\${esc(staleToggleLabel)}</button></div>
1428
1603
  \${staleDetectedHtml}
1429
1604
  \`;
1605
+ box.querySelectorAll('details').forEach((el) => { el.open = openDetails.has(el.dataset.detailKey); });
1430
1606
  box.scrollTop = savedScroll;
1607
+ restoreFocus();
1608
+ if (summaryFocus) [...box.querySelectorAll('summary')].find((el) => el.dataset.focusKey === summaryFocus)?.focus({ preventScroll: true });
1609
+ box.querySelectorAll('[data-navigation="clear-session-filters"]').forEach((button) => button.addEventListener('click', clearSessionFilters));
1610
+ window.hccCommandPalette?.refresh();
1611
+ box.querySelectorAll('[data-navigation="open-start"]').forEach((button) => button.addEventListener('click', openStartDialog));
1431
1612
  const staleToggle = document.getElementById('toggleStaleDetected');
1432
1613
  if (staleToggle) {
1433
1614
  staleToggle.addEventListener('click', () => {
1434
1615
  showStaleDetected = !showStaleDetected;
1435
- localStorage.setItem('hcc.showStaleDetected', showStaleDetected ? '1' : '0');
1616
+ hccUi.safeSet('hcc.showStaleDetected', showStaleDetected ? '1' : '0');
1436
1617
  renderSections();
1437
1618
  });
1438
1619
  }
1439
1620
  box.querySelectorAll('.session[data-type="managed"]').forEach((el) => {
1440
- el.addEventListener('click', () => connectManaged(el.dataset.id));
1621
+ el.addEventListener('click', (event) => {
1622
+ if (event.target.closest('details, [data-action]')) return;
1623
+ connectManaged(el.dataset.id);
1624
+ });
1441
1625
  });
1442
1626
  box.querySelectorAll('.session[data-type="detected"]').forEach((el) => {
1443
1627
  el.addEventListener('click', (e) => {
1444
- if (e.target.closest('[data-action]')) return;
1628
+ if (e.target.closest('details, [data-action]')) return;
1445
1629
  connectDetected(el.dataset.id);
1446
1630
  });
1447
1631
  });
@@ -1451,39 +1635,49 @@ export function webIndexHtml({ nonce } = {}) {
1451
1635
  const peerId = btn.dataset.id;
1452
1636
  document.getElementById('stopDialogTitle').textContent = tr('stop') + ' ' + peerId + '?';
1453
1637
  stopKillCb.checked = false;
1638
+ stopKillCb.parentElement.parentElement.hidden = false;
1639
+ stopConfirmBtn.textContent = tr('stop');
1454
1640
  document.getElementById('stopDialogMeta').textContent = tr('detectedPeer');
1455
1641
  stopDialog._action = 'detected';
1456
1642
  stopDialog._peerId = peerId;
1457
- stopDialog.hidden = false;
1643
+ openDialog(stopDialog, stopCancelBtn);
1458
1644
  });
1459
1645
  });
1460
1646
  box.querySelectorAll('[data-action="restart-detected"]').forEach((btn) => {
1461
1647
  btn.addEventListener('click', async (e) => {
1462
- e.stopPropagation();
1463
- const peerId = btn.dataset.id;
1464
- await api('/api/detected/' + encodeURIComponent(peerId) + '/restart', { method: 'POST', body: '{}' });
1465
- await Promise.all([refreshSessions(), refreshDetected()]);
1648
+ try {
1649
+ e.stopPropagation();
1650
+ const peerId = btn.dataset.id;
1651
+ await api('/api/detected/' + encodeURIComponent(peerId) + '/restart', { method: 'POST', body: '{}' });
1652
+ await Promise.all([refreshSessions(), refreshDetected()]);
1653
+ } catch (error) { handleUiError(error); }
1466
1654
  });
1467
1655
  });
1468
1656
  }
1469
1657
 
1470
1658
  async function refreshSessions() {
1659
+ const root = currentProject;
1471
1660
  const data = await api('/api/sessions');
1661
+ if (currentProject !== root) return;
1472
1662
  sessions = data.sessions || [];
1663
+ sessionsLoaded = true;
1473
1664
  renderSections();
1474
1665
  if (active && activeType === 'managed') {
1475
1666
  const meta = sessions.find((s) => s.id === active);
1476
1667
  if (meta) {
1477
- document.getElementById('activeTitle').textContent = sessionPeerId(meta) || active;
1478
- document.getElementById('activeMeta').textContent = sessionMetaText(meta);
1668
+ renderActiveSession(meta);
1669
+ activeLocalClients = meta.local_clients ?? activeLocalClients;
1479
1670
  }
1480
1671
  }
1481
- connText('online');
1672
+ connText(ws?.readyState === WebSocket.OPEN ? 'attached' : activeDetected ? 'coordinationOnly' : active ? (activeConnectionState === 'connecting' ? 'reconnecting' : 'offline') : 'online');
1673
+ renderHandoff();
1482
1674
  }
1483
1675
 
1484
1676
  async function refreshDetected() {
1677
+ const root = currentProject;
1485
1678
  try {
1486
1679
  const data = await api('/api/detected');
1680
+ if (currentProject !== root) return;
1487
1681
  activePeerTtl = Number(data.active_peer_ttl || activePeerTtl);
1488
1682
  lastStateNow = Number(data.now || lastStateNow);
1489
1683
  detected = data.detected || [];
@@ -1493,11 +1687,13 @@ export function webIndexHtml({ nonce } = {}) {
1493
1687
 
1494
1688
  // ── Project state panel ───────────────────────────────────────────────
1495
1689
  function renderState(data) {
1690
+ lastStateData = data;
1496
1691
  const stateRoot = data.root || '';
1497
1692
  document.getElementById('rootPath').textContent = stateRoot;
1498
1693
  activePeerTtl = Number(data.active_peer_ttl || activePeerTtl);
1499
1694
  lastStateNow = Number(data.now || lastStateNow);
1500
1695
  const state = document.getElementById('state');
1696
+ const restoreFocus = preserveFocus(state);
1501
1697
  const preserveScroll = lastStateRoot === stateRoot;
1502
1698
  const savedStateScroll = preserveScroll ? state.scrollTop : 0;
1503
1699
  const savedCardScroll = preserveScroll
@@ -1563,20 +1759,40 @@ export function webIndexHtml({ nonce } = {}) {
1563
1759
  else body.scrollTop = saved.top;
1564
1760
  }
1565
1761
  bindStateCardToggles();
1762
+ window.hccWorkbench.apply();
1763
+ restoreFocus();
1566
1764
  lastStateRoot = stateRoot;
1567
1765
  }
1568
1766
 
1569
1767
  async function refreshState() {
1768
+ const root = currentProject;
1769
+ const selected = active;
1770
+ const selectionType = activeType;
1570
1771
  const peer = active ? managedPeerId(active) : null;
1571
1772
  const p = peer ? '/api/state?peer=' + encodeURIComponent(peer) : '/api/state';
1572
- const data = await api(p);
1573
- renderState(data);
1773
+ try {
1774
+ const data = await api(p);
1775
+ if (currentProject !== root || active !== selected || activeType !== selectionType) return;
1776
+ renderState(data);
1777
+ window.hccWorkbench.fresh();
1778
+ } catch (error) {
1779
+ if (currentProject === root && active === selected && activeType === selectionType) window.hccWorkbench.stale(error);
1780
+ throw error;
1781
+ }
1574
1782
  }
1575
1783
 
1576
1784
  async function refreshDetectedState() {
1577
1785
  if (!activeDetected) return;
1578
- const data = await api('/api/state?peer=' + encodeURIComponent(activeDetected));
1579
- renderState(data);
1786
+ const root = currentProject, selected = activeDetected;
1787
+ try {
1788
+ const data = await api('/api/state?peer=' + encodeURIComponent(selected));
1789
+ if (currentProject !== root || activeDetected !== selected || activeType !== 'detected') return;
1790
+ renderState(data);
1791
+ window.hccWorkbench.fresh();
1792
+ } catch (error) {
1793
+ if (currentProject === root && activeDetected === selected && activeType === 'detected') window.hccWorkbench.stale(error);
1794
+ throw error;
1795
+ }
1580
1796
  }
1581
1797
 
1582
1798
  async function refreshCurrentState() {
@@ -1587,11 +1803,12 @@ export function webIndexHtml({ nonce } = {}) {
1587
1803
  async function refreshVisibleData() {
1588
1804
  if (autoPollInFlight) return;
1589
1805
  autoPollInFlight = true;
1806
+ window.hccWorkbench.loading();
1590
1807
  try {
1591
1808
  await Promise.all([
1592
1809
  refreshSessions(),
1593
1810
  refreshDetected(),
1594
- activeType === 'detected' ? refreshDetectedState() : (active ? refreshState() : Promise.resolve())
1811
+ refreshCurrentState()
1595
1812
  ]);
1596
1813
  } finally {
1597
1814
  autoPollInFlight = false;
@@ -1610,19 +1827,32 @@ export function webIndexHtml({ nonce } = {}) {
1610
1827
 
1611
1828
  // ── Connect to managed (PTY) session ─────────────────────────────────
1612
1829
  function connectManaged(id) {
1830
+ if (!paneMode) window.hccWorkspace?.primary();
1613
1831
  const meta = sessions.find((s) => s.id === id);
1614
- const peerId = sessionPeerId(meta) || id;
1832
+ window.hccReview?.reset();
1833
+ disconnectWebSocket();
1615
1834
  active = id;
1616
1835
  activeDetected = null;
1617
1836
  activeType = 'managed';
1837
+ if (paneMode) { const url = new URL(location.href); url.searchParams.set('session', id); history.replaceState(null, '', url); }
1838
+ handoffStore.select(currentProject, 'managed', id);
1839
+ activeLocalClients = meta?.local_clients ?? null;
1618
1840
  renderSections();
1619
- document.getElementById('activeTitle').textContent = peerId;
1620
- document.getElementById('activeMeta').textContent = meta ? sessionMetaText(meta) : '';
1621
- document.getElementById('terminal').style.display = '';
1841
+ renderActiveSession(meta || { id });
1842
+ const isCodex = meta?.type === 'app-server';
1843
+ const isNative = meta?.type === 'native';
1844
+ document.getElementById('terminal').style.display = isCodex || isNative ? 'none' : '';
1845
+ document.getElementById('codexPanel').hidden = !isCodex;
1846
+ document.getElementById('codexPanel').style.display = isCodex ? '' : 'none';
1847
+ document.getElementById('nativePanel').hidden = !isNative;
1848
+ document.getElementById('terminalComposer').hidden = isCodex || isNative;
1622
1849
  document.getElementById('detectedPanel').style.display = 'none';
1623
1850
  document.getElementById('quickBar').style.display = '';
1624
-
1625
- if (ws) { clearTimeout(wsReconnectTimer); ws.close(); }
1851
+ setMobileView('terminal');
1852
+ draftReceipt = false;
1853
+ handoffStore.uncertain(currentProject, id);
1854
+ document.getElementById('terminalDraft').value = handoffStore.draft(currentProject, id).text;
1855
+ window.hccTerminalFind?.sync();
1626
1856
  term.reset();
1627
1857
  terminalHasContent = false;
1628
1858
  terminalLastDataAt = 0;
@@ -1662,13 +1892,20 @@ export function webIndexHtml({ nonce } = {}) {
1662
1892
  function openWs(id) {
1663
1893
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
1664
1894
  sessionActionTokens.delete(id);
1895
+ sessionControls.delete(id);
1896
+ activeConnectionState = 'connecting';
1897
+ renderHandoff();
1665
1898
  const socket = new WebSocket(proto + '://' + location.host + '/ws/terminal/' + encodeURIComponent(id) + requestQuery({ api_version: runtimeApiVersion }));
1666
1899
  ws = socket;
1667
1900
  socket.onopen = () => {
1901
+ if (ws !== socket || active !== id) return;
1902
+ activeConnectionState = 'connected';
1903
+ renderHandoff();
1668
1904
  wsReconnectFailures = 0;
1669
1905
  connText('attached');
1670
1906
  };
1671
1907
  socket.onmessage = (event) => {
1908
+ if (ws !== socket || active !== id) return;
1672
1909
  const msg = JSON.parse(event.data);
1673
1910
  const pinned = terminalPinned();
1674
1911
  // The server streams the tmux pane's raw output, so xterm renders
@@ -1676,9 +1913,15 @@ export function webIndexHtml({ nonce } = {}) {
1676
1913
  // escape sequences carry the cursor.
1677
1914
  if (msg.type === 'snapshot') {
1678
1915
  if (msg.action_token) sessionActionTokens.set(id, msg.action_token);
1916
+ if (msg.control) sessionControls.set(id, msg.control);
1917
+ if (msg.local_clients != null) activeLocalClients = msg.local_clients;
1918
+ const selected = sessions.find(item => item.id === id);
1919
+ if (selected?.type === 'native' && msg.native_state) window.hccNative?.render(msg.native_state);
1920
+ else if (selected?.type === 'app-server' && msg.state) window.hccCodex?.render(msg.state);
1921
+ renderHandoff();
1679
1922
  writeTerminalSnapshot(msg.data || '', true);
1680
1923
  resizeTerm();
1681
- flushPendingTerminalInput(id, socket);
1924
+ // Connection recovery never replays terminal input or a draft.
1682
1925
  }
1683
1926
  if (msg.type === 'data') {
1684
1927
  terminalHasContent = true;
@@ -1688,15 +1931,41 @@ export function webIndexHtml({ nonce } = {}) {
1688
1931
  if (msg.type === 'replace' && shouldApplyTerminalReplace()) {
1689
1932
  writeTerminalSnapshot(msg.data || '', pinned);
1690
1933
  }
1934
+ if (msg.type === 'control') {
1935
+ sessionControls.set(id, msg.control || {});
1936
+ lastSentTerminalSize = null;
1937
+ renderHandoff();
1938
+ resizeTerm();
1939
+ window.hccCodex?.sync();
1940
+ }
1941
+ if (msg.type === 'codex_state') window.hccCodex?.render(msg.state);
1942
+ if (msg.type === 'native_state') window.hccNative?.render(msg.state);
1943
+ if (msg.type === 'input_ack' && handoffStore.acknowledge(currentProject, id, msg.input_id)) {
1944
+ draftReceipt = true;
1945
+ document.getElementById('terminalDraft').value = handoffStore.draft(currentProject, id).text;
1946
+ renderDraftState();
1947
+ }
1948
+ if (msg.type === 'error') {
1949
+ if (msg.control) sessionControls.set(id, msg.control);
1950
+ renderHandoff();
1951
+ handoffStore.uncertain(currentProject, id);
1952
+ renderDraftState();
1953
+ handleUiError({ code: msg.error?.code, message: msg.error?.message || tr('requestFailed') });
1954
+ }
1691
1955
  if (msg.type === 'exit') { refreshSessions().catch(console.error); }
1692
1956
  };
1693
1957
  socket.onclose = (event) => {
1694
- sessionActionTokens.delete(id);
1695
1958
  if (ws !== socket) return;
1959
+ sessionActionTokens.delete(id);
1960
+ sessionControls.delete(id);
1961
+ activeConnectionState = 'disconnected';
1962
+ handoffStore.uncertain(currentProject, id);
1963
+ renderHandoff();
1964
+ window.hccCodex?.sync();
1696
1965
  // ui-4: no reconnect after logout, and stop after repeated failures
1697
1966
  // (e.g. the session cookie was revoked in another tab) instead of
1698
1967
  // looping every 2s forever.
1699
- if (event.code === 4001 || sessionStorage.getItem('hcc_logged_out') === '1') {
1968
+ if (event.code === 4001 || loggedOut) {
1700
1969
  connText('signed out');
1701
1970
  return;
1702
1971
  }
@@ -1717,17 +1986,27 @@ export function webIndexHtml({ nonce } = {}) {
1717
1986
 
1718
1987
  // ── Connect to detected (coordination-only) peer ──────────────────────
1719
1988
  function connectDetected(id) {
1989
+ if (!paneMode) window.hccWorkspace?.primary();
1720
1990
  const peer = detected.find((p) => p.id === id);
1991
+ window.hccReview?.reset();
1992
+ disconnectWebSocket();
1721
1993
  active = null;
1722
1994
  activeDetected = id;
1723
1995
  activeType = 'detected';
1724
- if (ws) { clearTimeout(wsReconnectTimer); ws.close(); ws = null; }
1996
+ handoffStore.select(currentProject, 'detected', id);
1997
+ renderHandoff();
1725
1998
  renderSections();
1726
1999
  document.getElementById('activeTitle').textContent = id + ' (' + tr('detected') + ')';
1727
2000
  document.getElementById('activeMeta').textContent = peer ? peer.kind + ' · ' + (peer.worktree || '') : '';
1728
2001
  document.getElementById('terminal').style.display = 'none';
2002
+ document.getElementById('codexPanel').hidden = true;
2003
+ document.getElementById('nativePanel').hidden = true;
2004
+ document.getElementById('codexPanel').style.display = 'none';
2005
+ document.getElementById('terminalComposer').hidden = true;
2006
+ document.getElementById('activeTask').hidden = true;
1729
2007
  document.getElementById('detectedPanel').style.display = '';
1730
2008
  document.getElementById('quickBar').style.display = 'none';
2009
+ setMobileView('terminal');
1731
2010
  connText('coordinationOnly');
1732
2011
  renderDetectedPanel(peer || { id });
1733
2012
  refreshDetectedState().catch(console.error);
@@ -1742,7 +2021,11 @@ export function webIndexHtml({ nonce } = {}) {
1742
2021
  <div class="body">
1743
2022
  <div class="item"><strong>\${esc(tr('peer'))}</strong><span class="mono">\${esc(peer.id)}</span></div>
1744
2023
  <div class="item"><strong>\${esc(tr('kind'))}</strong><span>\${esc(peer.kind || '')}</span></div>
1745
- <div class="item"><strong>\${esc(tr('status'))}</strong><span>\${esc(peer.status || '')}</span></div>
2024
+ \${dshCoordinationPeer(peer) ? \`
2025
+ <div class="item"><strong>\${esc(tr('providerSession'))}</strong><span class="mono" style="overflow-wrap:anywhere">\${esc(peer.provider_session_id || tr('unknown'))}</span></div>
2026
+ <p style="font-size:12px;color:var(--muted)">\${esc(tr('dshCoordinationHelp'))}</p>
2027
+ \` : ''}
2028
+ <div class="item"><strong>\${esc(tr('status'))}</strong><span>\${esc(statusText(peer.status))}</span></div>
1746
2029
  <div class="item"><strong>\${esc(tr('cwd'))}</strong><span class="mono" style="font-size:11px">\${esc(peer.worktree || '')}</span></div>
1747
2030
  <div class="item"><strong>\${esc(tr('pid'))}</strong><span>\${esc(peer.pid || tr('unknown'))}</span></div>
1748
2031
  <div class="item"><strong>\${esc(tr('lastSeen'))}</strong><span>\${peer.age_sec != null ? esc(peer.age_sec + tr('secondsAgo')) : ''}</span></div>
@@ -1751,24 +2034,34 @@ export function webIndexHtml({ nonce } = {}) {
1751
2034
  <div class="card">
1752
2035
  <h2>\${esc(tr('sendMessage'))}</h2>
1753
2036
  <div class="body" style="gap:8px">
1754
- <div style="font-size:12px;color:var(--muted)">\${tr('messageHelp')}</div>
1755
- <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>
2037
+ <div style="font-size:12px;color:var(--muted)">\${tr(dshCoordinationPeer(peer) && peer.transport === 'cordis' ? 'dshCordisMessageHelp' : 'messageHelp')}</div>
2038
+ <textarea id="detMsg" rows="3" style="width:100%;background:var(--input-bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:8px;font:inherit;resize:vertical" placeholder="\${esc(tr('messageBodyPlaceholder'))}"></textarea>
1756
2039
  <button class="primary" id="sendDetMsg">\${esc(tr('send'))}</button>
1757
2040
  </div>
1758
2041
  </div>
1759
2042
  </div>
1760
2043
  \`;
2044
+ const draftProject = currentProject;
2045
+ const draftKey = 'detected:' + peer.id;
2046
+ document.getElementById('detMsg').value = handoffStore.draft(draftProject, draftKey).text;
2047
+ document.getElementById('detMsg').addEventListener('input', (event) => handoffStore.saveDraft(draftProject, draftKey, event.target.value));
1761
2048
  document.getElementById('sendDetMsg').addEventListener('click', async () => {
1762
- const body = document.getElementById('detMsg').value.trim();
1763
- if (!body) return;
1764
- await api('/api/detected/' + encodeURIComponent(peer.id) + '/msg', {
1765
- method: 'POST',
1766
- body: JSON.stringify({ body })
2049
+ try {
2050
+ const body = document.getElementById('detMsg').value.trim();
2051
+ if (!body) return;
2052
+ await api('/api/detected/' + encodeURIComponent(peer.id) + '/msg', {
2053
+ method: 'POST',
2054
+ body: JSON.stringify({ body })
1767
2055
  });
1768
- document.getElementById('detMsg').value = '';
2056
+ const textarea = document.getElementById('detMsg');
2057
+ if (currentProject === draftProject && activeDetected === peer.id && textarea?.value.trim() === body) {
2058
+ textarea.value = '';
2059
+ handoffStore.saveDraft(draftProject, draftKey, '');
2060
+ }
1769
2061
  await refreshDetectedState();
1770
- });
1771
- }
2062
+ } catch (error) { handleUiError(error); }
2063
+ });
2064
+ }
1772
2065
 
1773
2066
  // ── Helpers ───────────────────────────────────────────────────────────
1774
2067
  function sendLine(text) {
@@ -1777,24 +2070,52 @@ export function webIndexHtml({ nonce } = {}) {
1777
2070
 
1778
2071
  // ── Start session form ────────────────────────────────────────────────
1779
2072
  document.getElementById('kind').addEventListener('change', syncStartModeOptions);
2073
+ document.getElementById('transport').addEventListener('change', syncStartModeOptions);
1780
2074
  document.getElementById('startMode').addEventListener('change', syncStartModeOptions);
1781
2075
  document.getElementById('resumeSelect').addEventListener('change', toggleResumeCustom);
1782
2076
  syncStartModeOptions();
1783
2077
 
1784
2078
  document.getElementById('startForm').addEventListener('submit', async (event) => {
1785
2079
  event.preventDefault();
1786
- const kind = document.getElementById('kind').value;
1787
- const mode = document.getElementById('startMode').value;
1788
- const sel = document.getElementById('resumeSelect');
1789
- const resume = (sel.value && sel.value !== '__custom__')
1790
- ? sel.value
1791
- : document.getElementById('resumeArg').value.trim();
1792
- if (mode === 'resume' && !resume) return;
1793
- const payload = { kind, mode };
1794
- if (mode === 'resume') payload.resume = resume;
1795
- const data = await api('/api/sessions', { method: 'POST', body: JSON.stringify(payload) });
1796
- await refreshSessions();
1797
- connectManaged(data.session.id);
2080
+ if (startPending) return;
2081
+ startPending = true;
2082
+ document.getElementById('openStartDialog').disabled = true;
2083
+ const submitButton = event.currentTarget.querySelector('button[type="submit"]');
2084
+ submitButton.disabled = true;
2085
+ const errorElement = document.getElementById('startDialogError');
2086
+ const projectRoot = currentProject;
2087
+ errorElement.hidden = true;
2088
+ try {
2089
+ const kind = document.getElementById('kind').value;
2090
+ const mode = document.getElementById('startMode').value;
2091
+ const sel = document.getElementById('resumeSelect');
2092
+ const resume = (sel.value && sel.value !== '__custom__')
2093
+ ? sel.value
2094
+ : document.getElementById('resumeArg').value.trim();
2095
+ if (mode === 'resume' && !resume) {
2096
+ document.getElementById('resumeArg').focus();
2097
+ return;
2098
+ }
2099
+ const payload = { kind, mode };
2100
+ if (kind === 'codex' && document.getElementById('transport').value === 'app-server') {
2101
+ payload.transport = 'app-server';
2102
+ if (mode === 'resume') {
2103
+ if (!document.getElementById('appServerResumeConfirm').checked) throw new Error(tr('handoff.resumeConfirm'));
2104
+ payload.handoffConfirmed = true;
2105
+ }
2106
+ }
2107
+ if (mode === 'resume') payload.resume = resume;
2108
+ const data = await api('/api/sessions', { method: 'POST', body: JSON.stringify(payload) });
2109
+ if (currentProject !== projectRoot) return;
2110
+ await refreshSessions();
2111
+ if (currentProject !== projectRoot || startDialog.hidden) return;
2112
+ closeDialog(startDialog);
2113
+ connectManaged(data.session.id);
2114
+ } catch (error) {
2115
+ errorElement.textContent = tr('error.' + error.code, error.detail || error.message);
2116
+ errorElement.hidden = false;
2117
+ }
2118
+ finally { startPending = false; submitButton.disabled = false; document.getElementById('openStartDialog').disabled = false; }
1798
2119
  });
1799
2120
 
1800
2121
  function activePeerInfo() {
@@ -1826,52 +2147,80 @@ export function webIndexHtml({ nonce } = {}) {
1826
2147
  function formatActionResult(result) {
1827
2148
  if (!result) return '';
1828
2149
  const data = result.data || {};
2150
+ const error = result.error || data.error;
2151
+ if (error) {
2152
+ const code = String(result.errorCode || '').toLowerCase();
2153
+ return [
2154
+ tr('error.' + code, tr('requestFailed')),
2155
+ result.errorCode ? '[' + result.errorCode + ']' : '',
2156
+ tr('details') + ': ' + error
2157
+ ].join('\\n');
2158
+ }
1829
2159
  if (result.action === 'status') {
1830
- const tasks = (data.tasks || []).map((row) => row.status + ':' + row.n).join(', ') || 'none';
2160
+ const tasks = (data.tasks || []).map((row) => statusText(row.status) + ': ' + row.n).join(', ') || tr('none');
1831
2161
  return [
1832
- data.root,
1833
- 'peers active=' + data.active_peers + ' stale=' + data.stale_peers,
1834
- 'tasks ' + tasks,
1835
- 'locks active=' + data.active_locks,
1836
- 'unread ' + (data.unread ?? 0)
2162
+ data.root ? tr('root') + ': ' + data.root : '',
2163
+ tr('peers') + ' · ' + tr('active') + ': ' + (data.active_peers ?? 0) + ' · ' + tr('stale') + ': ' + (data.stale_peers ?? 0),
2164
+ tr('tasks') + ': ' + tasks,
2165
+ tr('locks') + ' · ' + tr('active') + ': ' + (data.active_locks ?? 0),
2166
+ tr('unread') + ': ' + (data.unread ?? 0)
1837
2167
  ].filter(Boolean).join('\\n');
1838
2168
  }
1839
2169
  if (result.action === 'state') {
1840
2170
  const automation = data.automation || {};
1841
2171
  const next = automation.next_action || {};
1842
2172
  return [
1843
- 'phase: ' + (automation.phase || 'idle'),
1844
- 'next: ' + (next.command || next.kind || 'none'),
1845
- 'why: ' + (next.reason || tr('noImmediateAction')),
1846
- ...(automation.warnings || []).map((w) => 'warning: ' + w)
2173
+ tr('phase') + ': ' + statusText(automation.phase || 'idle'),
2174
+ tr('next') + ': ' + (next.command || (next.kind && next.kind !== 'none' ? tr('action.' + next.kind, next.kind) : tr('none'))),
2175
+ tr('why') + ': ' + (next.reason || tr('noImmediateAction')),
2176
+ ...(automation.warnings || []).map((w) => tr('warnings') + ': ' + w)
1847
2177
  ].join('\\n');
1848
2178
  }
1849
2179
  if (result.action === 'inbox') {
1850
2180
  const messages = data.messages || [];
1851
2181
  return messages.length
1852
- ? messages.map((m) => '#' + m.id + ' ' + m.sender + ' -> ' + (m.recipient || 'all') + ': ' + m.body).join('\\n')
2182
+ ? messages.map((m) => '#' + m.id + ' ' + m.sender + ' -> ' + (m.recipient || tr('all')) + ': ' + m.body).join('\\n')
1853
2183
  : tr('noMessages');
1854
2184
  }
1855
2185
  if (result.action === 'task-next') {
1856
2186
  return data.task
1857
- ? (data.current ? 'current ' : 'claimed ') + '#' + data.task.id + ' ' + data.task.title + ' (' + data.task.status + ')'
1858
- : result.summary;
2187
+ ? tr(data.current ? 'current' : 'claimed') + ': #' + data.task.id + ' ' + data.task.title + ' (' + statusText(data.task.status) + ')'
2188
+ : tr('noPendingTasks');
1859
2189
  }
1860
2190
  if (result.action === 'task-takeover') {
1861
- return data.task ? '#' + data.task.id + ' ' + data.task.title + ' -> ' + data.task.owner : result.summary;
2191
+ return data.task ? '#' + data.task.id + ' ' + data.task.title + ' · ' + tr('owner') + ': ' + data.task.owner : tr('noTasks');
1862
2192
  }
1863
2193
  if (result.action === 'lock-acquire') {
1864
- return data.lock ? 'locked ' + lockLabel(data.lock) + ' by ' + data.lock.owner : result.summary;
2194
+ return data.lock ? tr('locked') + ': ' + lockLabel(data.lock) + ' · ' + tr('by') + ': ' + data.lock.owner : tr('noActiveLocks');
1865
2195
  }
1866
2196
  if (result.action === 'lock-release') {
1867
- return result.summary || JSON.stringify(data.result || data, null, 2);
2197
+ return data.result ? tr(data.result.released ? 'lockReleased' : 'noLock') + ': ' + lockLabel(data.result) : tr('noActiveLocks');
2198
+ }
2199
+ if (result.action === 'heartbeat') {
2200
+ return [
2201
+ tr('peer') + ': ' + (data.peer || result.peer || tr('unknown')),
2202
+ data.status ? tr('status') + ': ' + statusText(data.status) : '',
2203
+ tr('renewedLocks') + ': ' + (data.renewed ?? 0)
2204
+ ].filter(Boolean).join('\\n');
2205
+ }
2206
+ if (result.action === 'register') {
2207
+ const peer = data.peer || {};
2208
+ return [
2209
+ tr('registered') + ': ' + (peer.id || result.peer || tr('unknown')),
2210
+ tr('kind') + ': ' + (peer.kind || tr('unknown')),
2211
+ tr('status') + ': ' + statusText(peer.status),
2212
+ peer.worktree ? tr('cwd') + ': ' + peer.worktree : ''
2213
+ ].filter(Boolean).join('\\n');
1868
2214
  }
1869
2215
  return result.summary || JSON.stringify(result, null, 2);
1870
2216
  }
1871
2217
 
1872
2218
  function showActionResult(result) {
2219
+ lastActionResult = result;
1873
2220
  const panel = document.getElementById('actionResult');
1874
- document.getElementById('actionResultTitle').textContent = (result.action || tr('actionResult')) + ' · ' + (result.peer || '');
2221
+ const action = String(result.action || '');
2222
+ const label = action ? tr('action.' + action, tr(action, action)) : tr('actionResult');
2223
+ document.getElementById('actionResultTitle').textContent = label + (result.peer ? ' · ' + result.peer : '');
1875
2224
  document.getElementById('actionResultBody').textContent = formatActionResult(result);
1876
2225
  panel.hidden = false;
1877
2226
  }
@@ -1885,7 +2234,7 @@ export function webIndexHtml({ nonce } = {}) {
1885
2234
  : action === 'heartbeat'
1886
2235
  ? { renew_locks: true }
1887
2236
  : {};
1888
- if (!readOnly) payload.action_token = info.actionToken;
2237
+ if (!readOnly) { payload.action_token = info.actionToken; payload.epoch = controlEpoch(); }
1889
2238
  const result = await api('/api/peers/' + encodeURIComponent(info.peerId) + '/actions/' + encodeURIComponent(action), readOnly
1890
2239
  ? {}
1891
2240
  : { method: 'POST', body: JSON.stringify(payload) });
@@ -1897,7 +2246,7 @@ export function webIndexHtml({ nonce } = {}) {
1897
2246
  button.addEventListener('click', () => {
1898
2247
  closeActionsMenu();
1899
2248
  runPeerAction(button.dataset.action).catch((err) => {
1900
- showActionResult({ action: button.dataset.action, peer: activePeerInfo()?.peerId || '', summary: err.message, data: { error: err.message } });
2249
+ handleUiError(err, button.dataset.action);
1901
2250
  });
1902
2251
  });
1903
2252
  });
@@ -1911,12 +2260,15 @@ export function webIndexHtml({ nonce } = {}) {
1911
2260
  });
1912
2261
  document.getElementById('logoutBtn').addEventListener('click', async () => {
1913
2262
  closeActionsMenu();
1914
- const res = await fetch('/logout', { method: 'POST', headers });
1915
- if (!res.ok) throw new Error('logout failed');
1916
- // Stop reconnect only after the server confirms revocation. A failed
1917
- // logout must not poison a later authenticated page in this tab.
1918
- sessionStorage.setItem('hcc_logged_out', '1');
1919
- location.replace('/');
2263
+ try {
2264
+ const res = await fetch('/logout', { method: 'POST', headers });
2265
+ if (!res.ok) throw new Error(tr('requestFailed'));
2266
+ // Stop reconnect only after the server confirms revocation.
2267
+ loggedOut = true;
2268
+ window.hccWorkspace?.destroy();
2269
+ try { sessionStorage.setItem('hcc_logged_out', '1'); } catch {}
2270
+ location.replace('/');
2271
+ } catch (error) { handleUiError(error, 'logout'); }
1920
2272
  });
1921
2273
  document.getElementById('actionResultClose').addEventListener('click', () => {
1922
2274
  document.getElementById('actionResult').hidden = true;
@@ -1927,6 +2279,87 @@ export function webIndexHtml({ nonce } = {}) {
1927
2279
  const stopConfirmBtn = document.getElementById('stopConfirmBtn');
1928
2280
  const stopCancelBtn = document.getElementById('stopCancelBtn');
1929
2281
  const stopKillCb = document.getElementById('stopKillCb');
2282
+ const settingsDialog = document.getElementById('settingsDialog');
2283
+ const projectDialog = document.getElementById('projectDialog');
2284
+ const startDialog = document.getElementById('startDialog');
2285
+ let dialogReturnFocus = null;
2286
+ function openDialog(dialog, initialFocus) {
2287
+ closeActionsMenu();
2288
+ dialogReturnFocus = document.activeElement;
2289
+ dialog.hidden = false;
2290
+ document.querySelector('.app').inert = true;
2291
+ document.querySelector('.global-header').inert = true;
2292
+ document.querySelector('.mobile-nav').inert = true;
2293
+ initialFocus?.focus();
2294
+ }
2295
+ function closeDialog(dialog) {
2296
+ dialog.hidden = true;
2297
+ document.querySelector('.app').inert = false;
2298
+ document.querySelector('.global-header').inert = false;
2299
+ document.querySelector('.mobile-nav').inert = false;
2300
+ if (dialogReturnFocus?.isConnected && !dialogReturnFocus.disabled && dialogReturnFocus.getClientRects().length) dialogReturnFocus.focus();
2301
+ else document.getElementById('settingsBtn').focus();
2302
+ dialogReturnFocus = null;
2303
+ }
2304
+ function updateStopDialogTitle() {
2305
+ const peerId = stopDialog._action === 'detected' ? stopDialog._peerId : (sessionPeerId(sessions.find((s) => s.id === active)) || active);
2306
+ document.getElementById('stopDialogTitle').textContent = tr('stop') + ' ' + (peerId || '') + '?';
2307
+ if (stopDialog._action === 'detected') {
2308
+ document.getElementById('stopDialogMeta').textContent = tr('detectedPeer');
2309
+ stopConfirmBtn.textContent = tr('stop');
2310
+ } else {
2311
+ const session = sessions.find((item) => item.id === active) || {};
2312
+ document.getElementById('stopDialogTitle').textContent = tr(session.type === 'tmux' ? 'handoff.detach' : 'handoff.terminate') + ' ' + (peerId || '') + '?';
2313
+ stopConfirmBtn.textContent = tr(session.type === 'tmux' ? 'handoff.detach' : 'handoff.terminate');
2314
+ }
2315
+ }
2316
+ function openStartDialog() {
2317
+ if (startPending) return;
2318
+ document.getElementById('startProjectPath').textContent = currentProject;
2319
+ document.getElementById('startDialogError').hidden = true;
2320
+ document.getElementById('appServerResumeConfirm').checked = false;
2321
+ syncStartModeOptions();
2322
+ openDialog(startDialog, document.getElementById('kind'));
2323
+ }
2324
+ document.getElementById('openStartDialog').addEventListener('click', openStartDialog);
2325
+ document.getElementById('openHistoryDialog').addEventListener('click', () => window.hccHistory.open());
2326
+ document.getElementById('openReviewDialog').addEventListener('click', () => window.hccReview.open());
2327
+ document.getElementById('startDialogClose').addEventListener('click', () => closeDialog(startDialog));
2328
+ document.getElementById('startCancelBtn').addEventListener('click', () => closeDialog(startDialog));
2329
+ document.getElementById('openProjectDialog').addEventListener('click', () => {
2330
+ document.getElementById('projectDialogError').hidden = true;
2331
+ openDialog(projectDialog, document.getElementById('projectPath'));
2332
+ });
2333
+ document.getElementById('projectDialogClose').addEventListener('click', () => closeDialog(projectDialog));
2334
+ document.getElementById('projectCancelBtn').addEventListener('click', () => closeDialog(projectDialog));
2335
+ document.getElementById('settingsBtn').addEventListener('click', () => openDialog(settingsDialog, document.getElementById('langSelect')));
2336
+ document.getElementById('settingsClose').addEventListener('click', () => closeDialog(settingsDialog));
2337
+ [settingsDialog, stopDialog, projectDialog, startDialog, document.getElementById('historyDialog'), document.getElementById('reviewDialog'), document.getElementById('commandDialog')].forEach((dialog) => {
2338
+ dialog.addEventListener('click', (event) => { if (event.target === dialog) closeDialog(dialog); });
2339
+ dialog.addEventListener('keydown', (event) => {
2340
+ if (event.isComposing) return;
2341
+ if (event.key === 'Escape') { event.preventDefault(); closeDialog(dialog); return; }
2342
+ if (event.key !== 'Tab') return;
2343
+ const controls = [...dialog.querySelectorAll('button, select, input, textarea, [tabindex="0"]')].filter((el) => !el.disabled && el.tabIndex >= 0 && el.getClientRects().length);
2344
+ const first = controls[0], last = controls[controls.length - 1];
2345
+ if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
2346
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
2347
+ });
2348
+ });
2349
+ function preserveFocus(root) {
2350
+ const focused = document.activeElement;
2351
+ if (!root.contains(focused)) return () => {};
2352
+ const key = focused.dataset.focusKey;
2353
+ const id = focused.id;
2354
+ const section = focused.closest('[data-section]')?.dataset.section;
2355
+ const action = focused.dataset.action;
2356
+ const peerId = focused.dataset.id;
2357
+ return () => {
2358
+ const target = [...root.querySelectorAll('button, select, input')].find((el) =>
2359
+ key ? el.dataset.focusKey === key : id ? el.id === id : section ? el.closest('[data-section]')?.dataset.section === section : action && el.dataset.action === action && el.dataset.id === peerId);
2360
+ target?.focus({ preventScroll: true });
2361
+ };
2362
+ }
1930
2363
 
1931
2364
  // ── Actions dropdown (declutters the toolbar) ─────────────────────────
1932
2365
  const actionsBtn = document.getElementById('actionsBtn');
@@ -1954,6 +2387,41 @@ export function webIndexHtml({ nonce } = {}) {
1954
2387
 
1955
2388
  // ── Collapsible side panels ───────────────────────────────────────────
1956
2389
  const appEl = document.querySelector('.app');
2390
+ const narrowViewport = window.matchMedia('(max-width: 1099px)');
2391
+ const focusBtn = document.getElementById('focusBtn');
2392
+ function syncFocusButton() {
2393
+ const enabled = appEl.classList.contains('focus-mode');
2394
+ focusBtn.textContent = tr(enabled ? 'exitFocus' : 'focusMode');
2395
+ focusBtn.setAttribute('aria-pressed', String(enabled));
2396
+ }
2397
+ function syncPanelAccess() {
2398
+ const focus = appEl.classList.contains('focus-mode') && !narrowViewport.matches;
2399
+ for (const [selector, view, side] of [['.sidebar', 'sessions', 'left'], ['.main', 'terminal', null], ['.inspector', 'state', 'right']]) {
2400
+ appEl.querySelector(selector).inert = paneMode ? view !== 'terminal' : narrowViewport.matches
2401
+ ? appEl.dataset.view !== view || view === 'terminal' && appEl.classList.contains('workspace-open') && document.getElementById('workspace').dataset.activePane === 'secondary'
2402
+ : Boolean(side && (focus || sideIsCollapsed(side) || side === 'right' && appEl.classList.contains('workspace-open')));
2403
+ }
2404
+ document.getElementById('workspaceSecondPane').inert = narrowViewport.matches && (appEl.dataset.view !== 'terminal' || document.getElementById('workspace').dataset.activePane !== 'secondary');
2405
+ }
2406
+ function setMobileView(view) {
2407
+ if (paneMode) view = 'terminal';
2408
+ if (!['sessions', 'terminal', 'state'].includes(view)) return;
2409
+ appEl.dataset.view = view;
2410
+ document.querySelectorAll('.mobile-nav [data-view]').forEach((button) => {
2411
+ if (button.dataset.view === view) button.setAttribute('aria-current', 'page');
2412
+ else button.removeAttribute('aria-current');
2413
+ });
2414
+ syncPanelAccess();
2415
+ closeActionsMenu();
2416
+ requestAnimationFrame(() => resizeTerm());
2417
+ }
2418
+ document.querySelectorAll('.mobile-nav [data-view]').forEach((button) => button.addEventListener('click', () => setMobileView(button.dataset.view)));
2419
+ focusBtn.addEventListener('click', () => {
2420
+ appEl.classList.toggle('focus-mode');
2421
+ syncFocusButton();
2422
+ syncPanelAccess();
2423
+ setTimeout(resizeTerm, 200);
2424
+ });
1957
2425
  const toggleLeftBtn = document.getElementById('toggleLeft');
1958
2426
  const toggleRightBtn = document.getElementById('toggleRight');
1959
2427
  const resizeLeftHandle = document.getElementById('resizeLeft');
@@ -1964,10 +2432,10 @@ export function webIndexHtml({ nonce } = {}) {
1964
2432
  const sideWidthKey = { left: 'hcc.sidebar.left.width', right: 'hcc.sidebar.right.width' };
1965
2433
  const dragState = { side: null, startX: 0, startWidth: 0, moved: false, suppressClick: false };
1966
2434
  function sideIsCollapsed(side) {
1967
- return appEl.classList.contains(side + '-collapsed') || localStorage.getItem('hcc.collapse.' + side) === '1';
2435
+ return appEl.classList.contains(side + '-collapsed') || hccUi.safeGet('hcc.collapse.' + side) === '1';
1968
2436
  }
1969
2437
  function storedSideWidth(side) {
1970
- const raw = Number(localStorage.getItem(sideWidthKey[side]));
2438
+ const raw = Number(hccUi.safeGet(sideWidthKey[side]));
1971
2439
  return Number.isFinite(raw) && raw > 0 ? raw : sideDefaults[side];
1972
2440
  }
1973
2441
  function effectiveOppositeWidth(side) {
@@ -1986,7 +2454,11 @@ export function webIndexHtml({ nonce } = {}) {
1986
2454
  function setSideWidth(side, width, persist = true) {
1987
2455
  const value = clampSideWidth(side, width);
1988
2456
  appEl.style.setProperty('--' + side + '-width', value + 'px');
1989
- if (persist) localStorage.setItem(sideWidthKey[side], String(value));
2457
+ if (persist) hccUi.safeSet(sideWidthKey[side], String(value));
2458
+ const handle = document.getElementById(side === 'left' ? 'resizeLeft' : 'resizeRight');
2459
+ handle.setAttribute('aria-valuemin', String(sideMin[side]));
2460
+ handle.setAttribute('aria-valuemax', String(clampSideWidth(side, sideMax[side])));
2461
+ handle.setAttribute('aria-valuenow', String(value));
1990
2462
  return value;
1991
2463
  }
1992
2464
  function applySideWidths() {
@@ -2002,17 +2474,19 @@ export function webIndexHtml({ nonce } = {}) {
2002
2474
  toggleRightBtn.title = (r ? tr('show') : tr('collapse')) + ' ' + tr('statePanel') + '; ' + tr('dragToResize');
2003
2475
  }
2004
2476
  function applyCollapseState() {
2005
- appEl.classList.toggle('left-collapsed', localStorage.getItem('hcc.collapse.left') === '1');
2006
- appEl.classList.toggle('right-collapsed', localStorage.getItem('hcc.collapse.right') === '1');
2477
+ appEl.classList.toggle('left-collapsed', hccUi.safeGet('hcc.collapse.left') === '1');
2478
+ appEl.classList.toggle('right-collapsed', hccUi.safeGet('hcc.collapse.right') === '1');
2007
2479
  applySideWidths();
2008
2480
  syncToggleIcons();
2481
+ syncPanelAccess();
2009
2482
  }
2010
2483
  function toggleSide(side) {
2011
2484
  const cls = side + '-collapsed';
2012
2485
  const on = appEl.classList.toggle(cls);
2013
- localStorage.setItem('hcc.collapse.' + side, on ? '1' : '0');
2486
+ hccUi.safeSet('hcc.collapse.' + side, on ? '1' : '0');
2014
2487
  applySideWidths();
2015
2488
  syncToggleIcons();
2489
+ syncPanelAccess();
2016
2490
  // Refit the terminal after the grid transition so xterm uses the new width.
2017
2491
  setTimeout(() => { try { resizeTerm(); } catch {} }, 200);
2018
2492
  }
@@ -2038,12 +2512,13 @@ export function webIndexHtml({ nonce } = {}) {
2038
2512
  setSideWidth(dragState.side, width);
2039
2513
  if (dragState.side === 'left') {
2040
2514
  appEl.classList.remove('left-collapsed');
2041
- localStorage.setItem('hcc.collapse.left', '0');
2515
+ hccUi.safeSet('hcc.collapse.left', '0');
2042
2516
  } else {
2043
2517
  appEl.classList.remove('right-collapsed');
2044
- localStorage.setItem('hcc.collapse.right', '0');
2518
+ hccUi.safeSet('hcc.collapse.right', '0');
2045
2519
  }
2046
2520
  syncToggleIcons();
2521
+ syncPanelAccess();
2047
2522
  try { resizeTerm(); } catch {}
2048
2523
  event.preventDefault();
2049
2524
  }
@@ -2057,6 +2532,19 @@ export function webIndexHtml({ nonce } = {}) {
2057
2532
  try { resizeTerm(); } catch {}
2058
2533
  }
2059
2534
  function bindSideHandle(handle, side, clickAction = null) {
2535
+ if (handle.getAttribute('role') === 'separator') handle.addEventListener('keydown', (event) => {
2536
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
2537
+ event.preventDefault();
2538
+ const step = event.shiftKey ? 40 : 16;
2539
+ const delta = (event.key === 'ArrowRight' ? 1 : -1) * (side === 'left' ? 1 : -1) * step;
2540
+ const width = event.key === 'Home' ? sideMin[side] : event.key === 'End' ? sideMax[side] : readSideWidth(side) + delta;
2541
+ appEl.classList.remove(side + '-collapsed');
2542
+ hccUi.safeSet('hcc.collapse.' + side, '0');
2543
+ setSideWidth(side, width);
2544
+ syncToggleIcons();
2545
+ syncPanelAccess();
2546
+ setTimeout(resizeTerm, 200);
2547
+ });
2060
2548
  handle.addEventListener('pointerdown', (event) => beginSideDrag(side, event));
2061
2549
  handle.addEventListener('pointermove', moveSideDrag);
2062
2550
  handle.addEventListener('pointerup', endSideDrag);
@@ -2075,31 +2563,116 @@ export function webIndexHtml({ nonce } = {}) {
2075
2563
  bindSideHandle(toggleRightBtn, 'right', toggleSide);
2076
2564
  window.addEventListener('resize', () => {
2077
2565
  applySideWidths();
2566
+ syncPanelAccess();
2567
+ closeActionsMenu();
2078
2568
  setTimeout(() => { try { resizeTerm(); } catch {} }, 50);
2079
2569
  });
2080
2570
  applyCollapseState();
2081
2571
 
2082
2572
  document.getElementById('stopBtn').addEventListener('click', () => {
2083
- if (!active) return;
2573
+ if (!active || !canControl()) return;
2084
2574
  const session = sessions.find((s) => s.id === active) || {};
2085
- document.getElementById('stopDialogTitle').textContent = tr('stop') + ' ' + (sessionPeerId(session) || active) + '?';
2575
+ if (session.type === 'native') return;
2576
+ document.getElementById('stopDialogTitle').textContent = tr(session.type === 'tmux' ? 'handoff.detach' : 'handoff.terminate') + ' ' + (sessionPeerId(session) || active) + '?';
2577
+ stopConfirmBtn.textContent = tr(session.type === 'tmux' ? 'handoff.detach' : 'handoff.terminate');
2086
2578
  stopKillCb.checked = false;
2087
2579
  const meta = sessionMetaText(session);
2088
- document.getElementById('stopDialogMeta').textContent = meta || '';
2580
+ document.getElementById('stopDialogMeta').textContent = [meta, tr(session.type === 'tmux' ? 'handoff.detachHelp' : 'handoff.terminateHelp')].filter(Boolean).join(' · ');
2581
+ stopKillCb.parentElement.parentElement.hidden = session.type !== 'tmux';
2089
2582
  stopDialog._action = null; stopDialog._peerId = null;
2090
- stopDialog.hidden = false;
2583
+ stopDialog._sessionId = active;
2584
+ stopDialog._projectRoot = currentProject;
2585
+ openDialog(stopDialog, stopCancelBtn);
2091
2586
  });
2092
2587
  document.getElementById('refreshBtn').addEventListener('click', () => {
2093
- refreshVisibleData().catch(console.error);
2588
+ refreshVisibleData().catch(handleUiError);
2589
+ });
2590
+ document.getElementById('stateRetry').addEventListener('click', () => {
2591
+ refreshVisibleData().catch(handleUiError);
2094
2592
  });
2095
2593
 
2594
+ function terminalFindAvailable() {
2595
+ const session = sessions.find(item => item.id === active);
2596
+ return Boolean(active && activeType === 'managed' && session && session.type !== 'app-server' && session.type !== 'native');
2597
+ }
2598
+ function focusSessionsSearch() {
2599
+ if (paneMode) { window.hccCommandPalette?.open(); return; }
2600
+ if (narrowViewport.matches) setMobileView('sessions');
2601
+ else {
2602
+ appEl.classList.remove('focus-mode');
2603
+ hccUi.safeSet('hcc.collapse.left', '0');
2604
+ applyCollapseState(); syncFocusButton();
2605
+ }
2606
+ document.getElementById('sessionSearch').focus();
2607
+ }
2608
+ const shortcutPrefix = /Mac|iPhone|iPad/.test(navigator.platform || '') ? '⌘ ⇧ ' : 'Ctrl Shift ';
2609
+ window.hccTerminalFindHost = {
2610
+ term, addon: searchAddon, available: terminalFindAvailable,
2611
+ subject: () => currentProject + ':' + (active || ''),
2612
+ showTerminal: () => { if (!paneMode) window.hccWorkspace?.primary(); setMobileView('terminal'); }
2613
+ };
2614
+ ${terminalFindScript()}
2615
+ window.hccCommandHost = {
2616
+ openDialog, closeDialog, error: handleUiError,
2617
+ modalOpen: () => Boolean(document.querySelector('.dialog-overlay:not([hidden])')),
2618
+ terminalAvailable: terminalFindAvailable, focusSessions: focusSessionsSearch,
2619
+ commands() {
2620
+ const page = tr('commands.pages');
2621
+ const commands = [
2622
+ { id:'new', label:tr('newSession'), group:page, enabled:!startPending, run:openStartDialog },
2623
+ { id:'sessions', label:tr('sessionSearch'), group:page, shortcut:shortcutPrefix+'L', run:focusSessionsSearch },
2624
+ { id:'find', label:tr('find.title'), group:page, shortcut:shortcutPrefix+'F', enabled:terminalFindAvailable(), run:() => window.hccTerminalFind.open() },
2625
+ { id:'settings', label:tr('settings'), group:page, run:() => document.getElementById('settingsBtn').click() },
2626
+ { id:'project-add', label:tr('addProject'), group:page, enabled:!document.getElementById('openProjectDialog').disabled, run:() => document.getElementById('openProjectDialog').click() },
2627
+ { id:'refresh', label:tr('commands.refresh'), group:page, run:refreshVisibleData },
2628
+ { id:'focus', label:tr(appEl.classList.contains('focus-mode') ? 'exitFocus' : 'focusMode'), group:page, enabled:!narrowViewport.matches, run:() => focusBtn.click() },
2629
+ { id:'history', label:tr('aux.history'), group:page, run:() => document.getElementById('openHistoryDialog').click() },
2630
+ { id:'review', label:tr('aux.review'), group:page, enabled:!document.getElementById('openReviewDialog').disabled, run:() => document.getElementById('openReviewDialog').click() }
2631
+ ];
2632
+ for (const view of ['overview','tasks','messages','events']) {
2633
+ commands.push({ id:'view:'+view, label:tr('commands.view.'+view), group:page, run:() => {
2634
+ if (narrowViewport.matches) setMobileView('state');
2635
+ else { if (window.hccWorkspace?.isOpen) window.hccWorkspace.close(); appEl.classList.remove('focus-mode'); hccUi.safeSet('hcc.collapse.right','0'); applyCollapseState(); syncFocusButton(); }
2636
+ document.getElementById('stateTab-'+view).click(); document.getElementById('stateTab-'+view).focus();
2637
+ }});
2638
+ }
2639
+ for (const project of projects) {
2640
+ commands.push({ id:'project:'+project.root, label:project.name || project.root, detail:project.root, group:tr('commands.projects'), enabled:project.root!==currentProject, run:() => {
2641
+ document.getElementById('projectSelect').value = project.root;
2642
+ return switchProject(project.root);
2643
+ }});
2644
+ }
2645
+ const root = currentProject;
2646
+ for (const session of sessions) {
2647
+ commands.push({ id:'session:'+root+':'+session.id, label:session.name || session.task?.title || sessionPeerId(session), detail:sessionProvider(session)+' · '+statusText(session.status)+' · '+(session.cwd || ''), keywords:[session.id,sessionPeerId(session),session.task?.title,sessionRuntimeTarget(session),sessionProviderSessionValue(session),session.command].join(' '), group:tr('sessions'), enabled:session.status==='running', run:() => { if (currentProject===root) connectManaged(session.id); }});
2648
+ }
2649
+ for (const peer of detected) {
2650
+ commands.push({ id:'detected:'+root+':'+peer.id, label:peer.name || peer.id, detail:(peer.provider || peer.kind || '')+' · '+(peer.worktree || peer.cwd || ''), group:tr('detected'), run:() => { if (currentProject===root) connectDetected(peer.id); }});
2651
+ }
2652
+ if (paneMode) return commands.filter(command => ['new','sessions','find','settings','history','review','refresh'].includes(command.id) || command.id.startsWith('session:'));
2653
+ commands.splice(3,0,{id:'workspace',label:tr(window.hccWorkspace?.isOpen ? 'workspace.close' : 'workspace.split'),group:page,enabled:!document.getElementById('splitBtn').disabled,run:() => document.getElementById('splitBtn').click()});
2654
+ return commands;
2655
+ }
2656
+ };
2657
+ ${commandPaletteScript()}
2658
+ window.hccWorkspaceHost = {
2659
+ embedded:paneMode, app:appEl, primary:document.getElementById('workspacePrimaryPane'),
2660
+ project:() => currentProject, active:() => active, sessions:() => sessions, ready:() => sessionsLoaded,
2661
+ esc, showTerminal:() => setMobileView('terminal'), layoutChanged:() => { syncPanelAccess(); requestAnimationFrame(resizeTerm); }
2662
+ };
2663
+ ${workspaceScript()}
2664
+ term.attachCustomKeyEventHandler(event => !event.defaultPrevented && !window.hccCommandPalette.captures(event));
2665
+ hccUi.bindControls();
2666
+ window.addEventListener('hcc:preferences', (event) => {
2667
+ if (event.detail.changed.some((key) => key === 'language' || key === 'resolvedLanguage')) applyLanguage();
2668
+ syncFocusButton();
2669
+ syncToggleIcons();
2670
+ applyTerminalPreferences();
2671
+ });
2672
+ applyTerminalPreferences();
2096
2673
  applyLanguage();
2097
2674
  loadProjects().then(() => Promise.all([refreshSessions(), refreshDetected(), refreshState()])).then(() => {
2098
- // Auto-connect to first running managed session on load
2099
- if (!active && !activeDetected) {
2100
- const first = sessions.find(s => s.status === 'running');
2101
- if (first) connectManaged(first.id);
2102
- }
2675
+ if (!active && !activeDetected) restoreSelection();
2103
2676
  }).catch((err) => {
2104
2677
  connText('error');
2105
2678
  console.error(err);
@@ -2112,24 +2685,25 @@ export function webIndexHtml({ nonce } = {}) {
2112
2685
  refreshProjectsQuietly().catch(console.error);
2113
2686
  }, 8000);
2114
2687
 
2115
- stopCancelBtn.addEventListener('click', () => { stopDialog.hidden = true; });
2116
- stopDialog.addEventListener('click', (e) => { if (e.target === stopDialog) stopDialog.hidden = true; });
2117
- document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !stopDialog.hidden) stopDialog.hidden = true; });
2688
+ stopCancelBtn.addEventListener('click', () => closeDialog(stopDialog));
2118
2689
 
2119
2690
  stopConfirmBtn.addEventListener('click', async () => {
2120
- stopDialog.hidden = true;
2121
- const killTmux = stopKillCb.checked;
2122
- if (stopDialog._action === 'detected') {
2123
- const peerId = stopDialog._peerId;
2124
- stopDialog._action = null; stopDialog._peerId = null;
2125
- if (!peerId) return;
2126
- await api('/api/detected/' + encodeURIComponent(peerId) + '/stop', { method: 'POST', body: JSON.stringify({ kill_tmux: killTmux }) });
2127
- await Promise.all([refreshSessions(), refreshDetected()]);
2128
- } else {
2129
- if (!active) return;
2130
- await api('/api/sessions/' + encodeURIComponent(active) + '/stop', { method: 'POST', body: JSON.stringify({ kill_tmux: killTmux }) });
2131
- await refreshSessions();
2132
- }
2691
+ closeDialog(stopDialog);
2692
+ try {
2693
+ const killTmux = stopKillCb.checked;
2694
+ if (stopDialog._action === 'detected') {
2695
+ const peerId = stopDialog._peerId;
2696
+ stopDialog._action = null; stopDialog._peerId = null;
2697
+ if (!peerId) return;
2698
+ await api('/api/detected/' + encodeURIComponent(peerId) + '/stop', { method: 'POST', body: JSON.stringify({ kill_tmux: killTmux }) });
2699
+ await Promise.all([refreshSessions(), refreshDetected()]);
2700
+ } else {
2701
+ if (!active || !canControl()) return;
2702
+ if (stopDialog._sessionId !== active || stopDialog._projectRoot !== currentProject) throw new Error(tr('error.subject_changed'));
2703
+ await api('/api/sessions/' + encodeURIComponent(active) + '/stop', { method: 'POST', body: JSON.stringify({ kill_tmux: killTmux, action_token: sessionActionTokens.get(active) || '', epoch: controlEpoch() }) });
2704
+ await refreshSessions();
2705
+ }
2706
+ } catch (error) { handleUiError(error); }
2133
2707
  });
2134
2708
  </script>
2135
2709
  </body>
@@ -2141,49 +2715,5 @@ export function webIndexHtml({ nonce } = {}) {
2141
2715
  // flow is the token-in-URL exchange performed by the server; this form lets the
2142
2716
  // user re-enter the token once to get a fresh cookie.
2143
2717
  export function webLoginPage({ nonce } = {}) {
2144
- const inlineScriptNonce = nonceAttribute(nonce);
2145
- return `<!doctype html>
2146
- <html lang="en">
2147
- <head>
2148
- <meta charset="utf-8">
2149
- <meta name="viewport" content="width=device-width, initial-scale=1">
2150
- <title>hello-cc — sign in</title>
2151
- <style>
2152
- body { font-family: system-ui, sans-serif; background: #101418; color: #e8eaed; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
2153
- .card { background: #1a2027; border: 1px solid #2c343d; border-radius: 10px; padding: 28px; width: 320px; }
2154
- h1 { font-size: 18px; margin: 0 0 16px; }
2155
- label { display: block; font-size: 13px; margin-bottom: 6px; color: #aab3bd; }
2156
- input { width: 100%; box-sizing: border-box; padding: 9px 10px; border-radius: 6px; border: 1px solid #39424c; background: #101418; color: #e8eaed; font-size: 14px; }
2157
- button { margin-top: 16px; width: 100%; padding: 9px; border-radius: 6px; border: 0; background: #40c4aa; color: #06211b; font-weight: 600; font-size: 14px; cursor: pointer; }
2158
- button:hover { background: #57d4bc; }
2159
- .err { color: #ff8b8b; font-size: 13px; margin-top: 10px; min-height: 18px; }
2160
- </style>
2161
- </head>
2162
- <body>
2163
- <div class="card">
2164
- <h1>hello-cc</h1>
2165
- <label for="tok">Access token</label>
2166
- <input id="tok" type="password" autocomplete="off" placeholder="paste the ?token= value" autofocus>
2167
- <button id="go">Sign in</button>
2168
- <div class="err" id="err"></div>
2169
- </div>
2170
- <script${inlineScriptNonce}>
2171
- const input = document.getElementById('tok');
2172
- const err = document.getElementById('err');
2173
- async function submit() {
2174
- const token = input.value.trim();
2175
- if (!token) return;
2176
- err.textContent = '';
2177
- const res = await fetch('/login', {
2178
- method: 'POST',
2179
- headers: { 'Content-Type': 'application/json' },
2180
- body: JSON.stringify({ token })
2181
- });
2182
- if (res.ok) { location.href = '/'; } else { err.textContent = 'Invalid token.'; }
2183
- }
2184
- document.getElementById('go').addEventListener('click', submit);
2185
- input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
2186
- </script>
2187
- </body>
2188
- </html>`;
2718
+ return renderWebLoginPage({ inlineScriptNonce: nonceAttribute(nonce) });
2189
2719
  }