@logicseek/hello-cc 1.0.5 → 1.1.0-rc.3

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 (146) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/docs/codex-account.zh-CN.md +38 -0
  3. package/docs/codex-protocol.md +59 -0
  4. package/docs/commands.md +9 -0
  5. package/docs/commands.zh-CN.md +7 -0
  6. package/docs/macos-process-identity.md +2 -0
  7. package/docs/native.md +179 -14
  8. package/docs/native.zh-CN.md +136 -6
  9. package/docs/web-handoff.zh-CN.md +118 -8
  10. package/lib/cli/app.mjs +47 -4
  11. package/lib/cli/commands/coordination.mjs +14 -4
  12. package/lib/cli/commands/dsh.mjs +1 -1
  13. package/lib/cli/commands/gc.mjs +22 -15
  14. package/lib/cli/commands/hook.mjs +19 -3
  15. package/lib/cli/commands/install.mjs +80 -10
  16. package/lib/cli/commands/misc.mjs +36 -9
  17. package/lib/cli/commands/native.mjs +4 -38
  18. package/lib/cli/commands/peer.mjs +3 -0
  19. package/lib/cli/commands/run.mjs +13 -6
  20. package/lib/cli/commands/tmux.mjs +1 -2
  21. package/lib/cli-runtime.mjs +19 -2
  22. package/lib/core/coordination/results.mjs +27 -10
  23. package/lib/core/sessions/hook-root-identity.mjs +47 -0
  24. package/lib/core/sessions/launch.mjs +7 -1
  25. package/lib/db/connection.mjs +140 -42
  26. package/lib/db/schema.mjs +59 -0
  27. package/lib/diagnostics/codex-protocol.mjs +105 -0
  28. package/lib/guidance.mjs +21 -36
  29. package/lib/integrations/codex-account.mjs +143 -0
  30. package/lib/integrations/codex-interactions.mjs +13 -11
  31. package/lib/integrations/dsh-collaboration.mjs +6 -12
  32. package/lib/integrations/dsh-cordis.mjs +17 -13
  33. package/lib/integrations/dsh.mjs +16 -11
  34. package/lib/integrations/mcp-elicitation.mjs +116 -0
  35. package/lib/integrations/mcp-url-elicitation.mjs +25 -0
  36. package/lib/integrations/native/claude.mjs +24 -6
  37. package/lib/integrations/native/codex.mjs +38 -7
  38. package/lib/integrations/native/dsh-acp.mjs +67 -24
  39. package/lib/integrations/native/index.mjs +4 -1
  40. package/lib/integrations/native/interactions.mjs +2 -1
  41. package/lib/integrations/native/jsonrpc.mjs +37 -5
  42. package/lib/integrations/native/telemetry.mjs +30 -0
  43. package/lib/process/identity.mjs +4 -0
  44. package/lib/process/pinned-cwd.mjs +302 -0
  45. package/lib/process/pinned-guidance.mjs +138 -0
  46. package/lib/process/pinned-project-state.mjs +105 -0
  47. package/lib/process/selected-cwd-identity.mjs +95 -0
  48. package/lib/runtime/buffer-evidence.mjs +2 -1
  49. package/lib/runtime/client.mjs +9 -0
  50. package/lib/runtime/native/client.mjs +4 -1
  51. package/lib/runtime/native/history.mjs +140 -0
  52. package/lib/runtime/native/launcher.mjs +106 -0
  53. package/lib/runtime/native/service.mjs +64 -8
  54. package/lib/runtime/native/store.mjs +83 -58
  55. package/lib/runtime/paths.mjs +17 -2
  56. package/lib/runtime/private-file.mjs +76 -0
  57. package/lib/runtime/private-state.mjs +546 -0
  58. package/lib/runtime/project-context.mjs +2 -1
  59. package/lib/runtime/project-path.mjs +92 -20
  60. package/lib/runtime/project-trust.mjs +79 -0
  61. package/lib/runtime/projects.mjs +9 -3
  62. package/lib/runtime/state-migration.mjs +697 -0
  63. package/lib/runtime/state.mjs +69 -27
  64. package/lib/shared/automatic-result-key.mjs +6 -0
  65. package/lib/shared/json-file.mjs +55 -6
  66. package/lib/shared/redact.mjs +2 -1
  67. package/lib/ui/help.mjs +6 -4
  68. package/lib/web/agent-defaults.mjs +110 -0
  69. package/lib/web/auto-attach.mjs +3 -2
  70. package/lib/web/browser/contracts.d.ts +10 -0
  71. package/lib/web/browser/core.mjs +1997 -0
  72. package/lib/web/browser/project-data.mjs +158 -0
  73. package/lib/web/browser/session-sync.mjs +82 -0
  74. package/lib/web/browser-assets.mjs +19 -0
  75. package/lib/web/buffer-gc-runtime.mjs +2 -2
  76. package/lib/web/codex-app-server.mjs +111 -37
  77. package/lib/web/codex-sessions.mjs +61 -12
  78. package/lib/web/context-files.mjs +82 -0
  79. package/lib/web/cookie-auth.mjs +51 -3
  80. package/lib/web/external-sessions.mjs +76 -9
  81. package/lib/web/http-routes.mjs +238 -25
  82. package/lib/web/http.mjs +50 -4
  83. package/lib/web/liveness-reaper.mjs +2 -2
  84. package/lib/web/native-sessions.mjs +323 -14
  85. package/lib/web/project-contexts.mjs +236 -44
  86. package/lib/web/project-file-write.mjs +249 -0
  87. package/lib/web/project-files.mjs +268 -0
  88. package/lib/web/pty-sessions.mjs +11 -3
  89. package/lib/web/runtime-main.mjs +132 -80
  90. package/lib/web/session-serialize.mjs +87 -8
  91. package/lib/web/session-state-sync.mjs +98 -0
  92. package/lib/web/shutdown-diagnostics.mjs +38 -0
  93. package/lib/web/shutdown-response.mjs +21 -0
  94. package/lib/web/shutdown-state.mjs +91 -0
  95. package/lib/web/startup.mjs +103 -35
  96. package/lib/web/task-results.mjs +19 -19
  97. package/lib/web/tls.mjs +84 -33
  98. package/lib/web/tmux-sessions.mjs +73 -9
  99. package/lib/web/tmux-stream.mjs +36 -8
  100. package/lib/web/ui-agent-defaults.mjs +149 -0
  101. package/lib/web/ui-agent-start.mjs +330 -0
  102. package/lib/web/ui-codex-account.mjs +81 -0
  103. package/lib/web/ui-codex.mjs +105 -85
  104. package/lib/web/ui-command-palette.mjs +21 -5
  105. package/lib/web/ui-file-writes.mjs +220 -0
  106. package/lib/web/ui-files.mjs +231 -0
  107. package/lib/web/ui-handoff.mjs +10 -2
  108. package/lib/web/ui-history.mjs +175 -105
  109. package/lib/web/ui-i18n.mjs +280 -8
  110. package/lib/web/ui-interactions.mjs +107 -6
  111. package/lib/web/ui-native-timeline.mjs +326 -0
  112. package/lib/web/ui-native.mjs +93 -43
  113. package/lib/web/ui-review.mjs +5 -3
  114. package/lib/web/ui-safe-markdown.mjs +79 -0
  115. package/lib/web/ui-session-tools.mjs +224 -0
  116. package/lib/web/ui-template.mjs +29 -1968
  117. package/lib/web/ui-terminal-find.mjs +5 -3
  118. package/lib/web/ui-workbench.mjs +5 -3
  119. package/lib/web/ui-workspace.mjs +3 -0
  120. package/package.json +8 -2
  121. package/scripts/benchmark-session-sync.mjs +53 -0
  122. package/scripts/codex-protocol.mjs +88 -0
  123. package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
  124. package/scripts/dsh-package-provenance.mjs +118 -0
  125. package/scripts/mcp-form-live-fixture.mjs +81 -0
  126. package/scripts/native-bounded-live-acceptance.mjs +193 -0
  127. package/scripts/native-fixture-root.mjs +49 -0
  128. package/scripts/native-installed-acceptance.mjs +611 -0
  129. package/scripts/native-stability-checks.mjs +40 -0
  130. package/scripts/regression-cookie-expiry.mjs +70 -0
  131. package/scripts/regression-http-transport.mjs +9 -0
  132. package/scripts/regression-terminal-marker.mjs +95 -0
  133. package/scripts/regression.mjs +481 -219
  134. package/scripts/shim-reentry-probe.mjs +105 -0
  135. package/scripts/web-acceptance-diagnostics.mjs +90 -0
  136. package/scripts/web-codex-account-acceptance.mjs +178 -0
  137. package/scripts/web-file-preview-checks.mjs +302 -0
  138. package/scripts/web-mcp-form-acceptance.mjs +134 -0
  139. package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
  140. package/scripts/web-native-handoff-checks.mjs +128 -0
  141. package/scripts/web-native-interaction-acceptance.mjs +219 -18
  142. package/scripts/web-native-interaction-diagnostics.mjs +80 -0
  143. package/scripts/web-native-model-acceptance.mjs +22 -7
  144. package/scripts/web-session-tools-checks.mjs +112 -0
  145. package/scripts/web-workbench-acceptance.mjs +435 -0
  146. package/scripts/web-workbench-installed-acceptance.mjs +122 -0
@@ -1,17 +1,17 @@
1
- import { API_VERSION } from './api-version.mjs';
2
- import { uiPreferencesCss, uiPreferencesScript, preferenceControlsHtml, TERMINAL_THEMES } from './ui-preferences.mjs';
1
+ import { agentStartHtml } from './ui-agent-start.mjs';
2
+ import { agentDefaultsCss, agentDefaultsHtml } from './ui-agent-defaults.mjs';
3
+ import { filesCss, filesHtml } from './ui-files.mjs';
4
+ import { uiPreferencesCss, uiPreferencesScript, preferenceControlsHtml } from './ui-preferences.mjs';
3
5
  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';
13
-
14
- const SHELL_APOSTROPHE_ESCAPE = JSON.stringify(`'"'"'`);
6
+ import { handoffCss, handoffBarHtml, terminalComposerHtml } from './ui-handoff.mjs';
7
+ import { codexPanelHtml } from './ui-codex.mjs';
8
+ import { workbenchCss, workbenchControlsHtml } from './ui-workbench.mjs';
9
+ import { codexHistoryHtml } from './ui-history.mjs';
10
+ import { auxiliaryPanelsCss, reviewPanelHtml } from './ui-review.mjs';
11
+ import { nativePanelHtml } from './ui-native.mjs';
12
+ import { commandPaletteCss, commandPaletteHtml } from './ui-command-palette.mjs';
13
+ import { terminalFindCss, terminalFindHtml } from './ui-terminal-find.mjs';
14
+ import { workspaceCss, workspaceBarHtml, workspaceSecondaryHtml } from './ui-workspace.mjs';
15
15
 
16
16
  function nonceAttribute(nonce) {
17
17
  const value = String(nonce || '');
@@ -250,7 +250,12 @@ export function webIndexHtml({ nonce, pane = false } = {}) {
250
250
  .dialog-heading button { width: 36px; height: 36px; padding: 0; font-size: 20px; }
251
251
  .dialog-help { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
252
252
  .dialog .form { padding: 0; border: 0; gap: 14px; }
253
+ #agentLaunchFields { display:grid; gap:14px; }
254
+ #agentLaunchFields[hidden] { display:none; }
255
+ #historyDialog [hidden] { display:none; }
253
256
  #startForm [hidden], .dialog-help[hidden] { display: none; }
257
+ #agentAdvanced summary { cursor: pointer; padding: 8px 0; }
258
+ #agentAdvanced > :not(summary) { margin-top: 10px; }
254
259
  .dialog-help[role="alert"] { color: var(--danger); }
255
260
  .dialog-target { font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
256
261
  .session-empty { display: grid; gap: 12px; text-align: center; }
@@ -606,15 +611,18 @@ export function webIndexHtml({ nonce, pane = false } = {}) {
606
611
  @media (max-width: 480px) {
607
612
  .global-header { padding: 0 10px; }
608
613
  .global-header > strong { font-size: 15px; }
609
- .header-controls { gap: 6px; }
614
+ .header-controls { gap: 6px; flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: thin; }
615
+ .header-controls > * { flex-shrink: 0; }
610
616
  .global-header #connState { max-width: 88px; overflow: hidden; text-overflow: ellipsis; }
611
617
  .toolbar { flex-wrap: wrap; padding: 8px 10px; }
612
618
  .toolbar .title { flex-basis: 100%; }
613
- .quick { margin-left: auto; }
619
+ .quick { margin-left: auto; max-width:100%; flex-wrap:wrap; justify-content:flex-end; }
614
620
  .settings-fields label { grid-template-columns: 1fr; gap: 6px; }
615
621
  }
616
622
  @media (prefers-reduced-motion: reduce) { .app, .edge-toggle, .edge-resizer { transition: none; } }
617
623
  ${workspaceCss}
624
+ ${filesCss}
625
+ ${agentDefaultsCss}
618
626
  </style>
619
627
  </head>
620
628
  <body>
@@ -622,6 +630,7 @@ export function webIndexHtml({ nonce, pane = false } = {}) {
622
630
  <strong>hello-cc</strong>
623
631
  <div class="header-controls">
624
632
  <span id="connState" role="status">offline</span>
633
+ <button id="filesBtn" type="button" data-i18n="files.button" aria-haspopup="dialog" aria-controls="filesDialog">Files</button>
625
634
  <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>
626
635
  <button id="splitBtn" type="button" data-i18n="workspace.split" aria-pressed="false" disabled>Split</button>
627
636
  <button id="focusBtn" type="button" data-i18n="focusMode" aria-pressed="false">Focus</button>
@@ -647,7 +656,7 @@ export function webIndexHtml({ nonce, pane = false } = {}) {
647
656
  <label><span data-i18n="project">Project</span><select id="projectSelect"></select></label>
648
657
  <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>
649
658
  </div>
650
- <button id="openStartDialog" class="primary" type="button" data-i18n="newSession" aria-haspopup="dialog" aria-controls="startDialog">New session</button>
659
+ <button id="openStartDialog" class="primary" type="button" data-i18n="newSession" aria-haspopup="dialog" aria-controls="startDialog">New Agent</button>
651
660
  <button id="openHistoryDialog" type="button" data-i18n="aux.history" aria-haspopup="dialog" aria-controls="historyDialog">Codex history</button>
652
661
  </div>
653
662
  <div class="session-header">
@@ -670,7 +679,6 @@ export function webIndexHtml({ nonce, pane = false } = {}) {
670
679
  <div class="title">
671
680
  <strong id="activeTitle">No session selected</strong>
672
681
  <span class="path" id="activeMeta">Start or select a session from the left panel</span>
673
- <span id="activeTask" hidden></span>
674
682
  </div>
675
683
  <div class="quick" id="quickBar">
676
684
  <button id="openReviewDialog" type="button" data-i18n="aux.review" aria-haspopup="dialog" aria-controls="reviewDialog" disabled>Review results</button>
@@ -737,30 +745,13 @@ export function webIndexHtml({ nonce, pane = false } = {}) {
737
745
  </form>
738
746
  </div>
739
747
  </div>
740
- <div class="dialog-overlay" id="startDialog" role="dialog" aria-modal="true" aria-labelledby="startDialogTitle" hidden>
741
- <div class="dialog">
742
- <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>
743
- <p class="dialog-help" data-i18n="startDialogHelp">Choose a CLI and start a new session or resume existing history.</p>
744
- <p class="dialog-help" id="startDialogError" role="alert" hidden></p>
745
- <div class="dialog-target"><span data-i18n="startProject">Target project</span>: <span id="startProjectPath"></span></div>
746
- <form class="form" id="startForm">
747
- <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>
748
- <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>
749
- <div class="start-options">
750
- <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>
751
- <label data-resume-field><span data-i18n="session">Session</span><select id="resumeSelect"></select></label>
752
- <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>
753
- </div>
754
- <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>
755
- <p class="dialog-help" data-i18n="dshLaunchHelp">For DeepSeek Harness, run hcc dsh web in the project. Its agents appear under Detected.</p>
756
- <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>
757
- </form>
758
- </div>
759
- </div>
748
+ ${agentStartHtml()}
749
+ ${filesHtml()}
760
750
  <div class="dialog-overlay" id="settingsDialog" role="dialog" aria-modal="true" aria-labelledby="settingsTitle" hidden>
761
751
  <div class="dialog settings-dialog">
762
752
  <header><h3 id="settingsTitle" data-i18n="settings">Settings</h3><button id="settingsClose" type="button" data-i18n-aria="close" aria-label="Close">×</button></header>
763
753
  <div class="settings-fields">${preferenceControlsHtml({ terminal: true })}</div>
754
+ ${agentDefaultsHtml()}
764
755
  </div>
765
756
  </div>
766
757
  <div class="dialog-overlay" id="stopDialog" role="dialog" aria-modal="true" aria-labelledby="stopDialogTitle" hidden>
@@ -781,1937 +772,7 @@ export function webIndexHtml({ nonce, pane = false } = {}) {
781
772
  <script src="/assets/xterm.js"></script>
782
773
  <script src="/assets/addon-fit.js"></script>
783
774
  <script src="/assets/addon-search.js"></script>
784
- <script${inlineScriptNonce}>
785
- ${uiHandoffScript()}
786
- ${workbenchScript()}
787
- const hccUi = window.hccUi;
788
- const paneMode = ${pane ? 'true' : 'false'};
789
- const handoffStore = window.hccHandoffStore;
790
- const initialParams = new URLSearchParams(location.search);
791
- let loggedOut = false;
792
- try { sessionStorage.removeItem('hcc_logged_out'); } catch {}
793
- const token = initialParams.get('token') || '';
794
- const runtimeApiVersion = ${API_VERSION};
795
- const headers = {
796
- 'X-HCC-API-Version': String(runtimeApiVersion),
797
- ...(token ? { Authorization: 'Bearer ' + token } : {})
798
- };
799
- let currentProject = initialParams.get('project') || initialParams.get('root') || '';
800
- let projects = [];
801
- let startPending = false;
802
- let sessionKindFilter = initialParams.get('kind') || 'all';
803
- let sessionSearchQuery = '';
804
- let sessionStatusFilter = 'all';
805
- let sessions = []; // managed (PTY) sessions
806
- let sessionsLoaded = false;
807
- let detected = []; // coordination-only peers (from hooks/watcher)
808
- let resumableCache = []; // provider sessions available to resume (from /api/resumable)
809
- // The current token belongs to one terminal WebSocket connection and is
810
- // replaced on every reconnect.
811
- const sessionActionTokens = new Map();
812
- const sessionControls = new Map();
813
- let activeConnectionState = 'offline';
814
- let activeLocalClients = null;
815
- let draftReceipt = false;
816
- let lastControlNotice = 0;
817
- let active = null; // active managed session id
818
- let activeDetected = null; // active detected peer id
819
- let activeType = 'managed'; // 'managed' | 'detected'
820
- let showStaleDetected = hccUi.safeGet('hcc.showStaleDetected') === '1';
821
- let activePeerTtl = 600;
822
- let lastStateNow = 0;
823
- let lastStateRoot = '';
824
- let lastStateData = null;
825
- let lastActionResult = null;
826
- let ws = null;
827
- let wsReconnectTimer = null;
828
- let lastSentTerminalSize = null;
829
- // ui-4: after logout (or repeated failed reconnects) the WS must not loop
830
- // forever every 2s against a server that now rejects the upgrade.
831
- let wsReconnectFailures = 0;
832
- let terminalHasContent = false;
833
- let terminalLastDataAt = 0;
834
- let terminalLastResizeAt = 0;
835
- let terminalLastReplaceAt = 0;
836
- let autoPollInFlight = false;
837
- let projectPollInFlight = false;
838
- let lang = hccUi.language;
839
-
840
- function tr(key, fallback = '') {
841
- return hccUi.tr(key, fallback);
842
- }
843
-
844
- function setText(id, key) {
845
- const el = document.getElementById(id);
846
- if (el) el.textContent = tr(key);
847
- }
848
-
849
- function connText(key) {
850
- const el = document.getElementById('connState');
851
- if (el) {
852
- el.dataset.stateKey = key;
853
- el.textContent = tr('conn.' + key);
854
- }
855
- }
856
-
857
- function applyLanguage() {
858
- lang = hccUi.language;
859
- document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en';
860
- hccUi.translate();
861
- if (!active && !activeDetected) {
862
- setText('activeTitle', 'noSessionSelected');
863
- setText('activeMeta', 'startOrSelect');
864
- }
865
- const stateKey = document.getElementById('connState')?.dataset.stateKey || 'offline';
866
- connText(stateKey);
867
- syncStartModeOptions();
868
- renderSections();
869
- renderHandoff();
870
- if (activeType === 'detected' && activeDetected) {
871
- const draft = document.getElementById('detMsg')?.value || '';
872
- const peer = detected.find((p) => p.id === activeDetected) || { id: activeDetected };
873
- document.getElementById('activeTitle').textContent = activeDetected + ' (' + tr('detected') + ')';
874
- renderDetectedPanel(peer);
875
- const detMsg = document.getElementById('detMsg');
876
- if (detMsg && draft) detMsg.value = draft;
877
- } else if (activeType === 'managed' && active) {
878
- const meta = sessions.find((s) => s.id === active);
879
- if (meta) {
880
- renderActiveSession(meta);
881
- }
882
- }
883
- if (lastStateData) renderState(lastStateData);
884
- if (lastActionResult && !document.getElementById('actionResult').hidden) showActionResult(lastActionResult);
885
- if (!stopDialog.hidden) updateStopDialogTitle();
886
- syncFocusButton();
887
- syncToggleIcons();
888
- }
889
-
890
- function requestQuery(extra = {}) {
891
- const params = new URLSearchParams();
892
- if (token) params.set('token', token);
893
- if (currentProject) params.set('root', currentProject);
894
- for (const [key, value] of Object.entries(extra)) {
895
- if (value !== undefined && value !== null && value !== '') params.set(key, value);
896
- }
897
- const text = params.toString();
898
- return text ? '?' + text : '';
899
- }
900
-
901
- function updateLocationProject() {
902
- const params = new URLSearchParams(location.search);
903
- if (token) params.set('token', token);
904
- if (currentProject) params.set('project', currentProject);
905
- if (!paneMode) params.delete('session');
906
- if (sessionKindFilter && sessionKindFilter !== 'all') params.set('kind', sessionKindFilter);
907
- history.replaceState(null, '', location.pathname + '?' + params.toString());
908
- }
909
-
910
- const term = new Terminal({
911
- // Search match decorations use xterm's proposed decoration API.
912
- allowProposedApi: true,
913
- cursorBlink: true,
914
- cursorInactiveStyle: 'outline',
915
- cursorStyle: 'bar',
916
- convertEol: true,
917
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
918
- fontSize: hccUi.preferences.fontSize,
919
- lineHeight: 1.15,
920
- theme: ${JSON.stringify(TERMINAL_THEMES)}[hccUi.preferences.terminalTheme === 'system' ? hccUi.theme : hccUi.preferences.terminalTheme]
921
- });
922
- const fitAddon = new FitAddon.FitAddon();
923
- term.loadAddon(fitAddon);
924
- const searchAddon = new SearchAddon.SearchAddon({ highlightLimit: 1000 });
925
- term.loadAddon(searchAddon);
926
- term.open(document.getElementById('terminal'));
927
-
928
- function resizeTerm() {
929
- const el = document.getElementById('terminal');
930
- if (!el.getClientRects().length || el.clientWidth <= 16 || el.clientHeight <= 16) return;
931
- const size = fitAddon.proposeDimensions();
932
- if (!size || !Number.isFinite(size.cols) || !Number.isFinite(size.rows)) return;
933
- const { cols, rows } = size;
934
- term.resize(cols, rows);
935
- const actionToken = sessionActionTokens.get(active) || '';
936
- if (ws && ws.readyState === WebSocket.OPEN && actionToken && canControl() &&
937
- (!lastSentTerminalSize || lastSentTerminalSize.socket !== ws || lastSentTerminalSize.cols !== cols || lastSentTerminalSize.rows !== rows)) {
938
- lastSentTerminalSize = { socket: ws, cols, rows };
939
- terminalLastResizeAt = Date.now();
940
- ws.send(JSON.stringify({ type: 'resize', cols, rows, action_token: actionToken, epoch: controlEpoch() }));
941
- }
942
- }
943
- window.addEventListener('resize', resizeTerm);
944
- new ResizeObserver(() => resizeTerm()).observe(document.getElementById('terminal'));
945
- document.fonts?.ready.then(() => resizeTerm());
946
- setTimeout(resizeTerm, 80);
947
-
948
- function applyTerminalPreferences() {
949
- const palette = ${JSON.stringify(TERMINAL_THEMES)};
950
- const theme = hccUi.preferences.terminalTheme === 'system' ? hccUi.theme : hccUi.preferences.terminalTheme;
951
- term.options.theme = palette[theme];
952
- term.options.fontSize = hccUi.preferences.fontSize;
953
- document.getElementById('terminal').style.backgroundColor = palette[theme].background;
954
- requestAnimationFrame(() => resizeTerm());
955
- }
956
-
957
- term.onData((data) => {
958
- sendTerminalInput(data);
959
- });
960
-
961
- function sendTerminalInput(data, inputId = '') {
962
- if (sessions.find(item => item.id === active)?.type === 'native' || sessions.find(item => item.id === active)?.type === 'app-server') return false;
963
- if (!active || !ws || ws.readyState !== WebSocket.OPEN || !canControl()) {
964
- if (Date.now() - lastControlNotice > 3000) {
965
- lastControlNotice = Date.now();
966
- document.getElementById('handoffStatus').textContent = tr('handoff.controlRequired');
967
- }
968
- return false;
969
- }
970
- const actionToken = sessionActionTokens.get(active) || '';
971
- if (!actionToken) return false;
972
- ws.send(JSON.stringify({ type: 'input', data, action_token: actionToken, epoch: controlEpoch(), ...(inputId ? { input_id: inputId } : {}) }));
973
- return true;
974
- }
975
-
976
- async function api(path, options = {}) {
977
- const res = await fetch(path + (path.includes('?') ? '&' : '?') + requestQuery().slice(1), {
978
- ...options,
979
- headers: { 'Content-Type': 'application/json', ...headers, ...(options.headers || {}) }
980
- }).catch((cause) => {
981
- const error = new Error(tr('networkError'));
982
- error.detail = cause.message;
983
- throw error;
984
- });
985
- const json = await res.json();
986
- if (!res.ok) {
987
- const error = new Error(tr('error.' + json.error?.code, tr('requestFailed')));
988
- error.code = json.error?.code;
989
- error.detail = json.error?.message || json.message || error.message;
990
- throw error;
991
- }
992
- return json;
993
- }
994
-
995
- function controlEpoch() { return Number(sessionControls.get(active)?.epoch || 0); }
996
- function canControl() {
997
- return Boolean(active && sessions.find((item) => item.id === active)?.status === 'running' && ws?.readyState === WebSocket.OPEN && sessionActionTokens.get(active) && sessionControls.get(active)?.can_control);
998
- }
999
-
1000
- function renderActiveSession(meta) {
1001
- document.getElementById('activeTitle').textContent = sessionPeerId(meta) || active || '';
1002
- document.getElementById('activeMeta').textContent = sessionMetaText(meta);
1003
- const task = document.getElementById('activeTask');
1004
- task.hidden = !meta?.task?.title;
1005
- task.textContent = meta?.task?.title ? tr('handoff.task') + ' #' + meta.task.id + ': ' + meta.task.title + (meta.task.status ? ' · ' + statusText(meta.task.status) : '') : '';
1006
- }
1007
-
1008
- function renderDraftState() {
1009
- const value = active ? handoffStore.draft(currentProject, active) : { text: '', pending: null };
1010
- const note = document.getElementById('draftStatus');
1011
- note.textContent = tr(value.pending ? value.pending.status === 'uncertain' ? 'handoff.uncertain' : 'handoff.pending' : draftReceipt ? 'handoff.received' : 'handoff.saved');
1012
- note.title = tr('handoff.storageHint');
1013
- document.getElementById('reviewDraftBtn').hidden = value.pending?.status !== 'uncertain';
1014
- document.getElementById('sendDraftBtn').disabled = !canControl() || !value.text || Boolean(value.pending);
1015
- }
1016
-
1017
- function renderHandoff() {
1018
- document.getElementById('handoffBar').hidden = !active;
1019
- const control = sessionControls.get(active) || {};
1020
- const writable = canControl();
1021
- term.options.disableStdin = !writable;
1022
- const copy = (en, zh) => lang === 'zh' ? zh : en;
1023
- const connection = document.getElementById('handoffConnection');
1024
- connection.textContent = copy('Web: ', '连接:') + (activeConnectionState === 'connected' ? copy('Connected', '已连接') : activeConnectionState === 'connecting' ? copy('Connecting…', '连接中…') : copy('Disconnected', '已断开'));
1025
- connection.dataset.tone = activeConnectionState === 'connected' ? 'ok' : 'warn';
1026
- const meta = sessions.find((item) => item.id === active);
1027
- const processState = document.getElementById('handoffProcess');
1028
- processState.textContent = copy('Process: ', '进程:') + statusText(meta?.status || 'unknown');
1029
- processState.dataset.tone = meta?.status === 'running' ? 'ok' : meta?.status === 'exited' ? 'danger' : '';
1030
- const access = document.getElementById('handoffAccess');
1031
- 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', '只读');
1032
- access.dataset.tone = writable ? 'ok' : control.has_controller ? 'warn' : '';
1033
- document.getElementById('handoffHelp').textContent = copy('Attachment details', '连接与离开说明');
1034
- const claim = document.getElementById('claimControlBtn');
1035
- claim.hidden = writable;
1036
- claim.disabled = activeConnectionState !== 'connected' || !sessionActionTokens.get(active);
1037
- claim.textContent = tr(control.has_controller ? 'handoff.takeover' : 'handoff.claim');
1038
- document.getElementById('releaseControlBtn').hidden = !writable;
1039
- const reconnect = document.getElementById('reconnectBtn');
1040
- reconnect.disabled = !active || activeConnectionState === 'connecting';
1041
- reconnect.hidden = activeConnectionState === 'connected';
1042
- const clients = activeLocalClients ?? meta?.local_clients;
1043
- const count = typeof clients === 'number' ? clients : Array.isArray(clients) ? clients.length : Number(clients?.count || 0);
1044
- 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');
1045
- const stop = document.getElementById('stopBtn');
1046
- stop.hidden = meta?.type === 'native';
1047
- stop.disabled = !writable;
1048
- stop.dataset.i18n = meta?.type === 'tmux' ? 'handoff.detach' : 'handoff.terminate';
1049
- stop.textContent = tr(stop.dataset.i18n);
1050
- document.querySelectorAll('[data-terminal-action]').forEach((button) => { button.disabled = !writable || meta?.type === 'app-server' || meta?.type === 'native'; });
1051
- document.querySelectorAll('[data-action]').forEach((button) => {
1052
- if (!['status', 'state', 'inbox'].includes(button.dataset.action)) button.disabled = !writable;
1053
- });
1054
- renderDraftState();
1055
- window.hccCodex?.sync();
1056
- window.hccNative?.sync();
1057
- window.hccHistory?.sync();
1058
- window.hccReview?.sync();
1059
- window.hccTerminalFind?.sync();
1060
- window.hccWorkspace?.sync();
1061
- document.getElementById('openReviewDialog').disabled = !meta;
1062
- }
1063
-
1064
- function disconnectWebSocket() {
1065
- clearTimeout(wsReconnectTimer);
1066
- const previous = ws;
1067
- ws = null;
1068
- if (active) {
1069
- handoffStore.uncertain(currentProject, active);
1070
- sessionActionTokens.delete(active);
1071
- sessionControls.delete(active);
1072
- }
1073
- activeConnectionState = 'offline';
1074
- lastSentTerminalSize = null;
1075
- previous?.close();
1076
- }
1077
-
1078
- function restoreSelection() {
1079
- const explicit = initialParams.get('session');
1080
- initialParams.delete('session');
1081
- const selected = explicit ? { type: 'managed', id: explicit } : handoffStore.selected(currentProject);
1082
- if (selected) {
1083
- if (selected.type === 'managed' && sessions.some((item) => item.id === selected.id && item.status === 'running')) return connectManaged(selected.id);
1084
- if (selected.type === 'detected' && detected.some((item) => item.id === selected.id)) return connectDetected(selected.id);
1085
- document.getElementById('activeMeta').textContent = tr('handoff.missingSelection');
1086
- return;
1087
- }
1088
- const first = sessions.find((item) => item.status === 'running');
1089
- if (first) connectManaged(first.id);
1090
- }
1091
-
1092
- window.hccHandoff = {
1093
- get active() { return active; },
1094
- get projectRoot() { return currentProject; },
1095
- get draftScope() { return window.hccDraftScope || ''; },
1096
- get actionToken() { return sessionActionTokens.get(active) || ''; },
1097
- get epoch() { return controlEpoch(); },
1098
- get canControl() { return canControl(); },
1099
- get session() { return sessions.find(item => item.id === active) || null; },
1100
- get sessions() { return sessions; },
1101
- refreshSessions: () => refreshSessions(), openManaged: id => connectManaged(id), openDialog, closeDialog,
1102
- api: (...args) => api(...args), tr, esc
1103
- };
1104
- ${codexPanelScript()}
1105
- ${codexHistoryScript()}
1106
- ${reviewPanelScript()}
1107
- ${nativePanelScript()}
1108
- document.getElementById('claimControlBtn').addEventListener('click', () => {
1109
- if (ws?.readyState !== WebSocket.OPEN) return;
1110
- const target = active, socket = ws, epoch = controlEpoch();
1111
- const takeover = Boolean(sessionControls.get(active)?.has_controller);
1112
- if (takeover && !confirm(lang === 'zh' ? '接管后,其他浏览器窗口将转为只读。当前任务继续运行。是否接管?' : 'Taking control makes the other browser window read only. The current task keeps running. Continue?')) return;
1113
- if (active !== target || ws !== socket) return;
1114
- socket.send(JSON.stringify({ type: 'control', action: 'claim', action_token: sessionActionTokens.get(target) || '', epoch, force: takeover }));
1115
- });
1116
- document.getElementById('releaseControlBtn').addEventListener('click', () => {
1117
- if (!canControl()) return;
1118
- ws.send(JSON.stringify({ type: 'control', action: 'release', action_token: sessionActionTokens.get(active) || '', epoch: controlEpoch() }));
1119
- });
1120
- document.getElementById('reconnectBtn').addEventListener('click', () => {
1121
- if (!active) return;
1122
- const id = active;
1123
- disconnectWebSocket();
1124
- wsReconnectFailures = 0;
1125
- openWs(id);
1126
- });
1127
- document.getElementById('terminalDraft').addEventListener('input', (event) => {
1128
- if (!active) return;
1129
- draftReceipt = false;
1130
- handoffStore.saveDraft(currentProject, active, event.target.value);
1131
- renderDraftState();
1132
- });
1133
- function submitTerminalDraft(event) {
1134
- event.preventDefault();
1135
- if (!active || !canControl()) return;
1136
- const textarea = document.getElementById('terminalDraft');
1137
- const text = textarea.value;
1138
- if (!text || handoffStore.draft(currentProject, active).pending) return;
1139
- const inputId = window.crypto?.randomUUID?.() || 'input-' + Date.now() + '-' + Math.random().toString(36).slice(2);
1140
- handoffStore.saveDraft(currentProject, active, text);
1141
- handoffStore.submit(currentProject, active, inputId, text);
1142
- try {
1143
- if (!sendTerminalInput(text + '\\r', inputId)) handoffStore.uncertain(currentProject, active);
1144
- } catch {
1145
- handoffStore.uncertain(currentProject, active);
1146
- }
1147
- renderDraftState();
1148
- }
1149
- document.getElementById('terminalComposer').addEventListener('submit', submitTerminalDraft);
1150
- document.getElementById('terminalDraft').addEventListener('keydown', (event) => {
1151
- if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') submitTerminalDraft(event);
1152
- });
1153
- document.getElementById('reviewDraftBtn').addEventListener('click', () => {
1154
- if (!active) return;
1155
- handoffStore.review(currentProject, active);
1156
- draftReceipt = false;
1157
- renderDraftState();
1158
- });
1159
- window.addEventListener('pagehide', () => disconnectWebSocket());
1160
-
1161
- function handleUiError(error, action = '') {
1162
- showActionResult({ action, peer: activePeerInfo()?.peerId || '', error: error.detail || error.message, errorCode: error.code });
1163
- }
1164
-
1165
- function renderProjects() {
1166
- const select = document.getElementById('projectSelect');
1167
- select.innerHTML = projects.map((p) =>
1168
- '<option value="' + esc(p.root) + '">' + esc((p.name || p.root) + ' · ' + p.root) + '</option>'
1169
- ).join('');
1170
- if (currentProject) select.value = currentProject;
1171
- document.getElementById('sessionKindFilter').value = sessionKindFilter;
1172
- window.hccCommandPalette?.refresh();
1173
- }
1174
-
1175
- function kindMatches(item) {
1176
- const kind = ['claude', 'codex', 'dsh', 'shell'].includes(item.kind) ? item.kind : 'other';
1177
- return sessionKindFilter === 'all' || kind === sessionKindFilter;
1178
- }
1179
-
1180
- function syncStartModeOptions() {
1181
- const kind = document.getElementById('kind').value;
1182
- document.getElementById('transportField').hidden = kind !== 'codex';
1183
- const appServer = kind === 'codex' && document.getElementById('transport').value === 'app-server';
1184
- const modeSelect = document.getElementById('startMode');
1185
- const current = modeSelect.value;
1186
- const modes = kind === 'claude'
1187
- ? [['new', 'new'], ['resume', 'resume'], ['continue', 'continue']]
1188
- : kind === 'codex'
1189
- ? (appServer ? [['new', 'new'], ['resume', 'resume']] : [['new', 'new'], ['resume', 'resume'], ['last', 'last']])
1190
- : [['new', 'new']];
1191
- modeSelect.innerHTML = modes.map(([value]) => '<option value="' + value + '">' + tr('mode.' + value, value) + '</option>').join('');
1192
- modeSelect.value = modes.some(([value]) => value === current) ? current : 'new';
1193
- const isResume = modeSelect.value === 'resume';
1194
- document.getElementById('appServerResumeField').hidden = !(appServer && isResume);
1195
- document.getElementById('resumeArg').required = false;
1196
- document.querySelector('[data-resume-field]:not([data-resume-custom])').style.display = isResume ? '' : 'none';
1197
- if (isResume) loadResumable();
1198
- else document.querySelector('[data-resume-custom]').style.display = 'none';
1199
- }
1200
-
1201
- // Fetch provider sessions hcc knows about and fill the resume dropdown.
1202
- async function loadResumable() {
1203
- try { const d = await api('/api/resumable'); resumableCache = d.resumable || []; }
1204
- catch { resumableCache = []; }
1205
- populateResumeSelect();
1206
- }
1207
- function populateResumeSelect() {
1208
- const kind = document.getElementById('kind').value;
1209
- const sel = document.getElementById('resumeSelect');
1210
- const prev = sel.value;
1211
- const items = resumableCache.filter((r) => r.provider === kind);
1212
- const opts = items.map((r) => {
1213
- const resume = r.resume || r.session_id || r.session_name || '';
1214
- const shortResume = resume.length > 14 ? resume.slice(0, 10) + '…' : resume;
1215
- const label = (r.name && r.name !== resume ? r.name + ' · ' : '') + shortResume + ' (' + r.peer + ')';
1216
- return '<option value="' + esc(resume) + '">' + esc(label) + '</option>';
1217
- });
1218
- opts.push('<option value="__custom__">' + esc(tr('customSession')) + '</option>');
1219
- sel.innerHTML = opts.join('');
1220
- if (prev && [...sel.options].some((o) => o.value === prev)) sel.value = prev;
1221
- toggleResumeCustom();
1222
- }
1223
- function toggleResumeCustom() {
1224
- const sel = document.getElementById('resumeSelect');
1225
- const custom = document.getElementById('startMode').value === 'resume' && sel.value === '__custom__';
1226
- document.querySelector('[data-resume-custom]').style.display = custom ? '' : 'none';
1227
- document.getElementById('resumeArg').required = custom;
1228
- }
1229
-
1230
- async function loadProjects() {
1231
- const data = await api('/api/projects');
1232
- projects = data.projects || [];
1233
- if (!currentProject) currentProject = data.current?.root || projects[0]?.root || '';
1234
- renderProjects();
1235
- updateLocationProject();
1236
- }
1237
-
1238
- async function switchProject(root) {
1239
- if (paneMode && root !== currentProject) return;
1240
- window.hccWorkspace?.reset();
1241
- disconnectWebSocket();
1242
- window.hccHistory?.reset();
1243
- window.hccReview?.reset();
1244
- currentProject = root;
1245
- updateLocationProject();
1246
- active = null;
1247
- activeDetected = null;
1248
- activeType = 'managed';
1249
- sessions = [];
1250
- sessionsLoaded = false;
1251
- detected = [];
1252
- renderSections();
1253
- document.getElementById('rootPath').textContent = root;
1254
- lastStateData = null;
1255
- lastStateRoot = '';
1256
- window.hccWorkbench.reset();
1257
- term.reset();
1258
- document.getElementById('codexPanel').hidden = true;
1259
- document.getElementById('nativePanel').hidden = true;
1260
- document.getElementById('terminalComposer').hidden = true;
1261
- document.getElementById('activeTask').hidden = true;
1262
- renderHandoff();
1263
- document.getElementById('activeTitle').textContent = tr('noSessionSelected');
1264
- document.getElementById('activeMeta').textContent = tr('startOrSelect');
1265
- await Promise.all([refreshSessions(), refreshDetected(), refreshState()]);
1266
- if (currentProject !== root) return;
1267
- restoreSelection();
1268
- }
1269
-
1270
- function esc(text) {
1271
- return String(text ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]));
1272
- }
1273
-
1274
- function fmtTime(ts) {
1275
- if (!ts) return '';
1276
- return new Date(ts * 1000).toLocaleTimeString(lang === 'zh' ? 'zh-CN' : 'en');
1277
- }
1278
-
1279
- function badgeClass(status) {
1280
- return String(status || '').toLowerCase().replace(/[^a-z0-9_-]/g, '');
1281
- }
1282
-
1283
- function fmtAge(age) {
1284
- const n = Number(age);
1285
- if (!Number.isFinite(n)) return '?';
1286
- return Math.max(0, Math.round(n)) + (lang === 'zh' ? '秒' : 's');
1287
- }
1288
-
1289
- function sessionPeerId(session) {
1290
- return session?.peer_id || session?.id || '';
1291
- }
1292
-
1293
- function sessionRuntimeNote(session) {
1294
- const target = sessionRuntimeTarget(session);
1295
- return target ? tr('runtime') + '=' + target : '';
1296
- }
1297
-
1298
- function sessionBinding(session) {
1299
- return session?.binding || {};
1300
- }
1301
-
1302
- function sessionRuntimeTarget(session) {
1303
- const binding = sessionBinding(session);
1304
- return binding.runtime_target || session?.pane || session?.id || '';
1305
- }
1306
-
1307
- function runtimeTargetText(session) {
1308
- return tr('runtime') + '=' + (sessionRuntimeTarget(session) || tr('unknown'));
1309
- }
1310
-
1311
- function sessionProvider(session) {
1312
- const binding = sessionBinding(session);
1313
- return binding.provider || session?.kind || 'other';
1314
- }
1315
-
1316
- function sessionProviderSessionValue(session) {
1317
- const binding = sessionBinding(session);
1318
- return session?.provider_session_label || binding.provider_session_id || binding.provider_session_name || '';
1319
- }
1320
-
1321
- function providerSessionKnown(session) {
1322
- return session?.provider_session_known === true || Boolean(sessionProviderSessionValue(session));
1323
- }
1324
-
1325
- function sessionProviderSessionText(session) {
1326
- const value = sessionProviderSessionValue(session);
1327
- return tr('providerSession') + '=' + sessionProvider(session) + ':' + (value || tr('unknown'));
1328
- }
1329
-
1330
- function sessionCardDetailText(session) {
1331
- return [
1332
- runtimeTargetText(session),
1333
- sessionProviderSessionText(session),
1334
- session?.cwd || ''
1335
- ].filter(Boolean).join(' · ');
1336
- }
1337
-
1338
- function sessionMetaText(session) {
1339
- return [
1340
- runtimeTargetText(session),
1341
- sessionProviderSessionText(session),
1342
- session?.capabilities?.structured_turns ? 'App Server' : '',
1343
- session?.command ? tr('command') + '=' + session.command : '',
1344
- session?.cwd || ''
1345
- ].filter(Boolean).join(' · ');
1346
- }
1347
-
1348
- function statusText(status) {
1349
- const value = String(status || 'unknown');
1350
- return tr('status.' + value.toLowerCase(), value);
1351
- }
1352
-
1353
- function lockLabel(lock) {
1354
- const base = lock.base_resource || lock.resource || '';
1355
- const scope = lock.scope || '*';
1356
- return scope === '*' ? base : base + ' [' + scope + ']';
1357
- }
1358
-
1359
- function managedPeerId(id) {
1360
- return sessionPeerId(sessions.find((s) => s.id === id)) || id;
1361
- }
1362
-
1363
- function peerIsActive(peer, basisNow = lastStateNow) {
1364
- const age = Number(peer?.age_sec);
1365
- if (Number.isFinite(age)) return age <= activePeerTtl;
1366
- const seen = Number(peer?.last_seen_at || 0);
1367
- const t = Number(basisNow || 0) || Math.floor(Date.now() / 1000);
1368
- return seen > 0 && (t - seen) <= activePeerTtl;
1369
- }
1370
-
1371
- function dshCoordinationPeer(peer) {
1372
- return (peer?.provider || peer?.kind) === 'dsh' && ['hook', 'cordis'].includes(peer?.transport);
1373
- }
1374
-
1375
- function detectedPeerCanStop(peer) {
1376
- if (dshCoordinationPeer(peer)) return false;
1377
- const status = String(peer?.status || '').toLowerCase();
1378
- if (['exited', 'detached'].includes(status)) return false;
1379
- return peerIsActive(peer);
1380
- }
1381
-
1382
- function peerStateView(peer, runtime = null, basisNow = lastStateNow) {
1383
- const activity = peer?.status || 'unknown';
1384
- const liveness = peerIsActive(peer, basisNow) ? 'active' : 'stale';
1385
- const age = fmtAge(peer?.age_sec);
1386
- const branch = peer?.branch ? ' ' + tr('branch') + '=' + peer.branch : '';
1387
- if (runtime) {
1388
- return {
1389
- label: runtime.status || 'running',
1390
- detail: tr('peer') + '=' + statusText(activity) + ' ' + statusText(liveness) + ' ' + tr('age') + '=' + age + branch
1391
- };
1392
- }
1393
- if (liveness === 'stale') {
1394
- return {
1395
- label: 'stale',
1396
- detail: tr('lastSeen') + '=' + statusText(activity) + ' ' + tr('age') + '=' + age + branch
1397
- };
1398
- }
1399
- return {
1400
- label: activity,
1401
- detail: statusText(liveness) + ' ' + tr('age') + '=' + age + branch
1402
- };
1403
- }
1404
-
1405
- function taskOwnerStateText(task) {
1406
- if (!task?.owner) return '';
1407
- if (task.owner_stale) {
1408
- if (task.takeover_ready) return tr('ownerStaleNoLock');
1409
- const locks = Number(task.related_lock_count || 0);
1410
- return locks ? tr('ownerStaleLocks') + ': ' + locks : tr('ownerStale');
1411
- }
1412
- if (task.owner_active) return tr('ownerActive');
1413
- return '';
1414
- }
1415
-
1416
- function bodyPinned(el) {
1417
- if (!el) return false;
1418
- return el.scrollTop + el.clientHeight >= el.scrollHeight - 8;
1419
- }
1420
-
1421
- function stateCardCollapsed(section) {
1422
- return hccUi.safeGet('hcc.stateCard.' + section + '.collapsed') === '1';
1423
- }
1424
-
1425
- function stateCardHtml(section, title, count, bodyHtml) {
1426
- const collapsed = stateCardCollapsed(section);
1427
- return \`
1428
- <div class="card state-card \${collapsed ? 'state-card-collapsed' : ''}" data-section="\${esc(section)}">
1429
- <button class="state-card-toggle" type="button" aria-expanded="\${collapsed ? 'false' : 'true'}">
1430
- <span class="state-card-toggle-title"><strong>\${esc(title)}</strong> <span class="badge">\${esc(count)}</span></span>
1431
- <span class="state-card-chevron">⌄</span>
1432
- </button>
1433
- <div class="body">\${bodyHtml}</div>
1434
- </div>\`;
1435
- }
1436
-
1437
- function bindStateCardToggles() {
1438
- document.querySelectorAll('.state-card[data-section] .state-card-toggle').forEach((button) => {
1439
- button.addEventListener('click', () => {
1440
- const card = button.closest('.state-card[data-section]');
1441
- if (!card) return;
1442
- const section = card.dataset.section || '';
1443
- const collapsed = !card.classList.contains('state-card-collapsed');
1444
- card.classList.toggle('state-card-collapsed', collapsed);
1445
- button.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
1446
- hccUi.safeSet('hcc.stateCard.' + section + '.collapsed', collapsed ? '1' : '0');
1447
- });
1448
- });
1449
- }
1450
-
1451
- function renderTimelineItem(item) {
1452
- const meta = [
1453
- item.source + ':' + item.source_id,
1454
- item.task_id ? tr('task') + ' #' + item.task_id : '',
1455
- item.thread_id ? tr('thread') + ' #' + item.thread_id : '',
1456
- item.direction || '',
1457
- fmtTime(item.ts)
1458
- ].filter(Boolean).join(' · ');
1459
- return \`
1460
- <div class="item timeline-item">
1461
- <strong>\${esc(item.title || item.kind || item.source)} <span class="badge">\${esc(item.kind || item.source)}</span></strong>
1462
- <span>\${esc(meta)}</span>
1463
- \${item.text ? '<span>' + esc(item.text) + '</span>' : ''}
1464
- </div>\`;
1465
- }
1466
-
1467
- document.getElementById('projectSelect').addEventListener('change', (event) => {
1468
- switchProject(event.target.value).catch(handleUiError);
1469
- });
1470
- document.getElementById('sessionKindFilter').addEventListener('change', (event) => {
1471
- sessionKindFilter = event.target.value || 'all';
1472
- updateLocationProject();
1473
- renderSections();
1474
- });
1475
- document.getElementById('sessionSearch').addEventListener('input', (event) => {
1476
- sessionSearchQuery = event.target.value;
1477
- renderSections();
1478
- });
1479
- document.getElementById('sessionStatusFilter').addEventListener('change', (event) => {
1480
- sessionStatusFilter = event.target.value || 'all';
1481
- renderSections();
1482
- });
1483
- document.getElementById('projectForm').addEventListener('submit', async (event) => {
1484
- event.preventDefault();
1485
- const button = document.getElementById('addProjectBtn');
1486
- if (button.disabled) return;
1487
- button.disabled = true;
1488
- document.getElementById('openProjectDialog').disabled = true;
1489
- const errorElement = document.getElementById('projectDialogError');
1490
- const originalProject = currentProject;
1491
- errorElement.hidden = true;
1492
- try {
1493
- const input = document.getElementById('projectPath');
1494
- const root = input.value.trim();
1495
- if (!root) return;
1496
- await api('/api/projects', { method: 'POST', body: JSON.stringify({ root }) });
1497
- if (input.value.trim() === root) input.value = '';
1498
- await loadProjects();
1499
- if (projectDialog.hidden || currentProject !== originalProject) return;
1500
- await switchProject(root);
1501
- if (!projectDialog.hidden) closeDialog(projectDialog);
1502
- } catch (error) {
1503
- errorElement.textContent = tr('error.' + error.code, error.detail || error.message);
1504
- errorElement.hidden = false;
1505
- } finally { button.disabled = false; document.getElementById('openProjectDialog').disabled = false; }
1506
- });
1507
-
1508
- function clearSessionFilters() {
1509
- sessionSearchQuery = '';
1510
- sessionStatusFilter = 'all';
1511
- sessionKindFilter = 'all';
1512
- document.getElementById('sessionSearch').value = '';
1513
- document.getElementById('sessionStatusFilter').value = 'all';
1514
- document.getElementById('sessionKindFilter').value = 'all';
1515
- updateLocationProject();
1516
- renderSections();
1517
- document.getElementById('sessionSearch').focus();
1518
- }
1519
-
1520
- function sessionMatchesSearch(item) {
1521
- const words = sessionSearchQuery.trim().toLocaleLowerCase().split(/\\s+/).filter(Boolean);
1522
- if (!words.length) return true;
1523
- const searchable = [item.name, item.task?.title, item.id, item.peer_id, item.kind, item.provider, item.cwd, item.worktree, item.command,
1524
- item.type, item.transport, item.branch, item.provider_session_label, item.binding?.runtime_target,
1525
- item.binding?.provider, item.binding?.provider_session_id, item.binding?.provider_session_name]
1526
- .filter(Boolean).join(' ').toLocaleLowerCase();
1527
- return words.every((word) => searchable.includes(word));
1528
- }
1529
-
1530
- function sessionDetailsHtml(key, rows) {
1531
- return '<details class="session-details" data-detail-key="' + esc(key) + '"><summary data-focus-key="detail:' + esc(key) + '">' + esc(tr('sessionDetails')) + '</summary><dl>' +
1532
- rows.filter(([, value]) => value !== undefined && value !== null && value !== '').map(([label, value]) =>
1533
- '<dt>' + esc(label) + '</dt><dd>' + esc(value) + '</dd>').join('') + '</dl></details>';
1534
- }
1535
-
1536
- // ── Sessions rendering (managed + detected) ──────────────────────────
1537
- function renderSections() {
1538
- const box = document.getElementById('sessions');
1539
- const filtered = Boolean(sessionSearchQuery.trim() || sessionStatusFilter !== 'all' || sessionKindFilter !== 'all');
1540
- const searching = Boolean(sessionSearchQuery.trim());
1541
- const visibleSessions = sessions.filter((s) => kindMatches(s) && sessionMatchesSearch(s) &&
1542
- (sessionStatusFilter === 'all' || s.status === 'running'))
1543
- .sort((a, b) => Number(b.status === 'running') - Number(a.status === 'running') ||
1544
- String(a.name || sessionPeerId(a)).localeCompare(String(b.name || sessionPeerId(b))));
1545
- const visibleDetected = detected.filter((p) => kindMatches(p) && sessionMatchesSearch(p) &&
1546
- (sessionStatusFilter === 'all' || peerIsActive(p)))
1547
- .sort((a, b) => Number(b.last_seen_at || 0) - Number(a.last_seen_at || 0) || String(a.id).localeCompare(String(b.id)));
1548
- const activeDetectedPeers = visibleDetected.filter((p) => peerIsActive(p));
1549
- const staleDetectedPeers = visibleDetected.filter((p) => !peerIsActive(p));
1550
- 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>';
1551
- 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>';
1552
- const manHtml = visibleSessions.length
1553
- ? visibleSessions.map((s) => {
1554
- const peerId = sessionPeerId(s);
1555
- const title = s.name || s.task?.title || peerId;
1556
- const path = s.cwd || '';
1557
- return \`
1558
- <div class="session \${active === s.id && activeType === 'managed' ? 'active' : ''}" data-id="\${esc(s.id)}" data-type="managed">
1559
- <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>
1560
- <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>
1561
- \${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]])}
1562
- </div>\`;
1563
- }).join('')
1564
- : filtered ? '' : startEmpty;
1565
-
1566
- const renderDetectedPeer = (p) => {
1567
- const state = peerStateView(p);
1568
- const canStop = detectedPeerCanStop(p);
1569
- return \`
1570
- <div class="session \${activeDetected === p.id && activeType === 'detected' ? 'active' : ''}" data-id="\${esc(p.id)}" data-type="detected">
1571
- <div class="row">
1572
- <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>
1573
- <div style="display:flex;gap:6px;align-items:center">
1574
- <span class="badge \${badgeClass(state.label)}" title="\${esc(state.detail)}">\${esc(statusText(state.label))}</span>
1575
- \${dshCoordinationPeer(p) ? '' : canStop ? \`
1576
- <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>
1577
- \` : \`
1578
- <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>
1579
- \`}
1580
- </div>
1581
- </div>
1582
- <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>
1583
- \${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]])}
1584
- </div>\`;
1585
- };
1586
- const activeDetectedHtml = activeDetectedPeers.length
1587
- ? activeDetectedPeers.map(renderDetectedPeer).join('')
1588
- : filtered ? '' : '<div class="empty">' + esc(tr('noActiveDetectedPeers')) + '</div>';
1589
- const staleToggleLabel = showStaleDetected ? tr('hideStale') : tr('showStale');
1590
- const staleDetectedHtml = (showStaleDetected || searching)
1591
- ? (staleDetectedPeers.length ? staleDetectedPeers.map(renderDetectedPeer).join('') : '<div class="empty">' + esc(tr('noDetectedPeers')) + '</div>')
1592
- : '';
1593
-
1594
- const savedScroll = box.scrollTop;
1595
- const restoreFocus = preserveFocus(box);
1596
- const summaryFocus = document.activeElement?.tagName === 'SUMMARY' && box.contains(document.activeElement) ? document.activeElement.dataset.focusKey : '';
1597
- const openDetails = new Set([...box.querySelectorAll('details[open]')].map((el) => el.dataset.detailKey));
1598
- document.getElementById('sessionListCount').textContent = tr('sessionListSummary').replace('{count}', visibleSessions.length + activeDetectedPeers.length + ((showStaleDetected || searching) ? staleDetectedPeers.length : 0));
1599
- box.innerHTML = filtered && !visibleSessions.length && !visibleDetected.length ? noMatches : \`
1600
- <div class="sec-label">\${esc(tr('managed'))} <span class="badge">\${visibleSessions.filter(s=>s.status==='running').length} \${esc(tr('running'))}</span></div>
1601
- \${manHtml}
1602
- <div class="sec-label" style="margin-top:10px">\${esc(tr('activeDetected'))} <span class="badge" style="color:var(--warn)">\${activeDetectedPeers.length}</span></div>
1603
- \${activeDetectedHtml}
1604
- <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>
1605
- \${staleDetectedHtml}
1606
- \`;
1607
- box.querySelectorAll('details').forEach((el) => { el.open = openDetails.has(el.dataset.detailKey); });
1608
- box.scrollTop = savedScroll;
1609
- restoreFocus();
1610
- if (summaryFocus) [...box.querySelectorAll('summary')].find((el) => el.dataset.focusKey === summaryFocus)?.focus({ preventScroll: true });
1611
- box.querySelectorAll('[data-navigation="clear-session-filters"]').forEach((button) => button.addEventListener('click', clearSessionFilters));
1612
- window.hccCommandPalette?.refresh();
1613
- box.querySelectorAll('[data-navigation="open-start"]').forEach((button) => button.addEventListener('click', openStartDialog));
1614
- const staleToggle = document.getElementById('toggleStaleDetected');
1615
- if (staleToggle) {
1616
- staleToggle.addEventListener('click', () => {
1617
- showStaleDetected = !showStaleDetected;
1618
- hccUi.safeSet('hcc.showStaleDetected', showStaleDetected ? '1' : '0');
1619
- renderSections();
1620
- });
1621
- }
1622
- box.querySelectorAll('.session[data-type="managed"]').forEach((el) => {
1623
- el.addEventListener('click', (event) => {
1624
- if (event.target.closest('details, [data-action]')) return;
1625
- connectManaged(el.dataset.id);
1626
- });
1627
- });
1628
- box.querySelectorAll('.session[data-type="detected"]').forEach((el) => {
1629
- el.addEventListener('click', (e) => {
1630
- if (e.target.closest('details, [data-action]')) return;
1631
- connectDetected(el.dataset.id);
1632
- });
1633
- });
1634
- box.querySelectorAll('[data-action="stop-detected"]').forEach((btn) => {
1635
- btn.addEventListener('click', (e) => {
1636
- e.stopPropagation();
1637
- const peerId = btn.dataset.id;
1638
- document.getElementById('stopDialogTitle').textContent = tr('stop') + ' ' + peerId + '?';
1639
- stopKillCb.checked = false;
1640
- stopKillCb.parentElement.parentElement.hidden = false;
1641
- stopConfirmBtn.textContent = tr('stop');
1642
- document.getElementById('stopDialogMeta').textContent = tr('detectedPeer');
1643
- stopDialog._action = 'detected';
1644
- stopDialog._peerId = peerId;
1645
- openDialog(stopDialog, stopCancelBtn);
1646
- });
1647
- });
1648
- box.querySelectorAll('[data-action="restart-detected"]').forEach((btn) => {
1649
- btn.addEventListener('click', async (e) => {
1650
- try {
1651
- e.stopPropagation();
1652
- const peerId = btn.dataset.id;
1653
- await api('/api/detected/' + encodeURIComponent(peerId) + '/restart', { method: 'POST', body: '{}' });
1654
- await Promise.all([refreshSessions(), refreshDetected()]);
1655
- } catch (error) { handleUiError(error); }
1656
- });
1657
- });
1658
- }
1659
-
1660
- async function refreshSessions() {
1661
- const root = currentProject;
1662
- const data = await api('/api/sessions');
1663
- if (currentProject !== root) return;
1664
- sessions = data.sessions || [];
1665
- sessionsLoaded = true;
1666
- renderSections();
1667
- if (active && activeType === 'managed') {
1668
- const meta = sessions.find((s) => s.id === active);
1669
- if (meta) {
1670
- renderActiveSession(meta);
1671
- activeLocalClients = meta.local_clients ?? activeLocalClients;
1672
- }
1673
- }
1674
- connText(ws?.readyState === WebSocket.OPEN ? 'attached' : activeDetected ? 'coordinationOnly' : active ? (activeConnectionState === 'connecting' ? 'reconnecting' : 'offline') : 'online');
1675
- renderHandoff();
1676
- }
1677
-
1678
- async function refreshDetected() {
1679
- const root = currentProject;
1680
- try {
1681
- const data = await api('/api/detected');
1682
- if (currentProject !== root) return;
1683
- activePeerTtl = Number(data.active_peer_ttl || activePeerTtl);
1684
- lastStateNow = Number(data.now || lastStateNow);
1685
- detected = data.detected || [];
1686
- renderSections();
1687
- } catch {}
1688
- }
1689
-
1690
- // ── Project state panel ───────────────────────────────────────────────
1691
- function renderState(data) {
1692
- lastStateData = data;
1693
- const stateRoot = data.root || '';
1694
- document.getElementById('rootPath').textContent = stateRoot;
1695
- activePeerTtl = Number(data.active_peer_ttl || activePeerTtl);
1696
- lastStateNow = Number(data.now || lastStateNow);
1697
- const state = document.getElementById('state');
1698
- const restoreFocus = preserveFocus(state);
1699
- const preserveScroll = lastStateRoot === stateRoot;
1700
- const savedStateScroll = preserveScroll ? state.scrollTop : 0;
1701
- const savedCardScroll = preserveScroll
1702
- ? new Map(
1703
- [...state.querySelectorAll('.state-card[data-section]')].map((card) => [
1704
- card.dataset.section,
1705
- {
1706
- top: card.querySelector('.body')?.scrollTop || 0,
1707
- pinned: bodyPinned(card.querySelector('.body'))
1708
- }
1709
- ])
1710
- )
1711
- : new Map();
1712
- const runtimeById = new Map();
1713
- for (const session of sessions || []) {
1714
- runtimeById.set(session.id, session);
1715
- const peerId = sessionPeerId(session);
1716
- if (peerId) runtimeById.set(peerId, session);
1717
- }
1718
- const tasksData = data.tasks || [];
1719
- const peersData = data.peers || [];
1720
- const locksData = data.locks || [];
1721
- const messagesData = data.messages || [];
1722
- const timelineData = data.timeline || [];
1723
- const automation = data.automation || {};
1724
- const nextAction = automation.next_action || {};
1725
- const tasks = tasksData.map((t) => \`
1726
- <div class="item"><strong>#\${t.id} \${esc(t.title)}</strong><span>\${esc(statusText(t.status))} \${esc(tr('owner'))}=\${esc(t.owner || '')} \${esc(tr('assignee'))}=\${esc(t.assignee || '')}\${taskOwnerStateText(t) ? ' · ' + esc(taskOwnerStateText(t)) : ''}</span></div>
1727
- \`).join('') || '<div class="empty">' + esc(tr('noTasks')) + '</div>';
1728
- const peers = peersData.map((a) => {
1729
- const peerRuntime = runtimeById.get(a.id);
1730
- const peerState = peerStateView(a, peerRuntime, data.now);
1731
- return \`
1732
- <div class="item"><strong>\${esc(a.id)} <span class="badge">\${esc(a.kind)}</span> <span class="badge \${badgeClass(peerState.label)}">\${esc(statusText(peerState.label))}</span></strong><span>\${esc(peerState.detail)}</span></div>
1733
- \`;
1734
- }).join('') || '<div class="empty">' + esc(tr('noPeers')) + '</div>';
1735
- const locks = locksData.map((l) => \`
1736
- <div class="item"><strong>\${esc(lockLabel(l))}</strong><span>\${esc(tr('owner'))}=\${esc(l.owner)} \${esc(tr('task'))}=\${l.task_id ? '#' + l.task_id : ''}</span></div>
1737
- \`).join('') || '<div class="empty">' + esc(tr('noActiveLocks')) + '</div>';
1738
- const messages = messagesData.map((m) => \`
1739
- <div class="item"><strong>#\${m.id} \${esc(m.sender)} → \${esc(m.recipient || tr('all'))}\${m.reply_to ? ' ' + esc(tr('reply')) + ' #' + m.reply_to : ''}</strong><span>\${esc(m.body)}</span></div>
1740
- \`).join('') || '<div class="empty">' + esc(tr('noMessages')) + '</div>';
1741
- const timeline = timelineData.map(renderTimelineItem).join('') || '<div class="empty">' + esc(tr('noTimelineItems')) + '</div>';
1742
- const actionLines = [
1743
- '<div class="item"><strong>' + esc(statusText(automation.phase || 'idle')) + '</strong><span>' + esc(nextAction.reason || tr('noImmediateAction')) + '</span></div>',
1744
- nextAction.command ? '<div class="item"><strong>' + esc(tr('next')) + '</strong><span class="mono">' + esc(nextAction.command) + '</span></div>' : '',
1745
- (automation.finish_actions || []).length ? '<div class="item"><strong>' + esc(tr('finish')) + '</strong><span>' + esc(automation.finish_actions.map((a) => a.command).join(' | ')) + '</span></div>' : '',
1746
- (automation.warnings || []).length ? '<div class="item"><strong>' + esc(tr('warnings')) + '</strong><span>' + esc(automation.warnings.join(' | ')) + '</span></div>' : ''
1747
- ].filter(Boolean).join('');
1748
- state.innerHTML = [
1749
- stateCardHtml('automation', tr('nextAction'), statusText(automation.phase || 'idle'), actionLines),
1750
- stateCardHtml('timeline', tr('timeline'), timelineData.length, timeline),
1751
- stateCardHtml('messages', tr('messages'), messagesData.length, messages),
1752
- stateCardHtml('peers', tr('peers'), peersData.length, peers),
1753
- stateCardHtml('tasks', tr('tasks'), tasksData.length, tasks),
1754
- stateCardHtml('locks', tr('locks'), locksData.length, locks)
1755
- ].join('');
1756
- state.scrollTop = savedStateScroll;
1757
- for (const [section, saved] of savedCardScroll) {
1758
- const body = state.querySelector('.state-card[data-section="' + section + '"] .body');
1759
- if (!body) continue;
1760
- if (section === 'timeline' && saved.pinned) body.scrollTop = body.scrollHeight;
1761
- else body.scrollTop = saved.top;
1762
- }
1763
- bindStateCardToggles();
1764
- window.hccWorkbench.apply();
1765
- restoreFocus();
1766
- lastStateRoot = stateRoot;
1767
- }
1768
-
1769
- async function refreshState() {
1770
- const root = currentProject;
1771
- const selected = active;
1772
- const selectionType = activeType;
1773
- const peer = active ? managedPeerId(active) : null;
1774
- const p = peer ? '/api/state?peer=' + encodeURIComponent(peer) : '/api/state';
1775
- try {
1776
- const data = await api(p);
1777
- if (currentProject !== root || active !== selected || activeType !== selectionType) return;
1778
- renderState(data);
1779
- window.hccWorkbench.fresh();
1780
- } catch (error) {
1781
- if (currentProject === root && active === selected && activeType === selectionType) window.hccWorkbench.stale(error);
1782
- throw error;
1783
- }
1784
- }
1785
-
1786
- async function refreshDetectedState() {
1787
- if (!activeDetected) return;
1788
- const root = currentProject, selected = activeDetected;
1789
- try {
1790
- const data = await api('/api/state?peer=' + encodeURIComponent(selected));
1791
- if (currentProject !== root || activeDetected !== selected || activeType !== 'detected') return;
1792
- renderState(data);
1793
- window.hccWorkbench.fresh();
1794
- } catch (error) {
1795
- if (currentProject === root && activeDetected === selected && activeType === 'detected') window.hccWorkbench.stale(error);
1796
- throw error;
1797
- }
1798
- }
1799
-
1800
- async function refreshCurrentState() {
1801
- if (activeType === 'detected') return refreshDetectedState();
1802
- return refreshState();
1803
- }
1804
-
1805
- async function refreshVisibleData() {
1806
- if (autoPollInFlight) return;
1807
- autoPollInFlight = true;
1808
- window.hccWorkbench.loading();
1809
- try {
1810
- await Promise.all([
1811
- refreshSessions(),
1812
- refreshDetected(),
1813
- refreshCurrentState()
1814
- ]);
1815
- } finally {
1816
- autoPollInFlight = false;
1817
- }
1818
- }
1819
-
1820
- async function refreshProjectsQuietly() {
1821
- if (projectPollInFlight) return;
1822
- projectPollInFlight = true;
1823
- try {
1824
- await loadProjects();
1825
- } finally {
1826
- projectPollInFlight = false;
1827
- }
1828
- }
1829
-
1830
- // ── Connect to managed (PTY) session ─────────────────────────────────
1831
- function connectManaged(id) {
1832
- if (!paneMode) window.hccWorkspace?.primary();
1833
- const meta = sessions.find((s) => s.id === id);
1834
- window.hccReview?.reset();
1835
- disconnectWebSocket();
1836
- active = id;
1837
- activeDetected = null;
1838
- activeType = 'managed';
1839
- if (paneMode) { const url = new URL(location.href); url.searchParams.set('session', id); history.replaceState(null, '', url); }
1840
- handoffStore.select(currentProject, 'managed', id);
1841
- activeLocalClients = meta?.local_clients ?? null;
1842
- renderSections();
1843
- renderActiveSession(meta || { id });
1844
- const isCodex = meta?.type === 'app-server';
1845
- const isNative = meta?.type === 'native';
1846
- document.getElementById('terminal').style.display = isCodex || isNative ? 'none' : '';
1847
- document.getElementById('codexPanel').hidden = !isCodex;
1848
- document.getElementById('codexPanel').style.display = isCodex ? '' : 'none';
1849
- document.getElementById('nativePanel').hidden = !isNative;
1850
- document.getElementById('terminalComposer').hidden = isCodex || isNative;
1851
- document.getElementById('detectedPanel').style.display = 'none';
1852
- document.getElementById('quickBar').style.display = '';
1853
- setMobileView('terminal');
1854
- draftReceipt = false;
1855
- handoffStore.uncertain(currentProject, id);
1856
- document.getElementById('terminalDraft').value = handoffStore.draft(currentProject, id).text;
1857
- window.hccTerminalFind?.sync();
1858
- term.reset();
1859
- terminalHasContent = false;
1860
- terminalLastDataAt = 0;
1861
- terminalLastReplaceAt = 0;
1862
- terminalLastResizeAt = Date.now();
1863
- openWs(id);
1864
- refreshState().catch(console.error);
1865
- }
1866
-
1867
- function terminalPinned() {
1868
- try {
1869
- const b = term.buffer.active;
1870
- return b.viewportY >= b.baseY;
1871
- } catch {
1872
- return true;
1873
- }
1874
- }
1875
-
1876
- function writeTerminalSnapshot(data, pinned = true) {
1877
- term.reset();
1878
- terminalHasContent = Boolean(data);
1879
- terminalLastReplaceAt = Date.now();
1880
- term.write(data || '', pinned ? () => { term.scrollToBottom(); } : undefined);
1881
- }
1882
-
1883
- function shouldApplyTerminalReplace() {
1884
- const t = Date.now();
1885
- if (!terminalHasContent) return true;
1886
- if (t - terminalLastResizeAt <= 1500) return true;
1887
- // Server fallback replace frames are useful for recovery, but applying
1888
- // them during normal idle chat causes visible rollback/flicker.
1889
- return document.visibilityState !== 'visible' &&
1890
- t - terminalLastDataAt > 30000 &&
1891
- t - terminalLastReplaceAt > 15000;
1892
- }
1893
-
1894
- function openWs(id) {
1895
- const proto = location.protocol === 'https:' ? 'wss' : 'ws';
1896
- sessionActionTokens.delete(id);
1897
- sessionControls.delete(id);
1898
- activeConnectionState = 'connecting';
1899
- renderHandoff();
1900
- const socket = new WebSocket(proto + '://' + location.host + '/ws/terminal/' + encodeURIComponent(id) + requestQuery({ api_version: runtimeApiVersion }));
1901
- ws = socket;
1902
- socket.onopen = () => {
1903
- if (ws !== socket || active !== id) return;
1904
- activeConnectionState = 'connected';
1905
- renderHandoff();
1906
- wsReconnectFailures = 0;
1907
- connText('attached');
1908
- };
1909
- socket.onmessage = (event) => {
1910
- if (ws !== socket || active !== id) return;
1911
- const msg = JSON.parse(event.data);
1912
- const pinned = terminalPinned();
1913
- // The server streams the tmux pane's raw output, so xterm renders
1914
- // incrementally (no reset/redraw → no flicker) and the program's own
1915
- // escape sequences carry the cursor.
1916
- if (msg.type === 'snapshot') {
1917
- if (msg.action_token) sessionActionTokens.set(id, msg.action_token);
1918
- if (msg.control) sessionControls.set(id, msg.control);
1919
- if (msg.local_clients != null) activeLocalClients = msg.local_clients;
1920
- const selected = sessions.find(item => item.id === id);
1921
- if (selected?.type === 'native' && msg.native_state) window.hccNative?.render(msg.native_state);
1922
- else if (selected?.type === 'app-server' && msg.state) window.hccCodex?.render(msg.state);
1923
- renderHandoff();
1924
- writeTerminalSnapshot(msg.data || '', true);
1925
- resizeTerm();
1926
- // Connection recovery never replays terminal input or a draft.
1927
- }
1928
- if (msg.type === 'data') {
1929
- terminalHasContent = true;
1930
- terminalLastDataAt = Date.now();
1931
- term.write(msg.data || '', pinned ? () => { term.scrollToBottom(); } : undefined);
1932
- }
1933
- if (msg.type === 'replace' && shouldApplyTerminalReplace()) {
1934
- writeTerminalSnapshot(msg.data || '', pinned);
1935
- }
1936
- if (msg.type === 'control') {
1937
- sessionControls.set(id, msg.control || {});
1938
- lastSentTerminalSize = null;
1939
- renderHandoff();
1940
- resizeTerm();
1941
- window.hccCodex?.sync();
1942
- }
1943
- if (msg.type === 'codex_state') window.hccCodex?.render(msg.state);
1944
- if (msg.type === 'native_state') window.hccNative?.render(msg.state);
1945
- if (msg.type === 'input_ack' && handoffStore.acknowledge(currentProject, id, msg.input_id)) {
1946
- draftReceipt = true;
1947
- document.getElementById('terminalDraft').value = handoffStore.draft(currentProject, id).text;
1948
- renderDraftState();
1949
- }
1950
- if (msg.type === 'error') {
1951
- if (msg.control) sessionControls.set(id, msg.control);
1952
- renderHandoff();
1953
- handoffStore.uncertain(currentProject, id);
1954
- renderDraftState();
1955
- handleUiError({ code: msg.error?.code, message: msg.error?.message || tr('requestFailed') });
1956
- }
1957
- if (msg.type === 'exit') { refreshSessions().catch(console.error); }
1958
- };
1959
- socket.onclose = (event) => {
1960
- if (ws !== socket) return;
1961
- sessionActionTokens.delete(id);
1962
- sessionControls.delete(id);
1963
- activeConnectionState = 'disconnected';
1964
- handoffStore.uncertain(currentProject, id);
1965
- renderHandoff();
1966
- window.hccCodex?.sync();
1967
- // ui-4: no reconnect after logout, and stop after repeated failures
1968
- // (e.g. the session cookie was revoked in another tab) instead of
1969
- // looping every 2s forever.
1970
- if (event.code === 4001 || loggedOut) {
1971
- connText('signed out');
1972
- return;
1973
- }
1974
- wsReconnectFailures += 1;
1975
- if (wsReconnectFailures > 5) {
1976
- connText('offline');
1977
- return;
1978
- }
1979
- connText('reconnecting');
1980
- // Auto-reconnect if session is still in the list and running
1981
- wsReconnectTimer = setTimeout(() => {
1982
- const s = sessions.find((s) => s.id === id);
1983
- if (s && s.status === 'running' && active === id) openWs(id);
1984
- else connText('online');
1985
- }, 2000);
1986
- };
1987
- }
1988
-
1989
- // ── Connect to detected (coordination-only) peer ──────────────────────
1990
- function connectDetected(id) {
1991
- if (!paneMode) window.hccWorkspace?.primary();
1992
- const peer = detected.find((p) => p.id === id);
1993
- window.hccReview?.reset();
1994
- disconnectWebSocket();
1995
- active = null;
1996
- activeDetected = id;
1997
- activeType = 'detected';
1998
- handoffStore.select(currentProject, 'detected', id);
1999
- renderHandoff();
2000
- renderSections();
2001
- document.getElementById('activeTitle').textContent = id + ' (' + tr('detected') + ')';
2002
- document.getElementById('activeMeta').textContent = peer ? peer.kind + ' · ' + (peer.worktree || '') : '';
2003
- document.getElementById('terminal').style.display = 'none';
2004
- document.getElementById('codexPanel').hidden = true;
2005
- document.getElementById('nativePanel').hidden = true;
2006
- document.getElementById('codexPanel').style.display = 'none';
2007
- document.getElementById('terminalComposer').hidden = true;
2008
- document.getElementById('activeTask').hidden = true;
2009
- document.getElementById('detectedPanel').style.display = '';
2010
- document.getElementById('quickBar').style.display = 'none';
2011
- setMobileView('terminal');
2012
- connText('coordinationOnly');
2013
- renderDetectedPanel(peer || { id });
2014
- refreshDetectedState().catch(console.error);
2015
- }
2016
-
2017
- function renderDetectedPanel(peer) {
2018
- const dp = document.getElementById('detectedPanel');
2019
- dp.innerHTML = \`
2020
- <div style="padding:16px;display:grid;gap:12px">
2021
- <div class="card">
2022
- <h2>\${esc(tr('detectedSession'))}</h2>
2023
- <div class="body">
2024
- <div class="item"><strong>\${esc(tr('peer'))}</strong><span class="mono">\${esc(peer.id)}</span></div>
2025
- <div class="item"><strong>\${esc(tr('kind'))}</strong><span>\${esc(peer.kind || '')}</span></div>
2026
- \${dshCoordinationPeer(peer) ? \`
2027
- <div class="item"><strong>\${esc(tr('providerSession'))}</strong><span class="mono" style="overflow-wrap:anywhere">\${esc(peer.provider_session_id || tr('unknown'))}</span></div>
2028
- <p style="font-size:12px;color:var(--muted)">\${esc(tr('dshCoordinationHelp'))}</p>
2029
- \` : ''}
2030
- <div class="item"><strong>\${esc(tr('status'))}</strong><span>\${esc(statusText(peer.status))}</span></div>
2031
- <div class="item"><strong>\${esc(tr('cwd'))}</strong><span class="mono" style="font-size:11px">\${esc(peer.worktree || '')}</span></div>
2032
- <div class="item"><strong>\${esc(tr('pid'))}</strong><span>\${esc(peer.pid || tr('unknown'))}</span></div>
2033
- <div class="item"><strong>\${esc(tr('lastSeen'))}</strong><span>\${peer.age_sec != null ? esc(peer.age_sec + tr('secondsAgo')) : ''}</span></div>
2034
- </div>
2035
- </div>
2036
- <div class="card">
2037
- <h2>\${esc(tr('sendMessage'))}</h2>
2038
- <div class="body" style="gap:8px">
2039
- <div style="font-size:12px;color:var(--muted)">\${tr(dshCoordinationPeer(peer) && peer.transport === 'cordis' ? 'dshCordisMessageHelp' : 'messageHelp')}</div>
2040
- <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>
2041
- <button class="primary" id="sendDetMsg">\${esc(tr('send'))}</button>
2042
- </div>
2043
- </div>
2044
- </div>
2045
- \`;
2046
- const draftProject = currentProject;
2047
- const draftKey = 'detected:' + peer.id;
2048
- document.getElementById('detMsg').value = handoffStore.draft(draftProject, draftKey).text;
2049
- document.getElementById('detMsg').addEventListener('input', (event) => handoffStore.saveDraft(draftProject, draftKey, event.target.value));
2050
- document.getElementById('sendDetMsg').addEventListener('click', async () => {
2051
- try {
2052
- const body = document.getElementById('detMsg').value.trim();
2053
- if (!body) return;
2054
- await api('/api/detected/' + encodeURIComponent(peer.id) + '/msg', {
2055
- method: 'POST',
2056
- body: JSON.stringify({ body })
2057
- });
2058
- const textarea = document.getElementById('detMsg');
2059
- if (currentProject === draftProject && activeDetected === peer.id && textarea?.value.trim() === body) {
2060
- textarea.value = '';
2061
- handoffStore.saveDraft(draftProject, draftKey, '');
2062
- }
2063
- await refreshDetectedState();
2064
- } catch (error) { handleUiError(error); }
2065
- });
2066
- }
2067
-
2068
- // ── Helpers ───────────────────────────────────────────────────────────
2069
- function sendLine(text) {
2070
- sendTerminalInput(text + '\\r');
2071
- }
2072
-
2073
- // ── Start session form ────────────────────────────────────────────────
2074
- document.getElementById('kind').addEventListener('change', syncStartModeOptions);
2075
- document.getElementById('transport').addEventListener('change', syncStartModeOptions);
2076
- document.getElementById('startMode').addEventListener('change', syncStartModeOptions);
2077
- document.getElementById('resumeSelect').addEventListener('change', toggleResumeCustom);
2078
- syncStartModeOptions();
2079
-
2080
- document.getElementById('startForm').addEventListener('submit', async (event) => {
2081
- event.preventDefault();
2082
- if (startPending) return;
2083
- startPending = true;
2084
- document.getElementById('openStartDialog').disabled = true;
2085
- const submitButton = event.currentTarget.querySelector('button[type="submit"]');
2086
- submitButton.disabled = true;
2087
- const errorElement = document.getElementById('startDialogError');
2088
- const projectRoot = currentProject;
2089
- errorElement.hidden = true;
2090
- try {
2091
- const kind = document.getElementById('kind').value;
2092
- const mode = document.getElementById('startMode').value;
2093
- const sel = document.getElementById('resumeSelect');
2094
- const resume = (sel.value && sel.value !== '__custom__')
2095
- ? sel.value
2096
- : document.getElementById('resumeArg').value.trim();
2097
- if (mode === 'resume' && !resume) {
2098
- document.getElementById('resumeArg').focus();
2099
- return;
2100
- }
2101
- const payload = { kind, mode };
2102
- if (kind === 'codex' && document.getElementById('transport').value === 'app-server') {
2103
- payload.transport = 'app-server';
2104
- if (mode === 'resume') {
2105
- if (!document.getElementById('appServerResumeConfirm').checked) throw new Error(tr('handoff.resumeConfirm'));
2106
- payload.handoffConfirmed = true;
2107
- }
2108
- }
2109
- if (mode === 'resume') payload.resume = resume;
2110
- const data = await api('/api/sessions', { method: 'POST', body: JSON.stringify(payload) });
2111
- if (currentProject !== projectRoot) return;
2112
- await refreshSessions();
2113
- if (currentProject !== projectRoot || startDialog.hidden) return;
2114
- closeDialog(startDialog);
2115
- connectManaged(data.session.id);
2116
- } catch (error) {
2117
- errorElement.textContent = tr('error.' + error.code, error.detail || error.message);
2118
- errorElement.hidden = false;
2119
- }
2120
- finally { startPending = false; submitButton.disabled = false; document.getElementById('openStartDialog').disabled = false; }
2121
- });
2122
-
2123
- function activePeerInfo() {
2124
- if (!active) return null;
2125
- const session = sessions.find((s) => s.id === active) || { id: active, kind: 'other', role: 'peer' };
2126
- return {
2127
- session,
2128
- peerId: sessionPeerId(session) || active,
2129
- // The action token is delivered via the terminal WS snapshot frame, not
2130
- // the session list (net-05); read it from the per-session store.
2131
- actionToken: sessionActionTokens.get(active) || ''
2132
- };
2133
- }
2134
-
2135
- function terminalShellQuote(value) {
2136
- return "'" + String(value).split("'").join(${SHELL_APOSTROPHE_ESCAPE}) + "'";
2137
- }
2138
-
2139
- function terminalCommandForAction(action, info) {
2140
- const session = info.session || {};
2141
- const peerId = terminalShellQuote(info.peerId);
2142
- const lines = {
2143
- register: \`hcc register --peer \${peerId} --kind \${terminalShellQuote(session.kind || 'other')} --role \${terminalShellQuote(session.role || 'peer')}\`,
2144
- inbox: \`hcc msg inbox --peer \${peerId}\`,
2145
- 'task-next': \`hcc task next --peer \${peerId}\`,
2146
- state: \`hcc state --peer \${peerId}\`,
2147
- status: \`hcc status --peer \${peerId}\`,
2148
- heartbeat:\`hcc heartbeat --peer \${peerId} --renew-locks\`
2149
- };
2150
- return lines[action] || lines.status;
2151
- }
2152
-
2153
- function formatActionResult(result) {
2154
- if (!result) return '';
2155
- const data = result.data || {};
2156
- const error = result.error || data.error;
2157
- if (error) {
2158
- const code = String(result.errorCode || '').toLowerCase();
2159
- return [
2160
- tr('error.' + code, tr('requestFailed')),
2161
- result.errorCode ? '[' + result.errorCode + ']' : '',
2162
- tr('details') + ': ' + error
2163
- ].join('\\n');
2164
- }
2165
- if (result.action === 'status') {
2166
- const tasks = (data.tasks || []).map((row) => statusText(row.status) + ': ' + row.n).join(', ') || tr('none');
2167
- return [
2168
- data.root ? tr('root') + ': ' + data.root : '',
2169
- tr('peers') + ' · ' + tr('active') + ': ' + (data.active_peers ?? 0) + ' · ' + tr('stale') + ': ' + (data.stale_peers ?? 0),
2170
- tr('tasks') + ': ' + tasks,
2171
- tr('locks') + ' · ' + tr('active') + ': ' + (data.active_locks ?? 0),
2172
- tr('unread') + ': ' + (data.unread ?? 0)
2173
- ].filter(Boolean).join('\\n');
2174
- }
2175
- if (result.action === 'state') {
2176
- const automation = data.automation || {};
2177
- const next = automation.next_action || {};
2178
- return [
2179
- tr('phase') + ': ' + statusText(automation.phase || 'idle'),
2180
- tr('next') + ': ' + (next.command || (next.kind && next.kind !== 'none' ? tr('action.' + next.kind, next.kind) : tr('none'))),
2181
- tr('why') + ': ' + (next.reason || tr('noImmediateAction')),
2182
- ...(automation.warnings || []).map((w) => tr('warnings') + ': ' + w)
2183
- ].join('\\n');
2184
- }
2185
- if (result.action === 'inbox') {
2186
- const messages = data.messages || [];
2187
- return messages.length
2188
- ? messages.map((m) => '#' + m.id + ' ' + m.sender + ' -> ' + (m.recipient || tr('all')) + ': ' + m.body).join('\\n')
2189
- : tr('noMessages');
2190
- }
2191
- if (result.action === 'task-next') {
2192
- return data.task
2193
- ? tr(data.current ? 'current' : 'claimed') + ': #' + data.task.id + ' ' + data.task.title + ' (' + statusText(data.task.status) + ')'
2194
- : tr('noPendingTasks');
2195
- }
2196
- if (result.action === 'task-takeover') {
2197
- return data.task ? '#' + data.task.id + ' ' + data.task.title + ' · ' + tr('owner') + ': ' + data.task.owner : tr('noTasks');
2198
- }
2199
- if (result.action === 'lock-acquire') {
2200
- return data.lock ? tr('locked') + ': ' + lockLabel(data.lock) + ' · ' + tr('by') + ': ' + data.lock.owner : tr('noActiveLocks');
2201
- }
2202
- if (result.action === 'lock-release') {
2203
- return data.result ? tr(data.result.released ? 'lockReleased' : 'noLock') + ': ' + lockLabel(data.result) : tr('noActiveLocks');
2204
- }
2205
- if (result.action === 'heartbeat') {
2206
- return [
2207
- tr('peer') + ': ' + (data.peer || result.peer || tr('unknown')),
2208
- data.status ? tr('status') + ': ' + statusText(data.status) : '',
2209
- tr('renewedLocks') + ': ' + (data.renewed ?? 0)
2210
- ].filter(Boolean).join('\\n');
2211
- }
2212
- if (result.action === 'register') {
2213
- const peer = data.peer || {};
2214
- return [
2215
- tr('registered') + ': ' + (peer.id || result.peer || tr('unknown')),
2216
- tr('kind') + ': ' + (peer.kind || tr('unknown')),
2217
- tr('status') + ': ' + statusText(peer.status),
2218
- peer.worktree ? tr('cwd') + ': ' + peer.worktree : ''
2219
- ].filter(Boolean).join('\\n');
2220
- }
2221
- return result.summary || JSON.stringify(result, null, 2);
2222
- }
2223
-
2224
- function showActionResult(result) {
2225
- lastActionResult = result;
2226
- const panel = document.getElementById('actionResult');
2227
- const action = String(result.action || '');
2228
- const label = action ? tr('action.' + action, tr(action, action)) : tr('actionResult');
2229
- document.getElementById('actionResultTitle').textContent = label + (result.peer ? ' · ' + result.peer : '');
2230
- document.getElementById('actionResultBody').textContent = formatActionResult(result);
2231
- panel.hidden = false;
2232
- }
2233
-
2234
- async function runPeerAction(action) {
2235
- const info = activePeerInfo();
2236
- if (!info) return;
2237
- const readOnly = ['status', 'state', 'inbox'].includes(action);
2238
- const payload = action === 'register'
2239
- ? { kind: info.session.kind || 'other', role: info.session.role || 'peer', worktree: info.session.cwd || currentProject }
2240
- : action === 'heartbeat'
2241
- ? { renew_locks: true }
2242
- : {};
2243
- if (!readOnly) { payload.action_token = info.actionToken; payload.epoch = controlEpoch(); }
2244
- const result = await api('/api/peers/' + encodeURIComponent(info.peerId) + '/actions/' + encodeURIComponent(action), readOnly
2245
- ? {}
2246
- : { method: 'POST', body: JSON.stringify(payload) });
2247
- showActionResult(result);
2248
- await Promise.all([refreshSessions(), refreshDetected(), refreshCurrentState()]);
2249
- }
2250
-
2251
- document.querySelectorAll('[data-action]').forEach((button) => {
2252
- button.addEventListener('click', () => {
2253
- closeActionsMenu();
2254
- runPeerAction(button.dataset.action).catch((err) => {
2255
- handleUiError(err, button.dataset.action);
2256
- });
2257
- });
2258
- });
2259
- document.querySelectorAll('[data-terminal-action]').forEach((button) => {
2260
- button.addEventListener('click', () => {
2261
- const info = activePeerInfo();
2262
- if (!info) return;
2263
- sendLine(terminalCommandForAction(button.dataset.terminalAction, info));
2264
- closeActionsMenu();
2265
- });
2266
- });
2267
- document.getElementById('logoutBtn').addEventListener('click', async () => {
2268
- closeActionsMenu();
2269
- try {
2270
- const res = await fetch('/logout', { method: 'POST', headers });
2271
- if (!res.ok) throw new Error(tr('requestFailed'));
2272
- // Stop reconnect only after the server confirms revocation.
2273
- loggedOut = true;
2274
- window.hccWorkspace?.destroy();
2275
- try { sessionStorage.setItem('hcc_logged_out', '1'); } catch {}
2276
- location.replace('/');
2277
- } catch (error) { handleUiError(error, 'logout'); }
2278
- });
2279
- document.getElementById('actionResultClose').addEventListener('click', () => {
2280
- document.getElementById('actionResult').hidden = true;
2281
- });
2282
-
2283
- // ── Stop confirmation dialog ───────────────────────────────────────
2284
- const stopDialog = document.getElementById('stopDialog');
2285
- const stopConfirmBtn = document.getElementById('stopConfirmBtn');
2286
- const stopCancelBtn = document.getElementById('stopCancelBtn');
2287
- const stopKillCb = document.getElementById('stopKillCb');
2288
- const settingsDialog = document.getElementById('settingsDialog');
2289
- const projectDialog = document.getElementById('projectDialog');
2290
- const startDialog = document.getElementById('startDialog');
2291
- let dialogReturnFocus = null;
2292
- function openDialog(dialog, initialFocus) {
2293
- closeActionsMenu();
2294
- dialogReturnFocus = document.activeElement;
2295
- dialog.hidden = false;
2296
- document.querySelector('.app').inert = true;
2297
- document.querySelector('.global-header').inert = true;
2298
- document.querySelector('.mobile-nav').inert = true;
2299
- initialFocus?.focus();
2300
- }
2301
- function closeDialog(dialog) {
2302
- dialog.hidden = true;
2303
- document.querySelector('.app').inert = false;
2304
- document.querySelector('.global-header').inert = false;
2305
- document.querySelector('.mobile-nav').inert = false;
2306
- if (dialogReturnFocus?.isConnected && !dialogReturnFocus.disabled && dialogReturnFocus.getClientRects().length) dialogReturnFocus.focus();
2307
- else document.getElementById('settingsBtn').focus();
2308
- dialogReturnFocus = null;
2309
- }
2310
- function updateStopDialogTitle() {
2311
- const peerId = stopDialog._action === 'detected' ? stopDialog._peerId : (sessionPeerId(sessions.find((s) => s.id === active)) || active);
2312
- document.getElementById('stopDialogTitle').textContent = tr('stop') + ' ' + (peerId || '') + '?';
2313
- if (stopDialog._action === 'detected') {
2314
- document.getElementById('stopDialogMeta').textContent = tr('detectedPeer');
2315
- stopConfirmBtn.textContent = tr('stop');
2316
- } else {
2317
- const session = sessions.find((item) => item.id === active) || {};
2318
- document.getElementById('stopDialogTitle').textContent = tr(session.type === 'tmux' ? 'handoff.detach' : 'handoff.terminate') + ' ' + (peerId || '') + '?';
2319
- stopConfirmBtn.textContent = tr(session.type === 'tmux' ? 'handoff.detach' : 'handoff.terminate');
2320
- }
2321
- }
2322
- function openStartDialog() {
2323
- if (startPending) return;
2324
- document.getElementById('startProjectPath').textContent = currentProject;
2325
- document.getElementById('startDialogError').hidden = true;
2326
- document.getElementById('appServerResumeConfirm').checked = false;
2327
- syncStartModeOptions();
2328
- openDialog(startDialog, document.getElementById('kind'));
2329
- }
2330
- document.getElementById('openStartDialog').addEventListener('click', openStartDialog);
2331
- document.getElementById('openHistoryDialog').addEventListener('click', () => window.hccHistory.open());
2332
- document.getElementById('openReviewDialog').addEventListener('click', () => window.hccReview.open());
2333
- document.getElementById('startDialogClose').addEventListener('click', () => closeDialog(startDialog));
2334
- document.getElementById('startCancelBtn').addEventListener('click', () => closeDialog(startDialog));
2335
- document.getElementById('openProjectDialog').addEventListener('click', () => {
2336
- document.getElementById('projectDialogError').hidden = true;
2337
- openDialog(projectDialog, document.getElementById('projectPath'));
2338
- });
2339
- document.getElementById('projectDialogClose').addEventListener('click', () => closeDialog(projectDialog));
2340
- document.getElementById('projectCancelBtn').addEventListener('click', () => closeDialog(projectDialog));
2341
- document.getElementById('settingsBtn').addEventListener('click', () => openDialog(settingsDialog, document.getElementById('langSelect')));
2342
- document.getElementById('settingsClose').addEventListener('click', () => closeDialog(settingsDialog));
2343
- [settingsDialog, stopDialog, projectDialog, startDialog, document.getElementById('historyDialog'), document.getElementById('reviewDialog'), document.getElementById('commandDialog')].forEach((dialog) => {
2344
- dialog.addEventListener('click', (event) => { if (event.target === dialog) closeDialog(dialog); });
2345
- dialog.addEventListener('keydown', (event) => {
2346
- if (event.isComposing) return;
2347
- if (event.key === 'Escape') { event.preventDefault(); closeDialog(dialog); return; }
2348
- if (event.key !== 'Tab') return;
2349
- const controls = [...dialog.querySelectorAll('button, select, input, textarea, [tabindex="0"]')].filter((el) => !el.disabled && el.tabIndex >= 0 && el.getClientRects().length);
2350
- const first = controls[0], last = controls[controls.length - 1];
2351
- if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
2352
- else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
2353
- });
2354
- });
2355
- function preserveFocus(root) {
2356
- const focused = document.activeElement;
2357
- if (!root.contains(focused)) return () => {};
2358
- const key = focused.dataset.focusKey;
2359
- const id = focused.id;
2360
- const section = focused.closest('[data-section]')?.dataset.section;
2361
- const action = focused.dataset.action;
2362
- const peerId = focused.dataset.id;
2363
- return () => {
2364
- const target = [...root.querySelectorAll('button, select, input')].find((el) =>
2365
- 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);
2366
- target?.focus({ preventScroll: true });
2367
- };
2368
- }
2369
-
2370
- // ── Actions dropdown (declutters the toolbar) ─────────────────────────
2371
- const actionsBtn = document.getElementById('actionsBtn');
2372
- const actionsMenu = document.getElementById('actionsMenu');
2373
- function closeActionsMenu() {
2374
- actionsMenu.hidden = true;
2375
- actionsBtn.setAttribute('aria-expanded', 'false');
2376
- }
2377
- actionsBtn.addEventListener('click', (e) => {
2378
- e.stopPropagation();
2379
- const open = actionsMenu.hidden;
2380
- actionsMenu.hidden = !open;
2381
- actionsBtn.setAttribute('aria-expanded', String(open));
2382
- if (open) {
2383
- // Position as a fixed layer so it escapes the toolbar/main overflow clip.
2384
- const r = actionsBtn.getBoundingClientRect();
2385
- actionsMenu.style.top = (r.bottom + 6) + 'px';
2386
- actionsMenu.style.left = Math.max(8, r.right - actionsMenu.offsetWidth) + 'px';
2387
- }
2388
- });
2389
- document.addEventListener('click', (e) => {
2390
- if (!actionsMenu.hidden && !e.target.closest('.menu-wrap')) closeActionsMenu();
2391
- });
2392
- document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeActionsMenu(); });
2393
-
2394
- // ── Collapsible side panels ───────────────────────────────────────────
2395
- const appEl = document.querySelector('.app');
2396
- const narrowViewport = window.matchMedia('(max-width: 1099px)');
2397
- const focusBtn = document.getElementById('focusBtn');
2398
- function syncFocusButton() {
2399
- const enabled = appEl.classList.contains('focus-mode');
2400
- focusBtn.textContent = tr(enabled ? 'exitFocus' : 'focusMode');
2401
- focusBtn.setAttribute('aria-pressed', String(enabled));
2402
- }
2403
- function syncPanelAccess() {
2404
- const focus = appEl.classList.contains('focus-mode') && !narrowViewport.matches;
2405
- for (const [selector, view, side] of [['.sidebar', 'sessions', 'left'], ['.main', 'terminal', null], ['.inspector', 'state', 'right']]) {
2406
- appEl.querySelector(selector).inert = paneMode ? view !== 'terminal' : narrowViewport.matches
2407
- ? appEl.dataset.view !== view || view === 'terminal' && appEl.classList.contains('workspace-open') && document.getElementById('workspace').dataset.activePane === 'secondary'
2408
- : Boolean(side && (focus || sideIsCollapsed(side) || side === 'right' && appEl.classList.contains('workspace-open')));
2409
- }
2410
- document.getElementById('workspaceSecondPane').inert = narrowViewport.matches && (appEl.dataset.view !== 'terminal' || document.getElementById('workspace').dataset.activePane !== 'secondary');
2411
- }
2412
- function setMobileView(view) {
2413
- if (paneMode) view = 'terminal';
2414
- if (!['sessions', 'terminal', 'state'].includes(view)) return;
2415
- appEl.dataset.view = view;
2416
- document.querySelectorAll('.mobile-nav [data-view]').forEach((button) => {
2417
- if (button.dataset.view === view) button.setAttribute('aria-current', 'page');
2418
- else button.removeAttribute('aria-current');
2419
- });
2420
- syncPanelAccess();
2421
- closeActionsMenu();
2422
- requestAnimationFrame(() => resizeTerm());
2423
- }
2424
- document.querySelectorAll('.mobile-nav [data-view]').forEach((button) => button.addEventListener('click', () => setMobileView(button.dataset.view)));
2425
- focusBtn.addEventListener('click', () => {
2426
- appEl.classList.toggle('focus-mode');
2427
- syncFocusButton();
2428
- syncPanelAccess();
2429
- setTimeout(resizeTerm, 200);
2430
- });
2431
- const toggleLeftBtn = document.getElementById('toggleLeft');
2432
- const toggleRightBtn = document.getElementById('toggleRight');
2433
- const resizeLeftHandle = document.getElementById('resizeLeft');
2434
- const resizeRightHandle = document.getElementById('resizeRight');
2435
- const sideDefaults = { left: 320, right: 360 };
2436
- const sideMin = { left: 220, right: 240 };
2437
- const sideMax = { left: 560, right: 640 };
2438
- const sideWidthKey = { left: 'hcc.sidebar.left.width', right: 'hcc.sidebar.right.width' };
2439
- const dragState = { side: null, startX: 0, startWidth: 0, moved: false, suppressClick: false };
2440
- function sideIsCollapsed(side) {
2441
- return appEl.classList.contains(side + '-collapsed') || hccUi.safeGet('hcc.collapse.' + side) === '1';
2442
- }
2443
- function storedSideWidth(side) {
2444
- const raw = Number(hccUi.safeGet(sideWidthKey[side]));
2445
- return Number.isFinite(raw) && raw > 0 ? raw : sideDefaults[side];
2446
- }
2447
- function effectiveOppositeWidth(side) {
2448
- const opposite = side === 'left' ? 'right' : 'left';
2449
- return sideIsCollapsed(opposite) ? 0 : storedSideWidth(opposite);
2450
- }
2451
- function clampSideWidth(side, width) {
2452
- const viewport = Math.max(640, window.innerWidth || appEl.clientWidth || 0);
2453
- const opposite = effectiveOppositeWidth(side);
2454
- const maxByViewport = Math.max(sideMin[side], viewport - opposite - 280);
2455
- return Math.max(sideMin[side], Math.min(sideMax[side], maxByViewport, Math.round(width)));
2456
- }
2457
- function readSideWidth(side) {
2458
- return clampSideWidth(side, storedSideWidth(side));
2459
- }
2460
- function setSideWidth(side, width, persist = true) {
2461
- const value = clampSideWidth(side, width);
2462
- appEl.style.setProperty('--' + side + '-width', value + 'px');
2463
- if (persist) hccUi.safeSet(sideWidthKey[side], String(value));
2464
- const handle = document.getElementById(side === 'left' ? 'resizeLeft' : 'resizeRight');
2465
- handle.setAttribute('aria-valuemin', String(sideMin[side]));
2466
- handle.setAttribute('aria-valuemax', String(clampSideWidth(side, sideMax[side])));
2467
- handle.setAttribute('aria-valuenow', String(value));
2468
- return value;
2469
- }
2470
- function applySideWidths() {
2471
- setSideWidth('left', readSideWidth('left'), false);
2472
- setSideWidth('right', readSideWidth('right'), false);
2473
- }
2474
- function syncToggleIcons() {
2475
- const l = appEl.classList.contains('left-collapsed');
2476
- const r = appEl.classList.contains('right-collapsed');
2477
- toggleLeftBtn.textContent = l ? '⟩' : '⟨';
2478
- toggleLeftBtn.title = (l ? tr('show') : tr('collapse')) + ' ' + tr('sidebar') + '; ' + tr('dragToResize');
2479
- toggleRightBtn.textContent = r ? '⟨' : '⟩';
2480
- toggleRightBtn.title = (r ? tr('show') : tr('collapse')) + ' ' + tr('statePanel') + '; ' + tr('dragToResize');
2481
- }
2482
- function applyCollapseState() {
2483
- appEl.classList.toggle('left-collapsed', hccUi.safeGet('hcc.collapse.left') === '1');
2484
- appEl.classList.toggle('right-collapsed', hccUi.safeGet('hcc.collapse.right') === '1');
2485
- applySideWidths();
2486
- syncToggleIcons();
2487
- syncPanelAccess();
2488
- }
2489
- function toggleSide(side) {
2490
- const cls = side + '-collapsed';
2491
- const on = appEl.classList.toggle(cls);
2492
- hccUi.safeSet('hcc.collapse.' + side, on ? '1' : '0');
2493
- applySideWidths();
2494
- syncToggleIcons();
2495
- syncPanelAccess();
2496
- // Refit the terminal after the grid transition so xterm uses the new width.
2497
- setTimeout(() => { try { resizeTerm(); } catch {} }, 200);
2498
- }
2499
- function beginSideDrag(side, event) {
2500
- if (event.button !== undefined && event.button !== 0) return;
2501
- dragState.side = side;
2502
- dragState.startX = event.clientX;
2503
- dragState.startWidth = readSideWidth(side);
2504
- dragState.moved = false;
2505
- dragState.suppressClick = false;
2506
- appEl.classList.add('resizing');
2507
- event.currentTarget.setPointerCapture?.(event.pointerId);
2508
- event.preventDefault();
2509
- }
2510
- function moveSideDrag(event) {
2511
- if (!dragState.side) return;
2512
- const delta = event.clientX - dragState.startX;
2513
- if (!dragState.moved && Math.abs(delta) <= 3) return;
2514
- dragState.moved = true;
2515
- const width = dragState.side === 'left'
2516
- ? dragState.startWidth + delta
2517
- : dragState.startWidth - delta;
2518
- setSideWidth(dragState.side, width);
2519
- if (dragState.side === 'left') {
2520
- appEl.classList.remove('left-collapsed');
2521
- hccUi.safeSet('hcc.collapse.left', '0');
2522
- } else {
2523
- appEl.classList.remove('right-collapsed');
2524
- hccUi.safeSet('hcc.collapse.right', '0');
2525
- }
2526
- syncToggleIcons();
2527
- syncPanelAccess();
2528
- try { resizeTerm(); } catch {}
2529
- event.preventDefault();
2530
- }
2531
- function endSideDrag(event) {
2532
- if (!dragState.side) return;
2533
- dragState.suppressClick = dragState.moved;
2534
- event.currentTarget.releasePointerCapture?.(event.pointerId);
2535
- dragState.side = null;
2536
- appEl.classList.remove('resizing');
2537
- setTimeout(() => { dragState.suppressClick = false; }, 0);
2538
- try { resizeTerm(); } catch {}
2539
- }
2540
- function bindSideHandle(handle, side, clickAction = null) {
2541
- if (handle.getAttribute('role') === 'separator') handle.addEventListener('keydown', (event) => {
2542
- if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
2543
- event.preventDefault();
2544
- const step = event.shiftKey ? 40 : 16;
2545
- const delta = (event.key === 'ArrowRight' ? 1 : -1) * (side === 'left' ? 1 : -1) * step;
2546
- const width = event.key === 'Home' ? sideMin[side] : event.key === 'End' ? sideMax[side] : readSideWidth(side) + delta;
2547
- appEl.classList.remove(side + '-collapsed');
2548
- hccUi.safeSet('hcc.collapse.' + side, '0');
2549
- setSideWidth(side, width);
2550
- syncToggleIcons();
2551
- syncPanelAccess();
2552
- setTimeout(resizeTerm, 200);
2553
- });
2554
- handle.addEventListener('pointerdown', (event) => beginSideDrag(side, event));
2555
- handle.addEventListener('pointermove', moveSideDrag);
2556
- handle.addEventListener('pointerup', endSideDrag);
2557
- handle.addEventListener('pointercancel', endSideDrag);
2558
- handle.addEventListener('click', (event) => {
2559
- if (dragState.suppressClick) {
2560
- event.preventDefault();
2561
- return;
2562
- }
2563
- if (clickAction) clickAction(side);
2564
- });
2565
- }
2566
- bindSideHandle(resizeLeftHandle, 'left');
2567
- bindSideHandle(resizeRightHandle, 'right');
2568
- bindSideHandle(toggleLeftBtn, 'left', toggleSide);
2569
- bindSideHandle(toggleRightBtn, 'right', toggleSide);
2570
- window.addEventListener('resize', () => {
2571
- applySideWidths();
2572
- syncPanelAccess();
2573
- closeActionsMenu();
2574
- setTimeout(() => { try { resizeTerm(); } catch {} }, 50);
2575
- });
2576
- applyCollapseState();
2577
-
2578
- document.getElementById('stopBtn').addEventListener('click', () => {
2579
- if (!active || !canControl()) return;
2580
- const session = sessions.find((s) => s.id === active) || {};
2581
- if (session.type === 'native') return;
2582
- document.getElementById('stopDialogTitle').textContent = tr(session.type === 'tmux' ? 'handoff.detach' : 'handoff.terminate') + ' ' + (sessionPeerId(session) || active) + '?';
2583
- stopConfirmBtn.textContent = tr(session.type === 'tmux' ? 'handoff.detach' : 'handoff.terminate');
2584
- stopKillCb.checked = false;
2585
- const meta = sessionMetaText(session);
2586
- document.getElementById('stopDialogMeta').textContent = [meta, tr(session.type === 'tmux' ? 'handoff.detachHelp' : 'handoff.terminateHelp')].filter(Boolean).join(' · ');
2587
- stopKillCb.parentElement.parentElement.hidden = session.type !== 'tmux';
2588
- stopDialog._action = null; stopDialog._peerId = null;
2589
- stopDialog._sessionId = active;
2590
- stopDialog._projectRoot = currentProject;
2591
- openDialog(stopDialog, stopCancelBtn);
2592
- });
2593
- document.getElementById('refreshBtn').addEventListener('click', () => {
2594
- refreshVisibleData().catch(handleUiError);
2595
- });
2596
- document.getElementById('stateRetry').addEventListener('click', () => {
2597
- refreshVisibleData().catch(handleUiError);
2598
- });
2599
-
2600
- function terminalFindAvailable() {
2601
- const session = sessions.find(item => item.id === active);
2602
- return Boolean(active && activeType === 'managed' && session && session.type !== 'app-server' && session.type !== 'native');
2603
- }
2604
- function focusSessionsSearch() {
2605
- if (paneMode) { window.hccCommandPalette?.open(); return; }
2606
- if (narrowViewport.matches) setMobileView('sessions');
2607
- else {
2608
- appEl.classList.remove('focus-mode');
2609
- hccUi.safeSet('hcc.collapse.left', '0');
2610
- applyCollapseState(); syncFocusButton();
2611
- }
2612
- document.getElementById('sessionSearch').focus();
2613
- }
2614
- const shortcutPrefix = /Mac|iPhone|iPad/.test(navigator.platform || '') ? '⌘ ⇧ ' : 'Ctrl Shift ';
2615
- window.hccTerminalFindHost = {
2616
- term, addon: searchAddon, available: terminalFindAvailable,
2617
- subject: () => currentProject + ':' + (active || ''),
2618
- showTerminal: () => { if (!paneMode) window.hccWorkspace?.primary(); setMobileView('terminal'); }
2619
- };
2620
- ${terminalFindScript()}
2621
- window.hccCommandHost = {
2622
- openDialog, closeDialog, error: handleUiError,
2623
- modalOpen: () => Boolean(document.querySelector('.dialog-overlay:not([hidden])')),
2624
- terminalAvailable: terminalFindAvailable, focusSessions: focusSessionsSearch,
2625
- commands() {
2626
- const page = tr('commands.pages');
2627
- const commands = [
2628
- { id:'new', label:tr('newSession'), group:page, enabled:!startPending, run:openStartDialog },
2629
- { id:'sessions', label:tr('sessionSearch'), group:page, shortcut:shortcutPrefix+'L', run:focusSessionsSearch },
2630
- { id:'find', label:tr('find.title'), group:page, shortcut:shortcutPrefix+'F', enabled:terminalFindAvailable(), run:() => window.hccTerminalFind.open() },
2631
- { id:'settings', label:tr('settings'), group:page, run:() => document.getElementById('settingsBtn').click() },
2632
- { id:'project-add', label:tr('addProject'), group:page, enabled:!document.getElementById('openProjectDialog').disabled, run:() => document.getElementById('openProjectDialog').click() },
2633
- { id:'refresh', label:tr('commands.refresh'), group:page, run:refreshVisibleData },
2634
- { id:'focus', label:tr(appEl.classList.contains('focus-mode') ? 'exitFocus' : 'focusMode'), group:page, enabled:!narrowViewport.matches, run:() => focusBtn.click() },
2635
- { id:'history', label:tr('aux.history'), group:page, run:() => document.getElementById('openHistoryDialog').click() },
2636
- { id:'review', label:tr('aux.review'), group:page, enabled:!document.getElementById('openReviewDialog').disabled, run:() => document.getElementById('openReviewDialog').click() }
2637
- ];
2638
- for (const view of ['overview','tasks','messages','events']) {
2639
- commands.push({ id:'view:'+view, label:tr('commands.view.'+view), group:page, run:() => {
2640
- if (narrowViewport.matches) setMobileView('state');
2641
- else { if (window.hccWorkspace?.isOpen) window.hccWorkspace.close(); appEl.classList.remove('focus-mode'); hccUi.safeSet('hcc.collapse.right','0'); applyCollapseState(); syncFocusButton(); }
2642
- document.getElementById('stateTab-'+view).click(); document.getElementById('stateTab-'+view).focus();
2643
- }});
2644
- }
2645
- for (const project of projects) {
2646
- commands.push({ id:'project:'+project.root, label:project.name || project.root, detail:project.root, group:tr('commands.projects'), enabled:project.root!==currentProject, run:() => {
2647
- document.getElementById('projectSelect').value = project.root;
2648
- return switchProject(project.root);
2649
- }});
2650
- }
2651
- const root = currentProject;
2652
- for (const session of sessions) {
2653
- 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); }});
2654
- }
2655
- for (const peer of detected) {
2656
- 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); }});
2657
- }
2658
- if (paneMode) return commands.filter(command => ['new','sessions','find','settings','history','review','refresh'].includes(command.id) || command.id.startsWith('session:'));
2659
- 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()});
2660
- return commands;
2661
- }
2662
- };
2663
- ${commandPaletteScript()}
2664
- window.hccWorkspaceHost = {
2665
- embedded:paneMode, app:appEl, primary:document.getElementById('workspacePrimaryPane'),
2666
- project:() => currentProject, active:() => active, sessions:() => sessions, ready:() => sessionsLoaded,
2667
- esc, showTerminal:() => setMobileView('terminal'), layoutChanged:() => { syncPanelAccess(); requestAnimationFrame(resizeTerm); }
2668
- };
2669
- ${workspaceScript()}
2670
- term.attachCustomKeyEventHandler(event => !event.defaultPrevented && !window.hccCommandPalette.captures(event));
2671
- hccUi.bindControls();
2672
- window.addEventListener('hcc:preferences', (event) => {
2673
- if (event.detail.changed.some((key) => key === 'language' || key === 'resolvedLanguage')) applyLanguage();
2674
- syncFocusButton();
2675
- syncToggleIcons();
2676
- applyTerminalPreferences();
2677
- });
2678
- applyTerminalPreferences();
2679
- applyLanguage();
2680
- loadProjects().then(() => Promise.all([refreshSessions(), refreshDetected(), refreshState()])).then(() => {
2681
- if (!active && !activeDetected) restoreSelection();
2682
- }).catch((err) => {
2683
- connText('error');
2684
- console.error(err);
2685
- });
2686
- // ── Auto-poll state ──────────────────────────────────────────────────
2687
- setInterval(() => {
2688
- refreshVisibleData().catch(console.error);
2689
- }, 3000);
2690
- setInterval(() => {
2691
- refreshProjectsQuietly().catch(console.error);
2692
- }, 8000);
2693
-
2694
- stopCancelBtn.addEventListener('click', () => closeDialog(stopDialog));
2695
-
2696
- stopConfirmBtn.addEventListener('click', async () => {
2697
- closeDialog(stopDialog);
2698
- try {
2699
- const killTmux = stopKillCb.checked;
2700
- if (stopDialog._action === 'detected') {
2701
- const peerId = stopDialog._peerId;
2702
- stopDialog._action = null; stopDialog._peerId = null;
2703
- if (!peerId) return;
2704
- await api('/api/detected/' + encodeURIComponent(peerId) + '/stop', { method: 'POST', body: JSON.stringify({ kill_tmux: killTmux }) });
2705
- await Promise.all([refreshSessions(), refreshDetected()]);
2706
- } else {
2707
- if (!active || !canControl()) return;
2708
- if (stopDialog._sessionId !== active || stopDialog._projectRoot !== currentProject) throw new Error(tr('error.subject_changed'));
2709
- await api('/api/sessions/' + encodeURIComponent(active) + '/stop', { method: 'POST', body: JSON.stringify({ kill_tmux: killTmux, action_token: sessionActionTokens.get(active) || '', epoch: controlEpoch() }) });
2710
- await refreshSessions();
2711
- }
2712
- } catch (error) { handleUiError(error); }
2713
- });
2714
- </script>
775
+ <script type="module" src="/assets/web/browser/core.mjs"></script>
2715
776
  </body>
2716
777
  </html>`;
2717
778
  }