@oar-sh/oar 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (293) hide show
  1. package/.github/extensions/web-relay/README.md +190 -0
  2. package/.github/extensions/web-relay/extension.mjs +972 -0
  3. package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
  4. package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
  5. package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
  6. package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
  7. package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
  8. package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
  9. package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
  10. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
  11. package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
  12. package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
  13. package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
  14. package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
  15. package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
  16. package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
  17. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
  18. package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
  19. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
  20. package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
  21. package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
  22. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
  23. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
  24. package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
  25. package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
  26. package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
  27. package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
  28. package/CHANGELOG.md +25 -0
  29. package/LICENSE +21 -0
  30. package/README.md +717 -0
  31. package/bin/bat/oar.bat +46 -0
  32. package/bin/oar.js +665 -0
  33. package/package.json +68 -0
  34. package/server/README.md +1379 -0
  35. package/server/claude-worker/claude-attachments.mjs +134 -0
  36. package/server/claude-worker/claude-preview-tool.mjs +57 -0
  37. package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
  38. package/server/claude-worker/claude-session-process.mjs +2391 -0
  39. package/server/claude-worker/claude-session-test-harness.mjs +236 -0
  40. package/server/claude-worker/claude-session-worker.mjs +186 -0
  41. package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
  42. package/server/claude-worker/claude-turn-publisher.mjs +291 -0
  43. package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
  44. package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
  45. package/server/claude-worker/workflow-progress-digest.mjs +269 -0
  46. package/server/copilot-sdk-runtime.mjs +126 -0
  47. package/server/copilot-worker/copilot-attachments.mjs +97 -0
  48. package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
  49. package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
  50. package/server/copilot-worker/copilot-plan-board.mjs +110 -0
  51. package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
  52. package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
  53. package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
  54. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
  55. package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
  56. package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
  57. package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
  58. package/server/copilot-worker/fixtures/abort-turn.json +588 -0
  59. package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
  60. package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
  61. package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
  62. package/server/copilot-worker/fixtures/happy-turn.json +566 -0
  63. package/server/copilot-worker/fixtures/quota-turn.json +647 -0
  64. package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
  65. package/server/copilot-worker/fixtures/resume-turn.json +700 -0
  66. package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
  67. package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
  68. package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
  69. package/server/cursor-worker/cursor-attachments.mjs +88 -0
  70. package/server/cursor-worker/cursor-context-usage.mjs +85 -0
  71. package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
  72. package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
  73. package/server/cursor-worker/cursor-session-worker.mjs +145 -0
  74. package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
  75. package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
  76. package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
  77. package/server/db-schema.mjs +932 -0
  78. package/server/features.mjs +106 -0
  79. package/server/grok-worker/acp-client.mjs +434 -0
  80. package/server/grok-worker/acp-host-services.mjs +277 -0
  81. package/server/grok-worker/grok-context-usage.mjs +77 -0
  82. package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
  83. package/server/grok-worker/grok-session-worker.mjs +145 -0
  84. package/server/grok-worker/grok-turn-runner.mjs +523 -0
  85. package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
  86. package/server/migrations/0001-add-structured-answer.mjs +80 -0
  87. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
  88. package/server/migrations/0003-push-subscriptions.mjs +87 -0
  89. package/server/public/app/action-confirmations.js +267 -0
  90. package/server/public/app/activity-replay-state.mjs +149 -0
  91. package/server/public/app/api-client.js +1082 -0
  92. package/server/public/app/ask-user-view.js +545 -0
  93. package/server/public/app/attachments-view.js +2112 -0
  94. package/server/public/app/auto-compact-window-options.mjs +59 -0
  95. package/server/public/app/background-tasks-view.mjs +494 -0
  96. package/server/public/app/bootstrap.js +4347 -0
  97. package/server/public/app/claude-auth-ui.js +394 -0
  98. package/server/public/app/claude-thinking-options.mjs +38 -0
  99. package/server/public/app/cli-install-ui.js +455 -0
  100. package/server/public/app/code-copy.mjs +85 -0
  101. package/server/public/app/composer-attachment-cache.mjs +151 -0
  102. package/server/public/app/composer-control-state.mjs +107 -0
  103. package/server/public/app/composer-paste.mjs +132 -0
  104. package/server/public/app/context-tier-options.mjs +68 -0
  105. package/server/public/app/context-usage-view.mjs +338 -0
  106. package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
  107. package/server/public/app/conversation-preferences.mjs +91 -0
  108. package/server/public/app/conversation-provider-indicator.mjs +88 -0
  109. package/server/public/app/conversation-view.js +2898 -0
  110. package/server/public/app/cwd-menu-placement.mjs +85 -0
  111. package/server/public/app/cwd-picker.js +605 -0
  112. package/server/public/app/emoji-view.js +101 -0
  113. package/server/public/app/external-link-policy.mjs +80 -0
  114. package/server/public/app/file-preview-navigation.mjs +51 -0
  115. package/server/public/app/font-scaling.js +262 -0
  116. package/server/public/app/git-changes-view.js +339 -0
  117. package/server/public/app/git-diff-model.mjs +109 -0
  118. package/server/public/app/grok-auth-ui.js +387 -0
  119. package/server/public/app/image-downscale.mjs +174 -0
  120. package/server/public/app/in-flight-snapshot.mjs +40 -0
  121. package/server/public/app/infinite-loader.js +164 -0
  122. package/server/public/app/journal-view.js +1141 -0
  123. package/server/public/app/known-cwd-options.mjs +41 -0
  124. package/server/public/app/launch-cwd-path.mjs +23 -0
  125. package/server/public/app/live-message-dedupe.mjs +57 -0
  126. package/server/public/app/message-search-view.js +242 -0
  127. package/server/public/app/model-selector-options.mjs +31 -0
  128. package/server/public/app/model-vision-support.mjs +56 -0
  129. package/server/public/app/new-conversation-model-choice.mjs +67 -0
  130. package/server/public/app/plan-usage-view.mjs +307 -0
  131. package/server/public/app/preview-cards.mjs +273 -0
  132. package/server/public/app/preview-command.mjs +103 -0
  133. package/server/public/app/push-settings.js +370 -0
  134. package/server/public/app/pwa-install.js +456 -0
  135. package/server/public/app/question-schema-view.mjs +71 -0
  136. package/server/public/app/reasoning-effort-labels.mjs +42 -0
  137. package/server/public/app/relay-board-view.js +117 -0
  138. package/server/public/app/relay-dot-state.mjs +69 -0
  139. package/server/public/app/relay-error-ctas.mjs +52 -0
  140. package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
  141. package/server/public/app/relay-thoughts.mjs +98 -0
  142. package/server/public/app/relay-watchdog-policy.mjs +29 -0
  143. package/server/public/app/repo-browser-preferences.mjs +79 -0
  144. package/server/public/app/repo-browser-tree-state.mjs +97 -0
  145. package/server/public/app/router.js +657 -0
  146. package/server/public/app/selection-guard.mjs +93 -0
  147. package/server/public/app/settings-modal.js +1484 -0
  148. package/server/public/app/settings-tabs.js +129 -0
  149. package/server/public/app/slash-autocomplete.mjs +188 -0
  150. package/server/public/app/slash-commands.mjs +183 -0
  151. package/server/public/app/socket-handlers.js +703 -0
  152. package/server/public/app/status-store.mjs +242 -0
  153. package/server/public/app/status-view.mjs +315 -0
  154. package/server/public/app/store.js +1454 -0
  155. package/server/public/app/stream-state.mjs +73 -0
  156. package/server/public/app/streaming-dom-patch.mjs +23 -0
  157. package/server/public/app/sync-outbox.mjs +208 -0
  158. package/server/public/app/tap-actions.js +62 -0
  159. package/server/public/app/thread-order.mjs +54 -0
  160. package/server/public/app/tmux-inspector-view.js +422 -0
  161. package/server/public/app/transcript-separators.mjs +282 -0
  162. package/server/public/app-icon-192.png +0 -0
  163. package/server/public/app-icon-512.png +0 -0
  164. package/server/public/app-icon.svg +4 -0
  165. package/server/public/favicon.ico +0 -0
  166. package/server/public/index.html +5743 -0
  167. package/server/public/manifest.webmanifest +32 -0
  168. package/server/public/sw.js +307 -0
  169. package/server/relay-exit-codes.mjs +4 -0
  170. package/server/relay-self-restart.mjs +170 -0
  171. package/server/relay-tools.md +27 -0
  172. package/server/relay.mjs +1026 -0
  173. package/server/repositories/image-conversation-repository.mjs +317 -0
  174. package/server/repositories/message-repository.mjs +434 -0
  175. package/server/repositories/question-repository.mjs +161 -0
  176. package/server/repositories/session-repository.mjs +320 -0
  177. package/server/repositories/share-visibility-statements.mjs +24 -0
  178. package/server/routes/ask-user-routes.mjs +292 -0
  179. package/server/routes/cache-routes.mjs +44 -0
  180. package/server/routes/git-routes.mjs +92 -0
  181. package/server/routes/messages-routes-test-harness.mjs +111 -0
  182. package/server/routes/messages-routes.mjs +7107 -0
  183. package/server/routes/preview-routes.mjs +108 -0
  184. package/server/routes/push-routes.mjs +59 -0
  185. package/server/routes/relay-board-routes.mjs +268 -0
  186. package/server/routes/sessions-routes.mjs +6150 -0
  187. package/server/server-runtime.mjs +7463 -0
  188. package/server/server.js +24 -0
  189. package/server/services/ask-user-routing-service.mjs +117 -0
  190. package/server/services/cache-rebuild-service.mjs +236 -0
  191. package/server/services/claude-auth-service.mjs +578 -0
  192. package/server/services/claude-context-usage.mjs +240 -0
  193. package/server/services/claude-session-root-service.mjs +228 -0
  194. package/server/services/cli-install-service.mjs +1152 -0
  195. package/server/services/cli-process-runner.mjs +181 -0
  196. package/server/services/cloudflared-tunnel-service.mjs +358 -0
  197. package/server/services/context-snapshot-service.mjs +499 -0
  198. package/server/services/context-usage-view.mjs +160 -0
  199. package/server/services/conversation-preferences-service.mjs +208 -0
  200. package/server/services/cursor-dashboard-usage.mjs +217 -0
  201. package/server/services/cursor-session-root-service.mjs +62 -0
  202. package/server/services/delete-archive-service.mjs +154 -0
  203. package/server/services/drives-path-helpers.mjs +66 -0
  204. package/server/services/fixtures/claude-auth-stub.sh +62 -0
  205. package/server/services/fixtures/cli-install-stub.sh +75 -0
  206. package/server/services/fixtures/grok-stub.sh +86 -0
  207. package/server/services/generated-image-cleanup-service.mjs +114 -0
  208. package/server/services/git-changes-service.mjs +195 -0
  209. package/server/services/github-billing-usage.mjs +133 -0
  210. package/server/services/grok-auth-service.mjs +593 -0
  211. package/server/services/grok-billing-usage.mjs +120 -0
  212. package/server/services/image-context-service.mjs +106 -0
  213. package/server/services/image-operation-service.mjs +135 -0
  214. package/server/services/keyed-mutex-service.mjs +82 -0
  215. package/server/services/mime-sniffer.mjs +118 -0
  216. package/server/services/oar-cli-helpers.mjs +109 -0
  217. package/server/services/oar-state-migration-service.mjs +243 -0
  218. package/server/services/plan-usage-claude.mjs +387 -0
  219. package/server/services/plan-usage-contract.mjs +271 -0
  220. package/server/services/plan-usage-copilot.mjs +421 -0
  221. package/server/services/plan-usage-cursor.mjs +566 -0
  222. package/server/services/plan-usage-grok.mjs +436 -0
  223. package/server/services/plan-usage-service.mjs +487 -0
  224. package/server/services/preview-health-probe.mjs +82 -0
  225. package/server/services/preview-proxy-server.mjs +498 -0
  226. package/server/services/preview-registry-service.mjs +457 -0
  227. package/server/services/preview-static-handler.mjs +237 -0
  228. package/server/services/provider-model-selection.mjs +91 -0
  229. package/server/services/provider-reasoning-effort.mjs +94 -0
  230. package/server/services/push-active-device-service.mjs +57 -0
  231. package/server/services/push-dispatch-service.mjs +459 -0
  232. package/server/services/rate-limit-service.mjs +63 -0
  233. package/server/services/relay-bridge-owner-service.mjs +84 -0
  234. package/server/services/relay-cli-launcher-service.mjs +209 -0
  235. package/server/services/relay-prompt-sanitizer.mjs +137 -0
  236. package/server/services/relay-queue-gate-service.mjs +38 -0
  237. package/server/services/relay-restart-orchestrator-service.mjs +935 -0
  238. package/server/services/relay-singleton-guard.mjs +124 -0
  239. package/server/services/safe-served-content.mjs +57 -0
  240. package/server/services/sdk-session-import-service.mjs +271 -0
  241. package/server/services/sdk-session-sync-service.mjs +207 -0
  242. package/server/services/session-history-refresh-service.mjs +497 -0
  243. package/server/services/session-transcript-service.mjs +419 -0
  244. package/server/services/session-worker-launch-service.mjs +786 -0
  245. package/server/services/session-worker-process-service.mjs +319 -0
  246. package/server/services/session-worker-registry-service.mjs +194 -0
  247. package/server/services/session-worker-stop-service.mjs +141 -0
  248. package/server/services/session-worker-supervisor-service.mjs +898 -0
  249. package/server/services/session-worker-websocket-service.mjs +483 -0
  250. package/server/services/ssh-tunnel-manager-service.mjs +423 -0
  251. package/server/services/status-event-service.mjs +158 -0
  252. package/server/services/tmux-inspector-access-policy.mjs +77 -0
  253. package/server/services/tmux-inspector-socket-service.mjs +191 -0
  254. package/server/services/tmux-inspector-stream-service.mjs +552 -0
  255. package/server/services/tunnel-worker-path-guard.mjs +107 -0
  256. package/server/services/upload-sweep.mjs +60 -0
  257. package/server/services/usage-snapshot-helpers.mjs +209 -0
  258. package/server/services/windows-autostart-service.mjs +115 -0
  259. package/server/services/workspace-root-defaults-service.mjs +34 -0
  260. package/server/services/workspace-root-path-policy.mjs +196 -0
  261. package/server/services/workspace-root-relaunch-service.mjs +132 -0
  262. package/server/services/workspace-symlink-guard.mjs +54 -0
  263. package/server/tools/capture-proxy.mjs +173 -0
  264. package/server/tty-console-bootstrap.mjs +75 -0
  265. package/server/uploads/README.md +36 -0
  266. package/server/uploads/image_input.md +540 -0
  267. package/server/windows-terminal-launcher.mjs +29 -0
  268. package/server/workspace-root.mjs +87 -0
  269. package/shared/ask-user-bridge.mjs +131 -0
  270. package/shared/auto-compact-window.mjs +116 -0
  271. package/shared/background-task-timeout.mjs +52 -0
  272. package/shared/claude-thinking.mjs +101 -0
  273. package/shared/context-window-fallbacks.mjs +148 -0
  274. package/shared/control-poller.mjs +115 -0
  275. package/shared/empty-turn-completion.mjs +20 -0
  276. package/shared/model-catalog-freshness.mjs +11 -0
  277. package/shared/model-descriptors.mjs +118 -0
  278. package/shared/model-id.mjs +147 -0
  279. package/shared/model-refresh.mjs +24 -0
  280. package/shared/openai-reasoning.mjs +13 -0
  281. package/shared/plan-lines.mjs +13 -0
  282. package/shared/preview-instructions.mjs +77 -0
  283. package/shared/preview-tool-core.mjs +191 -0
  284. package/shared/provider-routing.mjs +29 -0
  285. package/shared/question-schema.mjs +315 -0
  286. package/shared/question-timeout.mjs +18 -0
  287. package/shared/stream-emit-gating.mjs +28 -0
  288. package/shared/subagent-run-id.mjs +17 -0
  289. package/shared/thought-cap.mjs +13 -0
  290. package/shared/tool-activity.mjs +98 -0
  291. package/shared/turn-ceiling.mjs +53 -0
  292. package/shared/worker-bootstrap.mjs +44 -0
  293. package/shared/worker-crash-guard.mjs +60 -0
@@ -0,0 +1,422 @@
1
+ import { currentConvId, conversations, showTransientRelayNotice } from './store.js';
2
+ import { getSocket } from './socket-handlers.js';
3
+
4
+ let inspectorState = {
5
+ open: false,
6
+ sdkSessionId: '',
7
+ terminal: null,
8
+ fitAddon: null,
9
+ plainOutput: null,
10
+ usesPlainOutput: false,
11
+ lastResizeCols: null,
12
+ lastResizeRows: null,
13
+ socket: null,
14
+ listenersBound: false,
15
+ };
16
+
17
+ const XTERM_STYLE_CANDIDATES = [
18
+ 'https://cdn.jsdelivr.net/npm/xterm@5.5.0/css/xterm.min.css',
19
+ 'https://unpkg.com/xterm@5.5.0/css/xterm.min.css',
20
+ ];
21
+ const XTERM_SCRIPT_CANDIDATES = [
22
+ 'https://cdn.jsdelivr.net/npm/xterm@5.5.0/lib/xterm.min.js',
23
+ 'https://unpkg.com/xterm@5.5.0/lib/xterm.min.js',
24
+ ];
25
+ const XTERM_FIT_SCRIPT_CANDIDATES = [
26
+ 'https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.10.0/lib/xterm-addon-fit.min.js',
27
+ 'https://unpkg.com/xterm-addon-fit@0.10.0/lib/xterm-addon-fit.min.js',
28
+ ];
29
+
30
+ let xtermLoadPromise = null;
31
+ let resizeDebounceTimer = null;
32
+
33
+ function resolveCurrentSessionId() {
34
+ const convId = String(currentConvId || '').trim();
35
+ if (!convId) return '';
36
+ const conversation = conversations?.[convId] || null;
37
+ return String(conversation?.sdkSessionId || conversation?.sdk_session_id || '').trim();
38
+ }
39
+
40
+ function setStatus(text = '', isError = false) {
41
+ const statusEl = document.getElementById('tmux-inspector-status');
42
+ if (!statusEl) return;
43
+ statusEl.textContent = String(text || '').trim();
44
+ statusEl.dataset.state = isError ? 'error' : 'ok';
45
+ }
46
+
47
+ function resolveTerminalRuntime() {
48
+ const TerminalCtor = window?.Terminal;
49
+ return typeof TerminalCtor === 'function' ? TerminalCtor : null;
50
+ }
51
+
52
+ function stripAnsiControlCodes(input = '') {
53
+ return String(input || '')
54
+ // OSC ... BEL or ST
55
+ .replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, '')
56
+ // CSI sequence
57
+ .replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, '')
58
+ // 2-byte escape
59
+ .replace(/\u001B[@-_]/g, '')
60
+ // C1 control CSI (single-byte 0x9B)
61
+ .replace(/\u009B[0-?]*[ -/]*[@-~]/g, '')
62
+ // other non-printing controls except LF/TAB
63
+ .replace(/[\u0000-\u0008\u000B-\u001A\u001C-\u001F\u007F]/g, '')
64
+ .replace(/\r(?!\n)/g, '');
65
+ }
66
+
67
+ function loadScript(src) {
68
+ return new Promise((resolve, reject) => {
69
+ const timeoutMs = 3500;
70
+ let settled = false;
71
+ const finishResolve = () => {
72
+ if (settled) return;
73
+ settled = true;
74
+ clearTimeout(timer);
75
+ resolve();
76
+ };
77
+ const finishReject = (error) => {
78
+ if (settled) return;
79
+ settled = true;
80
+ clearTimeout(timer);
81
+ reject(error);
82
+ };
83
+ const timer = setTimeout(() => {
84
+ finishReject(new Error(`Timed out loading ${src}`));
85
+ }, timeoutMs);
86
+
87
+ const existing = Array.from(document.querySelectorAll('script[src]'))
88
+ .find((node) => String(node.getAttribute('src') || '').trim() === src);
89
+ if (existing) {
90
+ if (existing.dataset.loaded === 'true') {
91
+ finishResolve();
92
+ return;
93
+ }
94
+ if (existing.dataset.failed === 'true') {
95
+ finishReject(new Error(`Failed to load ${src}`));
96
+ return;
97
+ }
98
+ existing.addEventListener('load', () => finishResolve(), { once: true });
99
+ existing.addEventListener('error', () => finishReject(new Error(`Failed to load ${src}`)), { once: true });
100
+ return;
101
+ }
102
+ const script = document.createElement('script');
103
+ script.src = src;
104
+ script.async = true;
105
+ script.onload = () => {
106
+ script.dataset.loaded = 'true';
107
+ finishResolve();
108
+ };
109
+ script.onerror = () => {
110
+ script.dataset.failed = 'true';
111
+ finishReject(new Error(`Failed to load ${src}`));
112
+ };
113
+ document.head.appendChild(script);
114
+ });
115
+ }
116
+
117
+ function ensureStylesheet(urlCandidates = []) {
118
+ for (const href of urlCandidates) {
119
+ const hrefText = String(href || '').trim();
120
+ if (!hrefText) continue;
121
+ const already = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
122
+ .some((node) => String(node.getAttribute('href') || '').trim() === hrefText);
123
+ if (already) return;
124
+ const link = document.createElement('link');
125
+ link.rel = 'stylesheet';
126
+ link.href = hrefText;
127
+ document.head.appendChild(link);
128
+ return;
129
+ }
130
+ }
131
+
132
+ async function loadFirstSuccessful(candidates = []) {
133
+ let lastError = null;
134
+ for (const candidate of candidates) {
135
+ const src = String(candidate || '').trim();
136
+ if (!src) continue;
137
+ try {
138
+ await loadScript(src);
139
+ return true;
140
+ } catch (error) {
141
+ lastError = error;
142
+ }
143
+ }
144
+ if (lastError) throw lastError;
145
+ return false;
146
+ }
147
+
148
+ async function ensureTerminalRuntimeLoaded() {
149
+ const existing = resolveTerminalRuntime();
150
+ if (existing) return existing;
151
+ if (!xtermLoadPromise) {
152
+ xtermLoadPromise = (async () => {
153
+ ensureStylesheet(XTERM_STYLE_CANDIDATES);
154
+ await loadFirstSuccessful(XTERM_SCRIPT_CANDIDATES);
155
+ await loadFirstSuccessful(XTERM_FIT_SCRIPT_CANDIDATES);
156
+ return resolveTerminalRuntime();
157
+ })().catch(() => {
158
+ xtermLoadPromise = null;
159
+ return null;
160
+ });
161
+ }
162
+ return xtermLoadPromise;
163
+ }
164
+
165
+ function fitTerminal() {
166
+ if (inspectorState.usesPlainOutput) return;
167
+ try {
168
+ inspectorState.fitAddon?.fit?.();
169
+ } catch {}
170
+ scheduleTmuxResizeSync();
171
+ }
172
+
173
+ function ensureSocketListeners(socket) {
174
+ if (!socket || inspectorState.listenersBound) return;
175
+ inspectorState.listenersBound = true;
176
+ socket.on('tmux_inspector_chunk', (event = {}) => {
177
+ if (!inspectorState.open) return;
178
+ const sessionId = String(event?.sdkSessionId || '').trim();
179
+ if (!sessionId || sessionId !== inspectorState.sdkSessionId) return;
180
+ const data = String(event?.data || '');
181
+ if (!data) return;
182
+ if (inspectorState.usesPlainOutput) {
183
+ if (inspectorState.plainOutput) {
184
+ inspectorState.plainOutput.textContent += stripAnsiControlCodes(data);
185
+ inspectorState.plainOutput.scrollTop = inspectorState.plainOutput.scrollHeight;
186
+ }
187
+ return;
188
+ }
189
+ inspectorState.terminal?.write(data);
190
+ });
191
+ socket.on('tmux_inspector_status', (event = {}) => {
192
+ if (!inspectorState.open) return;
193
+ const sessionId = String(event?.sdkSessionId || '').trim();
194
+ if (!sessionId || sessionId !== inspectorState.sdkSessionId) return;
195
+ const state = String(event?.state || '').trim().toLowerCase();
196
+ const reason = String(event?.reason || '').trim();
197
+ if (state === 'ended') {
198
+ setStatus(reason || 'Session stream ended', true);
199
+ return;
200
+ }
201
+ if (state === 'error') {
202
+ setStatus(reason || 'Session stream error', true);
203
+ return;
204
+ }
205
+ setStatus(reason || 'Streaming', false);
206
+ });
207
+ }
208
+
209
+ function hideModal() {
210
+ const modal = document.getElementById('tmux-inspector-modal');
211
+ if (!modal) return;
212
+ modal.classList.remove('visible');
213
+ modal.setAttribute('aria-hidden', 'true');
214
+ }
215
+
216
+ function showModal() {
217
+ const modal = document.getElementById('tmux-inspector-modal');
218
+ if (!modal) return;
219
+ modal.classList.add('visible');
220
+ modal.setAttribute('aria-hidden', 'false');
221
+ }
222
+
223
+ function closeTmuxInspector({ notifyServer = true } = {}) {
224
+ const socket = inspectorState.socket;
225
+ const sessionId = inspectorState.sdkSessionId;
226
+ if (notifyServer && socket && sessionId) {
227
+ try {
228
+ socket.emit('tmux_inspector_close', { sdkSessionId: sessionId });
229
+ } catch {}
230
+ }
231
+ try {
232
+ inspectorState.terminal?.dispose?.();
233
+ } catch {}
234
+ if (resizeDebounceTimer) {
235
+ clearTimeout(resizeDebounceTimer);
236
+ resizeDebounceTimer = null;
237
+ }
238
+ inspectorState = {
239
+ ...inspectorState,
240
+ open: false,
241
+ sdkSessionId: '',
242
+ terminal: null,
243
+ fitAddon: null,
244
+ plainOutput: null,
245
+ usesPlainOutput: false,
246
+ lastResizeCols: null,
247
+ lastResizeRows: null,
248
+ };
249
+ setStatus('');
250
+ const sessionEl = document.getElementById('tmux-inspector-session');
251
+ if (sessionEl) sessionEl.textContent = '';
252
+ hideModal();
253
+ }
254
+
255
+ async function attachTmuxInspector(sessionId) {
256
+ const socket = getSocket();
257
+ if (!socket) throw new Error('Socket connection unavailable');
258
+ inspectorState.socket = socket;
259
+ ensureSocketListeners(socket);
260
+
261
+ const payload = await new Promise((resolve, reject) => {
262
+ socket.timeout(6000).emit('tmux_inspector_open', { sdkSessionId: sessionId }, (error, response) => {
263
+ if (error) {
264
+ reject(new Error('Timed out while opening tmux inspector'));
265
+ return;
266
+ }
267
+ resolve(response || null);
268
+ });
269
+ });
270
+ if (!payload?.ok) {
271
+ throw new Error(String(payload?.reason || payload?.code || 'Unable to open tmux inspector'));
272
+ }
273
+ return payload;
274
+ }
275
+
276
+ function sendTmuxResize(cols, rows) {
277
+ const socket = inspectorState.socket || getSocket();
278
+ const sdkSessionId = String(inspectorState.sdkSessionId || '').trim();
279
+ if (!socket || !sdkSessionId) return;
280
+ const nextCols = Math.trunc(Number(cols));
281
+ const nextRows = Math.trunc(Number(rows));
282
+ if (!Number.isFinite(nextCols) || !Number.isFinite(nextRows)) return;
283
+ if (nextCols < 80 || nextRows < 24) return;
284
+ if (inspectorState.lastResizeCols === nextCols && inspectorState.lastResizeRows === nextRows) return;
285
+ inspectorState.lastResizeCols = nextCols;
286
+ inspectorState.lastResizeRows = nextRows;
287
+ try {
288
+ socket.emit('tmux_inspector_resize', {
289
+ sdkSessionId,
290
+ cols: nextCols,
291
+ rows: nextRows,
292
+ });
293
+ } catch {}
294
+ }
295
+
296
+ function scheduleTmuxResizeSync() {
297
+ if (!inspectorState.open || inspectorState.usesPlainOutput) return;
298
+ const cols = Number(inspectorState.terminal?.cols || 0);
299
+ const rows = Number(inspectorState.terminal?.rows || 0);
300
+ if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols <= 0 || rows <= 0) return;
301
+ if (resizeDebounceTimer) clearTimeout(resizeDebounceTimer);
302
+ resizeDebounceTimer = setTimeout(() => {
303
+ resizeDebounceTimer = null;
304
+ sendTmuxResize(cols, rows);
305
+ }, 90);
306
+ }
307
+
308
+ async function openTmuxInspector() {
309
+ const sdkSessionId = resolveCurrentSessionId();
310
+ if (!sdkSessionId) {
311
+ showTransientRelayNotice('Select a conversation with a bound SDK session first.');
312
+ return;
313
+ }
314
+ closeTmuxInspector({ notifyServer: true });
315
+ showModal();
316
+ setStatus('Connecting…', false);
317
+ const sessionEl = document.getElementById('tmux-inspector-session');
318
+ if (sessionEl) sessionEl.textContent = sdkSessionId;
319
+ const mount = document.getElementById('tmux-inspector-term');
320
+ if (!mount) throw new Error('Terminal container missing');
321
+ mount.innerHTML = '';
322
+ const TerminalCtor = resolveTerminalRuntime();
323
+ if (!TerminalCtor) {
324
+ // Warm up renderer loading in the background without blocking first paint/connect.
325
+ void ensureTerminalRuntimeLoaded();
326
+ }
327
+ if (TerminalCtor) {
328
+ const terminal = new TerminalCtor({
329
+ convertEol: false,
330
+ disableStdin: true,
331
+ cursorBlink: false,
332
+ scrollback: 8000,
333
+ });
334
+ const FitAddonCtor = window?.FitAddon?.FitAddon;
335
+ const fitAddon = typeof FitAddonCtor === 'function' ? new FitAddonCtor() : null;
336
+ if (fitAddon) terminal.loadAddon(fitAddon);
337
+ terminal.open(mount);
338
+ inspectorState.terminal = terminal;
339
+ inspectorState.fitAddon = fitAddon;
340
+ inspectorState.plainOutput = null;
341
+ inspectorState.usesPlainOutput = false;
342
+ } else {
343
+ const plain = document.createElement('pre');
344
+ plain.className = 'tmux-inspector-plain-output';
345
+ plain.setAttribute('aria-label', 'tmux console stream');
346
+ mount.appendChild(plain);
347
+ inspectorState.terminal = null;
348
+ inspectorState.fitAddon = null;
349
+ inspectorState.plainOutput = plain;
350
+ inspectorState.usesPlainOutput = true;
351
+ }
352
+ inspectorState.open = true;
353
+ inspectorState.sdkSessionId = sdkSessionId;
354
+ fitTerminal();
355
+
356
+ const attached = await attachTmuxInspector(sdkSessionId);
357
+ const snapshot = String(attached?.snapshot || '');
358
+ if (snapshot) {
359
+ if (inspectorState.usesPlainOutput && inspectorState.plainOutput) {
360
+ inspectorState.plainOutput.textContent = stripAnsiControlCodes(snapshot);
361
+ inspectorState.plainOutput.scrollTop = inspectorState.plainOutput.scrollHeight;
362
+ } else {
363
+ inspectorState.terminal?.write(snapshot);
364
+ }
365
+ }
366
+ setStatus(
367
+ inspectorState.usesPlainOutput
368
+ ? 'Read-only stream attached (plain renderer fallback)'
369
+ : 'Read-only stream attached',
370
+ false,
371
+ );
372
+ fitTerminal();
373
+ scheduleTmuxResizeSync();
374
+ }
375
+
376
+ function bindCloseControls() {
377
+ const closeBtn = document.getElementById('tmux-inspector-close');
378
+ const modal = document.getElementById('tmux-inspector-modal');
379
+ if (closeBtn && closeBtn.dataset.bound !== '1') {
380
+ closeBtn.dataset.bound = '1';
381
+ closeBtn.addEventListener('click', () => closeTmuxInspector({ notifyServer: true }));
382
+ }
383
+ if (modal && modal.dataset.bound !== '1') {
384
+ modal.dataset.bound = '1';
385
+ modal.addEventListener('click', (event) => {
386
+ const dialog = modal.querySelector('.tmux-inspector-dialog');
387
+ if (!dialog || dialog.contains(event.target)) return;
388
+ closeTmuxInspector({ notifyServer: true });
389
+ });
390
+ }
391
+ if (!window.__tmuxInspectorResizeBound) {
392
+ window.__tmuxInspectorResizeBound = true;
393
+ window.addEventListener('resize', () => {
394
+ if (!inspectorState.open) return;
395
+ fitTerminal();
396
+ });
397
+ }
398
+ }
399
+
400
+ export function initTmuxInspectorView({
401
+ bindMenuAction,
402
+ lockChatActionsMenuShield,
403
+ closeChatActionsMenu,
404
+ } = {}) {
405
+ bindCloseControls();
406
+ const menuBtn = document.getElementById('chat-menu-inspect-tmux');
407
+ if (!menuBtn || typeof bindMenuAction !== 'function') return;
408
+ bindMenuAction(menuBtn, (event) => {
409
+ event.preventDefault();
410
+ event.stopPropagation();
411
+ lockChatActionsMenuShield?.(350);
412
+ closeChatActionsMenu?.();
413
+ openTmuxInspector().catch((error) => {
414
+ setStatus(String(error?.message || 'Failed to open tmux inspector'), true);
415
+ showTransientRelayNotice(String(error?.message || 'Failed to open tmux inspector'));
416
+ });
417
+ });
418
+ }
419
+
420
+ export function closeTmuxInspectorView() {
421
+ closeTmuxInspector({ notifyServer: true });
422
+ }
@@ -0,0 +1,282 @@
1
+ // Full-width transcript break rows: once-per-day date separators and the
2
+ // Claude auto-compaction boundary. Both kinds go through ONE idempotent DOM
3
+ // pass (syncTranscriptSeparators), because every insertion path — a full
4
+ // render, an appended live message, a prepended older history page — can
5
+ // invalidate separators that are already in the DOM. The planning half
6
+ // (buildSeparatorPlan) is pure and carries the whole decision, so the rules
7
+ // are testable without a DOM.
8
+ //
9
+ // Separator rows deliberately carry neither the `msg` class nor
10
+ // data-message-id: the render bookkeeping (rendered-fingerprint collection,
11
+ // seen-message ids, the "first row" anchors) walks `.msg`, and a separator
12
+ // that answered those selectors would be mistaken for a message.
13
+ import { parseTimestampMs } from './store.js';
14
+
15
+ export const SEPARATOR_CLASS = 'transcript-separator';
16
+
17
+ const DAY_MS = 24 * 60 * 60 * 1000;
18
+
19
+ function startOfLocalDay(ms) {
20
+ const date = new Date(ms);
21
+ date.setHours(0, 0, 0, 0);
22
+ return date.getTime();
23
+ }
24
+
25
+ function dayKeyOf(ms) {
26
+ const date = new Date(ms);
27
+ const month = String(date.getMonth() + 1).padStart(2, '0');
28
+ const day = String(date.getDate()).padStart(2, '0');
29
+ return `${date.getFullYear()}-${month}-${day}`;
30
+ }
31
+
32
+ /**
33
+ * `Today` / `Yesterday` for the two most recent local days, `Mon, 18 Aug`
34
+ * within the current calendar year, `Mon, 18 Aug 2025` before it.
35
+ */
36
+ export function formatDayLabel(value, nowMs = Date.now()) {
37
+ const timestampMs = typeof value === 'number' && Number.isFinite(value)
38
+ ? value
39
+ : parseTimestampMs(value);
40
+ if (!timestampMs) return '';
41
+ const now = Number.isFinite(Number(nowMs)) ? Number(nowMs) : Date.now();
42
+ const dayStart = startOfLocalDay(timestampMs);
43
+ const todayStart = startOfLocalDay(now);
44
+ if (dayStart === todayStart) return 'Today';
45
+ if (dayStart === startOfLocalDay(todayStart - DAY_MS)) return 'Yesterday';
46
+ const date = new Date(timestampMs);
47
+ const sameYear = date.getFullYear() === new Date(now).getFullYear();
48
+ const base = date.toLocaleDateString([], {
49
+ weekday: 'short',
50
+ day: 'numeric',
51
+ month: 'short',
52
+ ...(sameYear ? {} : { year: 'numeric' }),
53
+ });
54
+ return base;
55
+ }
56
+
57
+ function formatCompactTokens(value) {
58
+ if (value == null || value === '') return '';
59
+ const count = Number(value);
60
+ if (!Number.isFinite(count) || count < 0) return '';
61
+ if (count < 1000) return String(Math.round(count));
62
+ return `${(count / 1000).toFixed(1).replace(/\.0$/, '')}k`;
63
+ }
64
+
65
+ /**
66
+ * Parses the `data-compact-boundary` stamp (`"<pre>|<post>"`, either side
67
+ * possibly empty) into token counts.
68
+ */
69
+ export function parseCompactBoundaryValue(value) {
70
+ const text = String(value == null ? '' : value).trim();
71
+ if (!text) return null;
72
+ const [rawPre, rawPost] = text.split('|');
73
+ const toCount = (raw) => {
74
+ const digits = String(raw || '').trim();
75
+ if (!digits) return null;
76
+ const parsed = Number(digits);
77
+ return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : null;
78
+ };
79
+ return { preTokens: toCount(rawPre), postTokens: toCount(rawPost) };
80
+ }
81
+
82
+ export function formatCompactBoundaryLabel(boundary) {
83
+ const preTokens = formatCompactTokens(boundary?.preTokens);
84
+ const postTokens = formatCompactTokens(boundary?.postTokens);
85
+ if (preTokens && postTokens) return `Context compacted · ${preTokens} → ${postTokens} tokens`;
86
+ // Auto-compact payloads carry no post_tokens — optional in the SDK type and
87
+ // absent from the on-disk transcript of every compaction inspected — so the
88
+ // pre-only count is the whole story the break can tell.
89
+ if (preTokens) return `Context compacted · was ${preTokens} tokens`;
90
+ return 'Context compacted';
91
+ }
92
+
93
+ /**
94
+ * Pure planner: rows in transcript order →
95
+ * `[{ key, kind, label, beforeIndex, messageId }]`, each separator keyed by
96
+ * the row it precedes. A day separator opens the transcript and repeats on
97
+ * every local-day rollover; a compaction separator sits immediately before
98
+ * the message whose activities recorded the boundary (after that message's
99
+ * day separator, when both land on the same row).
100
+ */
101
+ export function buildSeparatorPlan(rows, nowMs = Date.now()) {
102
+ const list = Array.isArray(rows) ? rows : [];
103
+ const plan = [];
104
+ let previousDayKey = '';
105
+ list.forEach((row, index) => {
106
+ const messageId = String(row?.messageId || '').trim();
107
+ const timestampMs = Number.isFinite(Number(row?.timestampMs)) && Number(row?.timestampMs)
108
+ ? Number(row.timestampMs)
109
+ : parseTimestampMs(row?.timestamp);
110
+ if (timestampMs) {
111
+ const dayKey = dayKeyOf(timestampMs);
112
+ if (dayKey !== previousDayKey) {
113
+ previousDayKey = dayKey;
114
+ plan.push({
115
+ key: `day:${dayKey}`,
116
+ kind: 'day',
117
+ label: formatDayLabel(timestampMs, nowMs),
118
+ beforeIndex: index,
119
+ messageId,
120
+ });
121
+ }
122
+ }
123
+ const boundary = row?.compactBoundary && typeof row.compactBoundary === 'object'
124
+ ? row.compactBoundary
125
+ : parseCompactBoundaryValue(row?.compactBoundary);
126
+ if (boundary && (boundary.preTokens != null || boundary.postTokens != null || row?.compactBoundary)) {
127
+ plan.push({
128
+ key: `compact:${messageId || index}:${boundary.preTokens ?? ''}-${boundary.postTokens ?? ''}`,
129
+ kind: 'compact',
130
+ label: formatCompactBoundaryLabel(boundary),
131
+ beforeIndex: index,
132
+ messageId,
133
+ });
134
+ }
135
+ });
136
+ return plan;
137
+ }
138
+
139
+ function readRowFromNode(node) {
140
+ const dataset = node?.dataset || {};
141
+ return {
142
+ messageId: String(dataset.messageId || '').trim(),
143
+ timestamp: String(dataset.messageTimestamp || '').trim(),
144
+ compactBoundary: String(dataset.compactBoundary || '').trim(),
145
+ };
146
+ }
147
+
148
+ function createSeparatorNode(doc, separator) {
149
+ const node = doc.createElement('div');
150
+ node.className = separator.kind === 'compact'
151
+ ? `${SEPARATOR_CLASS} is-compact`
152
+ : `${SEPARATOR_CLASS} is-day`;
153
+ node.dataset.separatorKey = separator.key;
154
+ node.dataset.separatorKind = separator.kind;
155
+ node.setAttribute('role', 'separator');
156
+ node.setAttribute('aria-label', separator.label);
157
+ const label = doc.createElement('span');
158
+ label.className = 'transcript-separator-label';
159
+ // textContent, never innerHTML: labels are generated here but the row sits
160
+ // in the same container as user content.
161
+ label.textContent = separator.label;
162
+ node.appendChild(label);
163
+ return node;
164
+ }
165
+
166
+ function isSeparatorNode(node) {
167
+ return node?.nodeType === 1 && node.classList?.contains(SEPARATOR_CLASS);
168
+ }
169
+
170
+ /**
171
+ * Idempotent DOM pass: reconciles the separators already in `container`
172
+ * against the plan for the messages it currently holds. Safe to call after
173
+ * any insertion path; runs no DOM writes when nothing changed.
174
+ */
175
+ export function syncTranscriptSeparators(container, nowMs = Date.now()) {
176
+ if (!container || typeof container.querySelectorAll !== 'function') return 0;
177
+ const doc = container.ownerDocument || (typeof document !== 'undefined' ? document : null);
178
+ if (!doc || typeof doc.createElement !== 'function') return 0;
179
+ const nodes = Array.from(container.querySelectorAll('.msg[data-message-timestamp]'))
180
+ .filter((node) => node.parentNode === container);
181
+ const plan = buildSeparatorPlan(nodes.map(readRowFromNode), nowMs);
182
+ const planByIndex = new Map();
183
+ for (const separator of plan) {
184
+ const list = planByIndex.get(separator.beforeIndex) || [];
185
+ list.push(separator);
186
+ planByIndex.set(separator.beforeIndex, list);
187
+ }
188
+ const kept = new Set();
189
+ let changed = 0;
190
+ nodes.forEach((node, index) => {
191
+ const desired = planByIndex.get(index) || [];
192
+ const existing = [];
193
+ let cursor = node.previousSibling;
194
+ while (isSeparatorNode(cursor)) {
195
+ existing.unshift(cursor);
196
+ cursor = cursor.previousSibling;
197
+ }
198
+ const matches = existing.length === desired.length
199
+ && existing.every((el, i) => el.dataset.separatorKey === desired[i].key
200
+ && el.dataset.separatorLabel === desired[i].label);
201
+ if (matches) {
202
+ for (const el of existing) kept.add(el);
203
+ return;
204
+ }
205
+ for (const el of existing) el.remove();
206
+ for (const separator of desired) {
207
+ const created = createSeparatorNode(doc, separator);
208
+ created.dataset.separatorLabel = separator.label;
209
+ container.insertBefore(created, node);
210
+ kept.add(created);
211
+ changed += 1;
212
+ }
213
+ changed += existing.length;
214
+ });
215
+ // Anything left over (a stale row above a removed message, or a separator
216
+ // stranded at the tail) goes.
217
+ for (const el of Array.from(container.querySelectorAll(`.${SEPARATOR_CLASS}`))) {
218
+ if (kept.has(el)) continue;
219
+ el.remove();
220
+ changed += 1;
221
+ }
222
+ syncSeparatorRail(container);
223
+ return changed;
224
+ }
225
+
226
+ export const RAIL_ID = 'transcript-separator-rail';
227
+
228
+ function ensureRail(container) {
229
+ const parent = container.parentNode;
230
+ const doc = container.ownerDocument;
231
+ if (!parent || typeof parent.appendChild !== 'function' || typeof doc?.createElement !== 'function') return null;
232
+ const existing = typeof parent.querySelector === 'function' ? parent.querySelector(`#${RAIL_ID}`) : null;
233
+ if (existing) return existing;
234
+ const rail = doc.createElement('div');
235
+ rail.id = RAIL_ID;
236
+ // Decoration only: the separators themselves carry the accessible labels.
237
+ rail.setAttribute('aria-hidden', 'true');
238
+ parent.appendChild(rail);
239
+ return rail;
240
+ }
241
+
242
+ /**
243
+ * One dot per separator, floated beside the scrollbar at the separator's
244
+ * proportional position in the scroll content.
245
+ *
246
+ * The rail is a sibling of the scroll container, not a child: children of a
247
+ * scroller scroll away with the content. Positions are read from the
248
+ * separator rows this module already inserted, so there is no second source
249
+ * of truth — and offsets only mean anything once the container is laid out,
250
+ * which is why every read is guarded (the unit-test node model reports none).
251
+ */
252
+ export function syncSeparatorRail(container) {
253
+ if (!container || typeof container.querySelectorAll !== 'function') return 0;
254
+ const rail = ensureRail(container);
255
+ if (!rail || typeof rail.replaceChildren !== 'function') return 0;
256
+ const scrollHeight = Number(container.scrollHeight) || 0;
257
+ const rows = Array.from(container.querySelectorAll(`.${SEPARATOR_CLASS}`))
258
+ .filter((node) => node.parentNode === container);
259
+ // A conversation with no boundaries gets no rail rather than an empty stripe.
260
+ if (!rows.length || scrollHeight <= 0) {
261
+ rail.replaceChildren();
262
+ rail.hidden = true;
263
+ return 0;
264
+ }
265
+ const doc = container.ownerDocument;
266
+ rail.hidden = false;
267
+ // The rail spans the scroll viewport, which is only part of the positioned
268
+ // parent (the header and composer sit outside it).
269
+ rail.style.top = `${Number(container.offsetTop) || 0}px`;
270
+ rail.style.height = `${Number(container.clientHeight) || 0}px`;
271
+ const fragment = doc.createDocumentFragment();
272
+ for (const row of rows) {
273
+ const dot = doc.createElement('div');
274
+ const isCompact = row.dataset?.separatorKind === 'compact';
275
+ dot.className = `transcript-rail-dot${isCompact ? ' is-compact' : ''}`;
276
+ dot.style.top = `${((Number(row.offsetTop) || 0) / scrollHeight) * 100}%`;
277
+ dot.title = row.dataset?.separatorLabel || '';
278
+ fragment.appendChild(dot);
279
+ }
280
+ rail.replaceChildren(fragment);
281
+ return rows.length;
282
+ }
Binary file