@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,2898 @@
1
+ import {
2
+ cliOnline,
3
+ compactInFlight,
4
+ conversations,
5
+ CLIENT_ID,
6
+ currentConvId,
7
+ escHtml,
8
+ fmtDate,
9
+ generateId,
10
+ hasPendingUserMessageDuplicate,
11
+ clearPendingUserMessage,
12
+ pendingUserMessageIds,
13
+ trackPendingUserMessage,
14
+ seenMessageIds,
15
+ relayActivities,
16
+ relayThoughts,
17
+ selectedAttachments,
18
+ setCompactInFlight,
19
+ setCurrentConv,
20
+ updateCompactButton,
21
+ updateSessionPill,
22
+ updateWorkspaceRootHints,
23
+ applyContextUsageBar,
24
+ scrollBottom,
25
+ scrollBottomAfterSend,
26
+ isMobileComposerViewport,
27
+ releaseComposerFocusAfterSend,
28
+ autoResize,
29
+ setModelBanner,
30
+ showTransientRelayNotice,
31
+ repoBrowserState,
32
+ saveConversationLoadedMessageCount,
33
+ getSubagentRun,
34
+ upsertSubagentRun,
35
+ setSubagentStreamText,
36
+ clearSubagentRunsForMessage,
37
+ getRootSubagentRunsByMessage,
38
+ getChildSubagentRuns,
39
+ markSubagentCancelInFlight,
40
+ clearSubagentCancelInFlight,
41
+ isSubagentCancelInFlight,
42
+ IS_SHARED_VIEW,
43
+ SHARED_CONVERSATION_TOKEN,
44
+ imageEditTarget,
45
+ setImageEditTarget as setStoredImageEditTarget,
46
+ } from './store.js';
47
+ import { sendMessage as sendMessageApi, cancelConversationTurn, cancelQueuedConversationTurn, cancelSubagentRun, compactConversation as compactConversationApi, scheduleContextUsageRefresh, loadConversation as loadConversationApi, loadSharedConversation, updateConversationDraft as updateConversationDraftApi, updateMessageShareVisibility } from './api-client.js';
48
+ import { enqueueOutboxRequest, registerOutboxSync } from './sync-outbox.mjs';
49
+ import { linkifyWorkspaceMentionsInNode, renderMarkdownPreview, rewriteLocalAssetUrlsInNode } from './router.js';
50
+ import { renderAttachmentMarkup, clearAttachments, uploadAttachments, setComposerAttachments, setRepoBrowserSessionInfo } from './attachments-view.js';
51
+ import { buildWorkflowRunCard } from './background-tasks-view.mjs';
52
+ import { parsePreviewCommand, runPreviewCommand } from './preview-command.mjs';
53
+ import { buildTranscriptPreviewCard } from './preview-cards.mjs';
54
+ import { closeSlashAutocomplete, handleSlashAutocompleteKey, updateSlashAutocomplete } from './slash-autocomplete.mjs';
55
+ import { evaluateUnknownCommandGuard } from './slash-commands.mjs';
56
+ import { attachCodeCopyButtons } from './code-copy.mjs';
57
+ import { relayErrorCtaActions } from './relay-error-ctas.mjs';
58
+ import { confirmCliInstall } from './cli-install-ui.js';
59
+ import { startGrokSignIn } from './grok-auth-ui.js';
60
+ import { openSettingsModal } from './settings-modal.js';
61
+ import {
62
+ serializeDraftAttachments,
63
+ hydrateDraftAttachments,
64
+ mergeDraftAttachmentUpdate,
65
+ draftAttachmentsEqual,
66
+ } from './composer-attachment-cache.mjs';
67
+ import { renderRelayQuestions } from './ask-user-view.js';
68
+ import { renderRelayBoards } from './relay-board-view.js';
69
+ import { getMessageThreadAnchor, sortConversationMessages } from './thread-order.mjs';
70
+ import {
71
+ normalizeStreamSeq,
72
+ deriveLatestInFlightStreamEvent,
73
+ deriveInFlightStreamTextByThread,
74
+ computeNextRelayStreamState,
75
+ } from './stream-state.mjs';
76
+ import {
77
+ capRelayActivityEntries,
78
+ compactBoundaryFromActivities,
79
+ isCompactBoundaryActivityEntry,
80
+ mergeRelayActivityTexts,
81
+ normalizeRelayActivityEntry,
82
+ promotedCompactBoundaryEntry,
83
+ relayActivityEntryText,
84
+ } from './activity-replay-state.mjs';
85
+ import { SEPARATOR_CLASS, syncSeparatorRail, syncTranscriptSeparators } from './transcript-separators.mjs';
86
+ import { deriveComposerControlState, hasComposerDraft, hasUploadingAttachments } from './composer-control-state.mjs';
87
+ import { buildLiveMessageFingerprint } from './live-message-dedupe.mjs';
88
+ import { createInfiniteLoader } from './infinite-loader.js';
89
+ import { normalizeDraftTimestampMs, isIncomingDraftTimestampStale } from './conversation-draft-timestamp-utils.mjs';
90
+ import { isChatInteractionHeld, selectionIntersectsNode } from './selection-guard.mjs';
91
+ import { buildInFlightSnapshotKey } from './in-flight-snapshot.mjs';
92
+ import { computeStablePrefixLength, planListPatch } from './streaming-dom-patch.mjs';
93
+
94
+ const CONVERSATION_HISTORY_PAGE_SIZE = 20;
95
+ const HISTORY_LOAD_MORE_ID = 'history-load-more';
96
+ // The topmost transcript row, whichever kind it is: a day/compaction separator
97
+ // can sit above the first `.msg`, and anything that inserts "above the
98
+ // transcript" (the load-older control, a prepended history page) must land
99
+ // above that separator, not between it and its message.
100
+ const FIRST_TRANSCRIPT_ROW_SELECTOR = `.msg, .${SEPARATOR_CLASS}`;
101
+
102
+ // Batch guard: appendMessage syncs separators per insertion, which is right
103
+ // for a single live message but quadratic when a whole page is appended in a
104
+ // loop. Bulk paths suspend it and run one pass at the end.
105
+ let separatorSyncSuspended = 0;
106
+ let transcriptResizeObserved = null;
107
+
108
+ function syncSeparatorsNow() {
109
+ if (separatorSyncSuspended > 0) return;
110
+ const el = getMessagesElement();
111
+ if (!el) return;
112
+ syncTranscriptSeparators(el);
113
+ observeTranscriptResize(el);
114
+ }
115
+
116
+ // Render-time offsets go stale whenever the transcript changes height without
117
+ // a message being added — an image finishing, a <details> opening, a font-size
118
+ // change — which would leave the rail's dots pointing at the wrong rows.
119
+ // Re-laying out the dots is cheap and touches no message nodes.
120
+ function observeTranscriptResize(el) {
121
+ if (transcriptResizeObserved === el || typeof ResizeObserver !== 'function') return;
122
+ transcriptResizeObserved = el;
123
+ const observer = new ResizeObserver(() => syncSeparatorRail(el));
124
+ observer.observe(el);
125
+ }
126
+
127
+ function withSuspendedSeparatorSync(fn) {
128
+ separatorSyncSuspended += 1;
129
+ try {
130
+ return fn();
131
+ } finally {
132
+ separatorSyncSuspended -= 1;
133
+ }
134
+ }
135
+ const OPAQUE_RELAY_TEXT_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
136
+
137
+ let thinkingMessageId = null;
138
+ let lastInFlightSnapshotKey = '';
139
+ let deferredMessageRender = null;
140
+ const relayStreamStateByMessageId = new Map();
141
+ // Cumulative main-thread stream text per message. The seq/done state above is
142
+ // shared across threads (the server numbers stream rows per queue message, not
143
+ // per thread), so the text has to be tracked separately or a subagent frame
144
+ // would clobber the reply preview.
145
+ const relayStreamTextByMessageId = new Map();
146
+ const completedMessageIds = new Set();
147
+ const bubbleCancelInFlight = new Set();
148
+ const shareVisibilityInFlight = new Set();
149
+ const SUBAGENT_TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled', 'dropped', 'done']);
150
+ let lastRenderedMessageSnapshotKey = '';
151
+ let sendInFlight = false;
152
+ const COMPOSER_DRAFT_DEBOUNCE_MS = 500;
153
+ const draftSaveTimerByConversation = new Map();
154
+ const draftSavePromiseByConversation = new Map();
155
+ const activeTurnsByConversation = new Map();
156
+ let conversationHistoryState = {
157
+ conversationId: '',
158
+ hasMoreOlder: false,
159
+ hasMoreNewer: false,
160
+ oldestMessageId: '',
161
+ oldestMessageTimestamp: '',
162
+ newestMessageId: '',
163
+ newestMessageTimestamp: '',
164
+ loadedMessageCount: 0,
165
+ loadingOlder: false,
166
+ loadingNewer: false,
167
+ };
168
+
169
+ function renderImageEditTarget() {
170
+ const chip = document.getElementById('image-edit-target');
171
+ if (!chip) return;
172
+ if (!imageEditTarget) {
173
+ chip.classList.remove('visible');
174
+ chip.innerHTML = '';
175
+ return;
176
+ }
177
+ chip.innerHTML = `<span>Editing <strong>${escHtml(imageEditTarget.name)}</strong></span><button type="button" onclick="clearImageEditTarget()" aria-label="Cancel image edit target">✕</button>`;
178
+ chip.classList.add('visible');
179
+ }
180
+
181
+ export function setImageEditTarget(target) {
182
+ if (IS_SHARED_VIEW) return;
183
+ setStoredImageEditTarget(target);
184
+ renderImageEditTarget();
185
+ const input = document.getElementById('msg-input');
186
+ if (input) {
187
+ input.placeholder = 'Describe how to edit this image…';
188
+ input.focus();
189
+ }
190
+ }
191
+
192
+ export function clearImageEditTarget() {
193
+ setStoredImageEditTarget(null);
194
+ renderImageEditTarget();
195
+ const input = document.getElementById('msg-input');
196
+ if (input) input.placeholder = 'Message Copilot…';
197
+ }
198
+
199
+ export function jumpToImageParent(messageId) {
200
+ const id = String(messageId || '').trim();
201
+ if (!id) return;
202
+ const node = document.querySelector(`[data-message-id="${CSS.escape(id)}"]`);
203
+ if (!node) {
204
+ showTransientRelayNotice('Load earlier messages to view the source image.');
205
+ return;
206
+ }
207
+ node.scrollIntoView({ behavior: 'smooth', block: 'center' });
208
+ node.classList.add('message-highlight');
209
+ window.setTimeout(() => node.classList.remove('message-highlight'), 1400);
210
+ }
211
+
212
+ async function loadConversationHistoryPage(conversationId, options = {}) {
213
+ if (!IS_SHARED_VIEW) {
214
+ return loadConversationApi(conversationId, options);
215
+ }
216
+ // Shared mode must paginate through the token endpoint so hidden-from-share
217
+ // messages stay filtered even when an owner auth cookie is present.
218
+ const response = await loadSharedConversation(SHARED_CONVERSATION_TOKEN, options);
219
+ if (!response || response.ok === false) return null;
220
+ const { ok, status, error, shared, ...payload } = response;
221
+ return payload;
222
+ }
223
+
224
+ // The loaders' cursors belong to the conversation recorded in
225
+ // conversationHistoryState. Between a conversation switch and the new
226
+ // conversation's first render they still hold the previous conversation's
227
+ // cursors; the switch entry point lives outside this module, so staleness is
228
+ // detected here and the loaders are reset before any fetch can spend an old
229
+ // cursor against the new conversation. Returns true when a reset happened.
230
+ function resetConversationPaginationIfStale() {
231
+ const conversationId = String(currentConvId || '').trim();
232
+ if (String(conversationHistoryState.conversationId || '').trim() === conversationId) return false;
233
+ conversationHistoryLoader.reset({ hasMore: false, nextCursor: null });
234
+ conversationFutureLoader.reset({ hasMore: false, nextCursor: null });
235
+ return true;
236
+ }
237
+
238
+ function conversationPaginationCursorIsStale(conversationId) {
239
+ return String(conversationHistoryState.conversationId || '').trim() !== String(conversationId || '').trim();
240
+ }
241
+
242
+ const conversationHistoryLoader = createInfiniteLoader({
243
+ fetchPage: async (cursor) => {
244
+ const conversationId = String(currentConvId || '').trim();
245
+ if (!conversationId) {
246
+ return {
247
+ items: [],
248
+ hasMore: false,
249
+ nextCursor: null,
250
+ };
251
+ }
252
+ if (conversationPaginationCursorIsStale(conversationId)) return null;
253
+ const response = await loadConversationHistoryPage(conversationId, {
254
+ limit: CONVERSATION_HISTORY_PAGE_SIZE,
255
+ beforeMessageId: String(cursor?.beforeMessageId || '').trim(),
256
+ beforeTimestamp: String(cursor?.beforeTimestamp || '').trim(),
257
+ });
258
+ if (String(currentConvId || '').trim() !== conversationId) return null;
259
+ if (!response) throw new Error('Could not load older messages. Please try again.');
260
+ return {
261
+ items: response.messages || [],
262
+ hasMore: !!response.pageInfo?.hasMore,
263
+ nextCursor: response.pageInfo?.nextCursor || null,
264
+ };
265
+ },
266
+ applyPage: async (page) => {
267
+ const currentId = String(currentConvId || '').trim();
268
+ const el = getMessagesElement();
269
+ if (!currentId || !el) return;
270
+ // Covers the prefetch buffer: a page fetched before a conversation switch
271
+ // must not be applied to the newly opened conversation.
272
+ if (conversationPaginationCursorIsStale(currentId)) return;
273
+ const previousScrollTop = el.scrollTop;
274
+ const previousScrollHeight = el.scrollHeight;
275
+ const inserted = prependMessageNodes(page.items || []);
276
+ setConversationHistoryState({
277
+ conversationId: currentId,
278
+ hasMoreOlder: page.hasMore,
279
+ hasMoreNewer: conversationHistoryState.hasMoreNewer,
280
+ oldestMessageId: String(page.nextCursor?.beforeMessageId || conversationHistoryState.oldestMessageId || '').trim(),
281
+ oldestMessageTimestamp: String(page.nextCursor?.beforeTimestamp || conversationHistoryState.oldestMessageTimestamp || '').trim(),
282
+ newestMessageId: String(conversationHistoryState.newestMessageId || '').trim(),
283
+ newestMessageTimestamp: String(conversationHistoryState.newestMessageTimestamp || '').trim(),
284
+ loadedMessageCount: getConversationLoadedMessageCount() + inserted.inserted,
285
+ loadingOlder: false,
286
+ loadingNewer: conversationHistoryState.loadingNewer,
287
+ });
288
+ renderRelayQuestions();
289
+ renderRelayBoards();
290
+ // Before the scroll restore below: the prepended page can add (or retire)
291
+ // separator rows, and the height delta the restore measures has to include
292
+ // them or the viewport jumps.
293
+ syncTranscriptSeparators(el);
294
+ requestAnimationFrame(() => {
295
+ if (!el || String(currentConvId || '').trim() !== currentId) return;
296
+ const nextScrollHeight = el.scrollHeight;
297
+ el.scrollTop = previousScrollTop + (nextScrollHeight - previousScrollHeight);
298
+ void conversationHistoryLoader.handleBoundaryDistance(el.scrollTop);
299
+ });
300
+ },
301
+ onError: (error, { mode }) => {
302
+ if (mode === 'load') {
303
+ showTransientRelayNotice(error?.message || 'Could not load older messages. Please try again.');
304
+ }
305
+ },
306
+ onStateChange: (state) => {
307
+ conversationHistoryState = {
308
+ ...conversationHistoryState,
309
+ hasMoreOlder: state.hasMore,
310
+ loadingOlder: state.isLoading,
311
+ };
312
+ syncHistoryLoadMoreControl();
313
+ },
314
+ });
315
+
316
+ const conversationFutureLoader = createInfiniteLoader({
317
+ fetchPage: async (cursor) => {
318
+ const conversationId = String(currentConvId || '').trim();
319
+ if (!conversationId) {
320
+ return {
321
+ items: [],
322
+ hasMore: false,
323
+ nextCursor: null,
324
+ };
325
+ }
326
+ if (conversationPaginationCursorIsStale(conversationId)) return null;
327
+ const response = await loadConversationHistoryPage(conversationId, {
328
+ limit: CONVERSATION_HISTORY_PAGE_SIZE,
329
+ afterMessageId: String(cursor?.afterMessageId || '').trim(),
330
+ afterTimestamp: String(cursor?.afterTimestamp || '').trim(),
331
+ });
332
+ if (String(currentConvId || '').trim() !== conversationId) return null;
333
+ if (!response) throw new Error('Could not load newer messages. Please try again.');
334
+ return {
335
+ items: response.messages || [],
336
+ hasMore: !!response.pageInfo?.hasMoreNewer,
337
+ nextCursor: response.pageInfo?.newerCursor || null,
338
+ };
339
+ },
340
+ applyPage: async (page) => {
341
+ const currentId = String(currentConvId || '').trim();
342
+ if (!currentId || conversationPaginationCursorIsStale(currentId)) return;
343
+ const ordered = sortConversationMessages(page.items || []);
344
+ let inserted = 0;
345
+ withSuspendedSeparatorSync(() => {
346
+ for (const m of ordered) {
347
+ const msgId = String(m?.id || '').trim() || null;
348
+ const node = appendMessage(m, false, msgId, true, null, false);
349
+ if (node) inserted += 1;
350
+ }
351
+ });
352
+ syncSeparatorsNow();
353
+ setConversationHistoryState({
354
+ conversationId: currentId,
355
+ hasMoreOlder: conversationHistoryState.hasMoreOlder,
356
+ hasMoreNewer: page.hasMore,
357
+ oldestMessageId: String(conversationHistoryState.oldestMessageId || '').trim(),
358
+ oldestMessageTimestamp: String(conversationHistoryState.oldestMessageTimestamp || '').trim(),
359
+ newestMessageId: String(page.nextCursor?.afterMessageId || conversationHistoryState.newestMessageId || '').trim(),
360
+ newestMessageTimestamp: String(page.nextCursor?.afterTimestamp || conversationHistoryState.newestMessageTimestamp || '').trim(),
361
+ loadedMessageCount: getConversationLoadedMessageCount() + inserted,
362
+ loadingOlder: conversationHistoryState.loadingOlder,
363
+ loadingNewer: false,
364
+ });
365
+ },
366
+ onError: (error, { mode }) => {
367
+ if (mode === 'load') {
368
+ showTransientRelayNotice(error?.message || 'Could not load newer messages. Please try again.');
369
+ }
370
+ },
371
+ onStateChange: (state) => {
372
+ conversationHistoryState = {
373
+ ...conversationHistoryState,
374
+ hasMoreNewer: state.hasMore,
375
+ loadingNewer: state.isLoading,
376
+ };
377
+ },
378
+ });
379
+
380
+ function isOpaqueRelayText(value) {
381
+ const text = String(value || '').trim();
382
+ return !!text && OPAQUE_RELAY_TEXT_PATTERN.test(text);
383
+ }
384
+
385
+ function setSendInFlight(value) {
386
+ sendInFlight = !!value;
387
+ syncSendButtonState();
388
+ }
389
+
390
+ function getActiveTurnForConversation(conversationId) {
391
+ const conversationKey = String(conversationId || '').trim();
392
+ if (!conversationKey) return null;
393
+ return activeTurnsByConversation.get(conversationKey) || null;
394
+ }
395
+
396
+ function syncSendButtonState() {
397
+ const btn = document.getElementById('send-btn');
398
+ if (!btn) return;
399
+ const currentTurn = getActiveTurnForConversation(currentConvId);
400
+ const state = deriveComposerControlState({
401
+ hasActiveTurn: !!currentTurn,
402
+ cancelRequested: currentTurn?.cancelRequested === true,
403
+ hasDraft: hasComposerDraft({
404
+ text: document.getElementById('msg-input')?.value || '',
405
+ attachmentCount: selectedAttachments.length,
406
+ }),
407
+ sendInFlight,
408
+ modelMetadataBlocked: window.isModelMetadataBlocked?.() === true,
409
+ attachmentsUploading: hasUploadingAttachments(selectedAttachments),
410
+ });
411
+ btn.disabled = state.disabled;
412
+ btn.dataset.action = state.action;
413
+ btn.textContent = state.label;
414
+ btn.title = state.title;
415
+ }
416
+
417
+ export function syncComposerControlState() {
418
+ syncSendButtonState();
419
+ void scheduleConversationDraftSave({
420
+ conversationId: currentConvId,
421
+ draftText: document.getElementById('msg-input')?.value || '',
422
+ });
423
+ }
424
+
425
+ export function isSendInFlight() {
426
+ return sendInFlight;
427
+ }
428
+
429
+ function clearDraftTimerForConversation(conversationId) {
430
+ const id = String(conversationId || '').trim();
431
+ if (!id) return;
432
+ const timer = draftSaveTimerByConversation.get(id);
433
+ if (!timer) return;
434
+ clearTimeout(timer);
435
+ draftSaveTimerByConversation.delete(id);
436
+ }
437
+
438
+ function upsertConversationDraftState(conversationId, {
439
+ draftText = '',
440
+ draftAttachments = undefined,
441
+ draftUpdatedAt = null,
442
+ draftUpdatedByClientId = null,
443
+ } = {}) {
444
+ const id = String(conversationId || '').trim();
445
+ if (!id || !conversations[id]) return;
446
+ const existing = conversations[id];
447
+ conversations[id] = {
448
+ ...existing,
449
+ draftText: String(draftText || ''),
450
+ draftAttachments: draftAttachments === undefined
451
+ ? (existing.draftAttachments || [])
452
+ : (Array.isArray(draftAttachments) ? draftAttachments : []),
453
+ draftUpdatedAt: draftUpdatedAt || null,
454
+ draftUpdatedByClientId: draftUpdatedByClientId || null,
455
+ };
456
+ }
457
+
458
+ /**
459
+ * Persists the composer's attachment set for the active conversation. Called
460
+ * whenever an attachment finishes uploading or is removed, so the cache is
461
+ * written as a discrete action rather than riding the text debounce.
462
+ *
463
+ * Before a conversation exists there is nowhere to persist to: the attachments
464
+ * simply stay in the composer (they were already uploaded) and are adopted by
465
+ * the conversation created on the first send.
466
+ */
467
+ export function persistComposerAttachments() {
468
+ const id = String(currentConvId || '').trim();
469
+ if (!id) return null;
470
+ return scheduleConversationDraftSave({
471
+ conversationId: id,
472
+ draftText: document.getElementById('msg-input')?.value || '',
473
+ draftAttachments: serializeDraftAttachments(selectedAttachments),
474
+ immediate: true,
475
+ });
476
+ }
477
+
478
+ async function persistConversationDraft(conversationId, draftText, draftAttachments = undefined) {
479
+ const id = String(conversationId || '').trim();
480
+ if (!id) return null;
481
+ const text = String(draftText || '');
482
+ const runPersist = async () => {
483
+ const baseDraftUpdatedAt = conversations[id]?.draftUpdatedAt || null;
484
+ const response = await updateConversationDraftApi(id, {
485
+ draftText: text,
486
+ clientId: CLIENT_ID,
487
+ baseDraftUpdatedAt,
488
+ ...(draftAttachments === undefined ? {} : { draftAttachments }),
489
+ });
490
+ if (!response?.ok) {
491
+ if (response?.conflict === true || response?.code === 'draft-version-conflict') {
492
+ applyIncomingConversationDraftUpdate({
493
+ conversationId: id,
494
+ draftText: response.draftText || '',
495
+ draftAttachments: response.draftAttachments,
496
+ draftUpdatedAt: response.draftUpdatedAt || null,
497
+ draftUpdatedByClientId: response.draftUpdatedByClientId || null,
498
+ });
499
+ }
500
+ return response || null;
501
+ }
502
+ upsertConversationDraftState(id, {
503
+ draftText: response.draftText,
504
+ draftAttachments: response.draftAttachments,
505
+ draftUpdatedAt: response.draftUpdatedAt || response.updatedAt || null,
506
+ draftUpdatedByClientId: response.draftUpdatedByClientId || response.senderClientId || null,
507
+ });
508
+ return response;
509
+ };
510
+ const previous = draftSavePromiseByConversation.get(id) || Promise.resolve();
511
+ const next = previous
512
+ .catch(() => null)
513
+ .then(runPersist)
514
+ .finally(() => {
515
+ if (draftSavePromiseByConversation.get(id) === next) {
516
+ draftSavePromiseByConversation.delete(id);
517
+ }
518
+ });
519
+ draftSavePromiseByConversation.set(id, next);
520
+ return next;
521
+ }
522
+
523
+ async function scheduleConversationDraftSave({
524
+ conversationId,
525
+ draftText,
526
+ draftAttachments = undefined,
527
+ immediate = false,
528
+ } = {}) {
529
+ const id = String(conversationId || '').trim();
530
+ if (!id) return null;
531
+ const text = String(draftText || '');
532
+ upsertConversationDraftState(id, { draftText: text, draftAttachments });
533
+ clearDraftTimerForConversation(id);
534
+ if (immediate) {
535
+ return persistConversationDraft(id, text, draftAttachments);
536
+ }
537
+ return new Promise((resolve) => {
538
+ const timer = setTimeout(() => {
539
+ draftSaveTimerByConversation.delete(id);
540
+ persistConversationDraft(id, text, draftAttachments).then(resolve).catch(() => resolve(null));
541
+ }, COMPOSER_DRAFT_DEBOUNCE_MS);
542
+ draftSaveTimerByConversation.set(id, timer);
543
+ });
544
+ }
545
+
546
+ export async function flushConversationDraft(conversationId = currentConvId) {
547
+ const id = String(conversationId || '').trim();
548
+ if (!id) return null;
549
+ const input = document.getElementById('msg-input');
550
+ const draftText = String((id === String(currentConvId || '').trim() && input) ? input.value : (conversations[id]?.draftText || ''));
551
+ return scheduleConversationDraftSave({
552
+ conversationId: id,
553
+ draftText,
554
+ immediate: true,
555
+ });
556
+ }
557
+
558
+ export function hydrateConversationDraft(conversationId, {
559
+ draftText = '',
560
+ draftAttachments = [],
561
+ draftUpdatedAt = null,
562
+ draftUpdatedByClientId = null,
563
+ } = {}) {
564
+ const id = String(conversationId || '').trim();
565
+ if (!id) return;
566
+ const normalizedDraftText = String(draftText || '');
567
+ const normalizedAttachments = Array.isArray(draftAttachments) ? draftAttachments : [];
568
+ const existingMs = normalizeDraftTimestampMs(conversations[id]?.draftUpdatedAt);
569
+ const incomingMs = normalizeDraftTimestampMs(draftUpdatedAt);
570
+ if (isIncomingDraftTimestampStale({ existingMs, incomingMs })) return;
571
+ upsertConversationDraftState(id, {
572
+ draftText: normalizedDraftText,
573
+ draftAttachments: normalizedAttachments,
574
+ draftUpdatedAt,
575
+ draftUpdatedByClientId,
576
+ });
577
+ if (String(currentConvId || '').trim() !== id) return;
578
+ // Restore the composer's pending attachments for the conversation being opened.
579
+ if (!draftAttachmentsEqual(selectedAttachments, hydrateDraftAttachments(normalizedAttachments))) {
580
+ setComposerAttachments(hydrateDraftAttachments(normalizedAttachments));
581
+ }
582
+ const input = document.getElementById('msg-input');
583
+ if (!input) return;
584
+ const isFocused = document.activeElement === input;
585
+ if (isFocused && input.value !== normalizedDraftText) {
586
+ syncSendButtonState();
587
+ return;
588
+ }
589
+ if (input.value !== normalizedDraftText) {
590
+ input.value = normalizedDraftText;
591
+ autoResize(input);
592
+ }
593
+ syncSendButtonState();
594
+ }
595
+
596
+ export function applyIncomingConversationDraftUpdate({
597
+ conversationId,
598
+ draftText = '',
599
+ draftAttachments = undefined,
600
+ draftUpdatedAt = null,
601
+ draftUpdatedByClientId = null,
602
+ senderClientId = null,
603
+ } = {}) {
604
+ const id = String(conversationId || '').trim();
605
+ if (!id || !conversations[id]) return;
606
+ if (senderClientId && senderClientId === CLIENT_ID) return;
607
+ const incomingDraftText = String(draftText || '');
608
+ const existingMs = normalizeDraftTimestampMs(conversations[id]?.draftUpdatedAt);
609
+ const incomingMs = normalizeDraftTimestampMs(draftUpdatedAt);
610
+ if (isIncomingDraftTimestampStale({ existingMs, incomingMs })) return;
611
+ upsertConversationDraftState(id, {
612
+ draftText: incomingDraftText,
613
+ draftAttachments,
614
+ draftUpdatedAt,
615
+ draftUpdatedByClientId: draftUpdatedByClientId || senderClientId || null,
616
+ });
617
+ if (String(currentConvId || '').trim() !== id) return;
618
+ if (draftAttachments !== undefined) {
619
+ const merged = mergeDraftAttachmentUpdate({
620
+ existing: selectedAttachments,
621
+ incoming: hydrateDraftAttachments(Array.isArray(draftAttachments) ? draftAttachments : []),
622
+ existingUpdatedAt: conversations[id]?.draftUpdatedAt || null,
623
+ incomingUpdatedAt: draftUpdatedAt,
624
+ isLocalEcho: senderClientId === CLIENT_ID,
625
+ });
626
+ if (merged.changed) setComposerAttachments(merged.attachments);
627
+ }
628
+ const input = document.getElementById('msg-input');
629
+ if (!input) return;
630
+ const isFocused = document.activeElement === input;
631
+ if (isFocused && input.value !== incomingDraftText) return;
632
+ if (input.value !== incomingDraftText) {
633
+ input.value = incomingDraftText;
634
+ autoResize(input);
635
+ }
636
+ syncSendButtonState();
637
+ }
638
+
639
+ function setConversationTurnState(conversationId, state = null) {
640
+ const conversationKey = String(conversationId || '').trim();
641
+ if (!conversationKey) {
642
+ syncSendButtonState();
643
+ return;
644
+ }
645
+ if (!state || !String(state.messageId || '').trim()) {
646
+ activeTurnsByConversation.delete(conversationKey);
647
+ syncSendButtonState();
648
+ return;
649
+ }
650
+ activeTurnsByConversation.set(conversationKey, {
651
+ messageId: String(state.messageId || '').trim(),
652
+ status: String(state.status || 'processing').trim().toLowerCase() || 'processing',
653
+ cancelRequested: state.cancelRequested === true,
654
+ });
655
+ syncSendButtonState();
656
+ }
657
+
658
+ function getMessagesElement() {
659
+ return document.getElementById('messages');
660
+ }
661
+
662
+ function resetConversationHistoryState() {
663
+ const conversationId = String(currentConvId || '').trim();
664
+ conversationHistoryState = {
665
+ conversationId,
666
+ hasMoreOlder: false,
667
+ hasMoreNewer: false,
668
+ oldestMessageId: '',
669
+ oldestMessageTimestamp: '',
670
+ newestMessageId: '',
671
+ newestMessageTimestamp: '',
672
+ loadedMessageCount: 0,
673
+ loadingOlder: false,
674
+ loadingNewer: false,
675
+ };
676
+ conversationHistoryLoader.reset({ hasMore: false, nextCursor: null });
677
+ conversationFutureLoader.reset({ hasMore: false, nextCursor: null });
678
+ saveConversationLoadedMessageCount(conversationId, 0);
679
+ syncHistoryLoadMoreControl();
680
+ }
681
+
682
+ function setConversationHistoryState(next = {}) {
683
+ conversationHistoryState = {
684
+ conversationId: String(next.conversationId || currentConvId || '').trim(),
685
+ hasMoreOlder: !!next.hasMoreOlder,
686
+ hasMoreNewer: !!next.hasMoreNewer,
687
+ oldestMessageId: String(next.oldestMessageId || '').trim(),
688
+ oldestMessageTimestamp: String(next.oldestMessageTimestamp || '').trim(),
689
+ newestMessageId: String(next.newestMessageId || '').trim(),
690
+ newestMessageTimestamp: String(next.newestMessageTimestamp || '').trim(),
691
+ loadedMessageCount: Math.max(0, Number(next.loadedMessageCount) || 0),
692
+ loadingOlder: !!next.loadingOlder,
693
+ loadingNewer: !!next.loadingNewer,
694
+ };
695
+ saveConversationLoadedMessageCount(
696
+ conversationHistoryState.conversationId,
697
+ conversationHistoryState.loadedMessageCount,
698
+ );
699
+ syncHistoryLoadMoreControl();
700
+ }
701
+
702
+ function getConversationHistoryCursor() {
703
+ return String(conversationHistoryState.oldestMessageId || '').trim();
704
+ }
705
+
706
+ function getConversationFutureCursor() {
707
+ return String(conversationHistoryState.newestMessageId || '').trim();
708
+ }
709
+
710
+ export function getConversationLoadedMessageCount() {
711
+ return Math.max(0, Number(conversationHistoryState.loadedMessageCount) || 0);
712
+ }
713
+
714
+ export function initConversationHistoryLazyLoading() {
715
+ const el = getMessagesElement();
716
+ if (!el || el.dataset.historyLazyLoadBound === '1') return;
717
+ el.dataset.historyLazyLoadBound = '1';
718
+ el.addEventListener('scroll', () => {
719
+ if (resetConversationPaginationIfStale()) return;
720
+ void conversationHistoryLoader.handleBoundaryDistance(el.scrollTop);
721
+ const forwardDistance = Math.max(0, el.scrollHeight - el.clientHeight - el.scrollTop);
722
+ void conversationFutureLoader.handleBoundaryDistance(forwardDistance);
723
+ }, { passive: true });
724
+ }
725
+
726
+ function buildHistoryLoadMoreMarkup(loading = false) {
727
+ const text = loading ? 'Loading older…' : 'Load older messages';
728
+ return `
729
+ <div id="${HISTORY_LOAD_MORE_ID}" class="history-load-more">
730
+ <button type="button" class="history-load-more-btn" onclick="loadOlderConversationMessages()" ${loading ? 'disabled' : ''}>${text}</button>
731
+ </div>`;
732
+ }
733
+
734
+ function syncHistoryLoadMoreControl() {
735
+ const el = getMessagesElement();
736
+ if (!el) return;
737
+ let box = document.getElementById(HISTORY_LOAD_MORE_ID);
738
+ if (!conversationHistoryState.hasMoreOlder) {
739
+ box?.remove();
740
+ return;
741
+ }
742
+ if (!box) {
743
+ const marker = el.querySelector(FIRST_TRANSCRIPT_ROW_SELECTOR);
744
+ if (!marker) {
745
+ el.insertAdjacentHTML('beforeend', buildHistoryLoadMoreMarkup(conversationHistoryState.loadingOlder));
746
+ return;
747
+ }
748
+ marker.insertAdjacentHTML('beforebegin', buildHistoryLoadMoreMarkup(conversationHistoryState.loadingOlder));
749
+ return;
750
+ }
751
+ const btn = box.querySelector('button');
752
+ if (!btn) return;
753
+ btn.disabled = conversationHistoryState.loadingOlder;
754
+ btn.textContent = conversationHistoryState.loadingOlder ? 'Loading older…' : 'Load older messages';
755
+ }
756
+
757
+ function splitVariantId(modelVariantId = '') {
758
+ const value = String(modelVariantId || '').trim();
759
+ if (!value) return { baseModelId: '', reasoningEffort: null };
760
+ const match = value.match(/^(.*)-(none|low|medium|high|xhigh|max)$/i);
761
+ if (!match) return { baseModelId: value, reasoningEffort: null };
762
+ return {
763
+ baseModelId: String(match[1] || '').trim(),
764
+ reasoningEffort: String(match[2] || '').trim().toLowerCase(),
765
+ };
766
+ }
767
+
768
+ function createMessageNode(msg, msgId = null, force = false) {
769
+ const el = getMessagesElement();
770
+ if (!el) return null;
771
+
772
+ if (msgId) {
773
+ const existing = el.querySelector(`[data-message-id="${msgId}"]`);
774
+ if (existing) return existing;
775
+ if (!force && seenMessageIds.has(msgId)) return null;
776
+ seenMessageIds.add(msgId);
777
+ }
778
+
779
+ const div = document.createElement('div');
780
+ div.className = `msg ${msg.role}`;
781
+ if (msgId) div.dataset.messageId = msgId;
782
+ const fingerprint = buildLiveMessageFingerprint({
783
+ ...(msg && typeof msg === 'object' ? msg : {}),
784
+ id: msgId || msg?.id || '',
785
+ });
786
+ div.dataset.messageRole = fingerprint.role || '';
787
+ div.dataset.messageTextFingerprint = fingerprint.text || '';
788
+ div.dataset.messageTimestamp = String(msg?.timestamp || '').trim();
789
+ if (fingerprint.sourceMessageId) div.dataset.sourceMessageId = fingerprint.sourceMessageId;
790
+
791
+ const label = msg.role === 'user' ? 'You' : '';
792
+ const { baseModelId, reasoningEffort } = splitVariantId(msg.model);
793
+ const explicitReasoningEffort = String(msg?.reasoningEffort || '').trim().toLowerCase() || null;
794
+ const resolvedReasoningEffort = explicitReasoningEffort || reasoningEffort;
795
+ const modelOrigin = String(msg?.modelOrigin || '').trim().toLowerCase();
796
+ const modelTag = (msg.role === 'assistant' && baseModelId)
797
+ ? ` <span class="msg-model">${escHtml(baseModelId)}</span>` : '';
798
+ const reasoningTag = (msg.role === 'assistant' && resolvedReasoningEffort && resolvedReasoningEffort !== 'none')
799
+ ? ` <span class="msg-reasoning">${escHtml(resolvedReasoningEffort)}</span>` : '';
800
+ const modeTag = msg.mode
801
+ ? ` <span class="msg-mode">${escHtml(msg.mode)}</span>` : '';
802
+ const autoTag = (msg.role === 'assistant' && modelOrigin === 'auto')
803
+ ? ' <span class="msg-auto">auto</span>' : '';
804
+ // A turn the agent started on its own after a background task settled — no
805
+ // user prompt precedes it, so say where it came from.
806
+ const continuationTag = (msg.role === 'assistant' && String(msg?.kind || '').trim() === 'continuation')
807
+ ? ' <span class="msg-continuation" title="The agent continued on its own after a background task finished.">background continuation</span>'
808
+ : '';
809
+ // A turn answered by a different provider than the conversation is bound to
810
+ // (e.g. the Copilot relay answering a Cursor conversation) must be visible,
811
+ // not silent: it ran on another plan than the header indicates.
812
+ const executedProvider = String(msg?.executedProvider || '').trim().toLowerCase();
813
+ const boundProvider = String(conversations[currentConvId]?.runtimeProviderType || 'github').trim().toLowerCase();
814
+ // 'unknown' means the responder identity did not resolve, not that another
815
+ // provider ran the turn — no chip for it.
816
+ const crossProviderTag = (msg.role === 'assistant' && executedProvider && executedProvider !== 'unknown' && executedProvider !== boundProvider)
817
+ ? ` <span class="msg-provider-mismatch" title="This turn was executed by the ${escHtml(executedProvider)} provider, not the conversation's ${escHtml(boundProvider)} provider.">ran on ${escHtml(executedProvider)}</span>`
818
+ : '';
819
+ const usage = (msg.role === 'assistant' && msg?.usage && typeof msg.usage === 'object') ? msg.usage : null;
820
+ const deltaCredits = Number(usage?.premium?.deltaCredits ?? usage?.premium?.deltaUsed);
821
+ const deltaMonthlyPercent = Number(usage?.plan?.deltaMonthlyPercent);
822
+ // Number(null) is 0, which would make missing data render as "0% left".
823
+ const monthlyPercentRemaining = usage?.plan?.percentRemaining == null
824
+ ? NaN
825
+ : Number(usage.plan.percentRemaining);
826
+ const usageTurnParts = [];
827
+ if (Number.isFinite(deltaCredits) && deltaCredits > 0) {
828
+ usageTurnParts.push(`+${escHtml(String(deltaCredits))}`);
829
+ }
830
+ if (Number.isFinite(deltaMonthlyPercent) && deltaMonthlyPercent > 0) {
831
+ usageTurnParts.push(`${escHtml(deltaMonthlyPercent.toFixed(3))}%`);
832
+ }
833
+ const usageTurnTag = usageTurnParts.length
834
+ ? ` <span class="msg-usage">${usageTurnParts.join(' (')}${usageTurnParts.length > 1 ? ')' : ''}</span>`
835
+ : '';
836
+ // Exactly 0% remaining is real data and must render; only absent/NaN hides.
837
+ const usageRemainingTag = Number.isFinite(monthlyPercentRemaining)
838
+ ? ` <span class="msg-usage">month ${escHtml(monthlyPercentRemaining.toFixed(1))}% left</span>`
839
+ : '';
840
+ const usageStaleTag = usage?.stale
841
+ ? ' <span class="msg-usage msg-usage-stale">stale</span>'
842
+ : '';
843
+ const content = renderMarkdownPreview(msg.text || '', false);
844
+ const attachments = Array.isArray(msg.attachments) ? msg.attachments : [];
845
+ // capRelayActivityEntries, not slice: a boundary past the cap would take
846
+ // the break row with it.
847
+ const activities = Array.isArray(msg.activities)
848
+ ? capRelayActivityEntries(msg.activities.filter(Boolean), 48)
849
+ : [];
850
+ // A compaction that happened during this turn is rendered as a full-width
851
+ // break row immediately above this message (syncTranscriptSeparators reads
852
+ // the stamp), so it must not also show up inside the bubble's activity list.
853
+ // Only the promoted (last) boundary is hidden — see renderActivityMarkup.
854
+ const promotedBoundaryEntry = promotedCompactBoundaryEntry(activities);
855
+ const compactBoundary = compactBoundaryFromActivities(activities);
856
+ if (compactBoundary) {
857
+ div.dataset.compactBoundary = `${compactBoundary.preTokens ?? ''}|${compactBoundary.postTokens ?? ''}`;
858
+ }
859
+ if (activities.some((item) => item !== promotedBoundaryEntry)) div.classList.add('msg-with-activity');
860
+ const thoughts = Array.isArray(msg.thoughts) ? msg.thoughts.filter((t) => t && String(t.text || '').trim()) : [];
861
+ const attachmentHtml = attachments.length ? renderAttachmentMarkup(attachments, { messageId: msgId }) : '';
862
+ const subagentRuns = Array.isArray(msg.subagentRuns) ? msg.subagentRuns.filter(Boolean) : [];
863
+ const mainActivities = activities.filter((item) => !(normalizeRelayActivityEntry(item)?.subagentRunId));
864
+ const mainThoughts = thoughts.filter((t) => !t?.subagentRunId);
865
+ const activityHtml = mainActivities.length ? renderActivityMarkup(mainActivities, promotedBoundaryEntry) : '';
866
+ const thoughtsHtml = mainThoughts.length ? renderThoughtsMarkup(mainThoughts) : '';
867
+ const subagentHtml = renderSubagentRunsMarkup(subagentRuns, activities, thoughts);
868
+ // Finished background workflows persisted with this assistant message
869
+ // (docs/plans/workflow-progress-tree.md, Phase 4). The template only
870
+ // reserves a placeholder; the cards are DOM-built below so digest text
871
+ // never passes through innerHTML.
872
+ const workflowRuns = (msg.role === 'assistant' && Array.isArray(msg.workflowRuns))
873
+ ? msg.workflowRuns.filter((run) => run && typeof run === 'object').slice(0, 5)
874
+ : [];
875
+ const workflowRunsHtml = workflowRuns.length ? '<div class="msg-workflow-runs"></div>' : '';
876
+ const previewCards = (msg.role === 'assistant' && Array.isArray(msg.previewCards))
877
+ ? msg.previewCards.filter((card) => card && typeof card === 'object').slice(0, 5)
878
+ : [];
879
+ const previewCardsHtml = previewCards.length ? '<div class="msg-preview-cards"></div>' : '';
880
+ const hasVisibleText = Boolean(String(msg.text || '').trim());
881
+ const bubbleClass = (!hasVisibleText && attachments.length && !activities.length)
882
+ ? 'msg-bubble msg-bubble-media-only'
883
+ : 'msg-bubble';
884
+
885
+ const isQueuedUserMessage = msg.role === 'user' && msgId && pendingUserMessageIds.has(msgId);
886
+ const isCancelInFlight = isQueuedUserMessage && bubbleCancelInFlight.has(msgId);
887
+ const hiddenFromShares = msg?.hiddenFromShares === true;
888
+ const activeTurnMessageId = String(getActiveTurnForConversation(currentConvId)?.messageId || '').trim();
889
+ const sourceMessageId = String(msg?.sourceMessageId || '').trim();
890
+ const belongsToActiveTurn = !!activeTurnMessageId
891
+ && (activeTurnMessageId === msgId || activeTurnMessageId === sourceMessageId);
892
+ const canToggleShareVisibility = !IS_SHARED_VIEW && !!msgId && !isQueuedUserMessage && !belongsToActiveTurn;
893
+ const shareVisibilityActionHtml = canToggleShareVisibility
894
+ ? `<div class="msg-share-visibility">
895
+ ${hiddenFromShares ? '<span class="msg-hidden-label">Hidden from shared viewers</span>' : ''}
896
+ <button type="button" class="msg-share-visibility-btn" data-action="toggle-share-visibility" data-message-id="${escHtml(msgId)}" data-hidden-from-shares="${hiddenFromShares ? 'true' : 'false'}" title="${hiddenFromShares ? 'Shows this message in shared conversations' : 'Hides this message from shared conversations'}">${hiddenFromShares ? 'Unhide' : 'Hide'}</button>
897
+ </div>`
898
+ : '';
899
+ // A terminal failure whose fix lives in the relay UI gets the fix as a button
900
+ // instead of an instruction to open a shell on the host. Shared viewers get
901
+ // none of them: they cannot install or sign in to anything.
902
+ const relayErrorActions = (!IS_SHARED_VIEW && msg.role === 'assistant')
903
+ ? relayErrorCtaActions(msg.text)
904
+ : [];
905
+ const relayErrorCtaHtml = relayErrorActions.length
906
+ ? `<div class="msg-error-cta">${relayErrorActions
907
+ .map((item) => `<button type="button" class="bubble-action-btn" data-action="relay-error-cta" data-cta="${escHtml(item.action)}">${escHtml(item.label)}</button>`)
908
+ .join('')}</div>`
909
+ : '';
910
+ const userBubbleActionsHtml = (!IS_SHARED_VIEW && isQueuedUserMessage)
911
+ ? `<div class="msg-bubble-actions"><button type="button" class="bubble-action-btn${isCancelInFlight ? ' stopping' : ''}" data-action="cancel-queued" data-message-id="${escHtml(msgId)}"${isCancelInFlight ? ' disabled' : ''}>${isCancelInFlight ? 'Cancelling…' : 'Cancel'}</button></div>`
912
+ : '';
913
+
914
+ div.innerHTML = `
915
+ <div class="${bubbleClass}">${shareVisibilityActionHtml}${thoughtsHtml}${content}${relayErrorCtaHtml}${attachmentHtml}${activityHtml}${subagentHtml}${workflowRunsHtml}${previewCardsHtml}${userBubbleActionsHtml}</div>
916
+ <div class="msg-label">${label}${modelTag}${reasoningTag}${modeTag}${autoTag}${continuationTag}${crossProviderTag}${usageTurnTag}${usageRemainingTag}${usageStaleTag} · ${fmtDate(msg.timestamp)}</div>`;
917
+
918
+ const bubble = div.querySelector('.msg-bubble');
919
+ rewriteLocalAssetUrlsInNode(bubble, { preferDrive: msg.role === 'assistant' });
920
+ linkifyWorkspaceMentionsInNode(bubble);
921
+ // One collapsed "Finished background task" card per persisted run, sharing
922
+ // the live panel's tree renderer. The native <details> owns the fold; open
923
+ // state is deliberately not keyed across re-renders — the runs are
924
+ // immutable once persisted, and this node is only rebuilt on a full
925
+ // renderMessages pass, which runs only when the snapshot key changes
926
+ // (buildMessageSnapshotKey covers the message fields INCLUDING a
927
+ // runId/status signature of these runs, and short-circuits otherwise).
928
+ if (workflowRuns.length) {
929
+ const runsHolder = div.querySelector('.msg-workflow-runs');
930
+ if (runsHolder) {
931
+ for (const run of workflowRuns) {
932
+ const card = buildWorkflowRunCard(run);
933
+ if (card) runsHolder.appendChild(card);
934
+ }
935
+ }
936
+ }
937
+ // Persisted preview snapshots render as link cards; live/closed state is
938
+ // overlaid from the current registry inside the builder.
939
+ if (previewCards.length) {
940
+ const cardsHolder = div.querySelector('.msg-preview-cards');
941
+ if (cardsHolder) {
942
+ for (const snapshot of previewCards) {
943
+ const card = buildTranscriptPreviewCard(snapshot);
944
+ if (card) cardsHolder.appendChild(card);
945
+ }
946
+ }
947
+ }
948
+ div.querySelectorAll('pre code').forEach((b) => hljs.highlightElement(b));
949
+ attachCodeCopyButtons(div);
950
+ return div;
951
+ }
952
+
953
+ // Never scrolls: the caller scrolls after the separator pass (see
954
+ // appendMessage).
955
+ function insertMessageNode(node, insertAfterId = null) {
956
+ if (!node) return null;
957
+ if (node.parentNode) return node;
958
+ const el = getMessagesElement();
959
+ if (!el) return null;
960
+ const anchorId = String(insertAfterId || '').trim();
961
+ const anchor = anchorId ? el.querySelector(`[data-message-id="${anchorId}"]`) : null;
962
+ if (anchor && anchor.parentNode === el) {
963
+ const next = anchor.nextSibling;
964
+ if (next) el.insertBefore(node, next);
965
+ else el.appendChild(node);
966
+ } else {
967
+ el.appendChild(node);
968
+ }
969
+ return node;
970
+ }
971
+
972
+ function prependMessageNodes(msgs) {
973
+ const el = getMessagesElement();
974
+ if (!el) return { inserted: 0, firstMessageId: '' };
975
+ const ordered = sortConversationMessages(msgs || []);
976
+ const fragment = document.createDocumentFragment();
977
+ let inserted = 0;
978
+ let firstMessageId = '';
979
+ for (const m of ordered) {
980
+ const msgId = String(m?.id || '').trim() || null;
981
+ const node = createMessageNode(m, msgId, true);
982
+ if (!node || node.parentNode) continue;
983
+ if (!firstMessageId && msgId) firstMessageId = msgId;
984
+ fragment.appendChild(node);
985
+ inserted += 1;
986
+ }
987
+ if (!inserted) return { inserted: 0, firstMessageId: '' };
988
+ const marker = el.querySelector(FIRST_TRANSCRIPT_ROW_SELECTOR);
989
+ if (marker && marker.parentNode === el) {
990
+ el.insertBefore(fragment, marker);
991
+ } else {
992
+ el.appendChild(fragment);
993
+ }
994
+ return { inserted, firstMessageId };
995
+ }
996
+
997
+ export function decorateActivityText(text) {
998
+ const value = String(text || '').trim();
999
+ if (!value) return '';
1000
+ const maskSharedPathSegments = (input) => {
1001
+ const source = String(input || '');
1002
+ const tokenMasked = source.replace(/@(file|folder):([^\s`]+)/gi, (_m, kind, rawPath) => {
1003
+ const normalized = String(rawPath || '').replace(/\\/g, '/').replace(/\/+$/, '');
1004
+ const segments = normalized.split('/').filter(Boolean);
1005
+ const basename = segments[segments.length - 1] || normalized;
1006
+ return `@${String(kind || '').toLowerCase()}:${basename}`;
1007
+ });
1008
+ return tokenMasked.replace(/([A-Za-z]:)?(?:[\\/~.]?[\\/])(?:[^\\/\s]+[\\/])+([^\\/\s]+)/g, (_m, _prefix, basename) => basename);
1009
+ };
1010
+ const sharedSafeValue = IS_SHARED_VIEW ? maskSharedPathSegments(value) : value;
1011
+ if (/^[\u{1F300}-\u{1FAFF}\u2600-\u27BF]/u.test(sharedSafeValue)) return sharedSafeValue;
1012
+ if (sharedSafeValue.startsWith('● ')) return `🔄 ${sharedSafeValue.slice(2).trim()}`;
1013
+ if (/^Model selected:/i.test(sharedSafeValue)) return `🧠 ${sharedSafeValue}`;
1014
+ if (/^Search \((glob|grep)\)/i.test(sharedSafeValue)) return `🔍 ${sharedSafeValue}`;
1015
+ if (/^Tool \(ask_user\)/i.test(sharedSafeValue)) return `❓ ${sharedSafeValue}`;
1016
+ if (/^Tool \(view\)/i.test(sharedSafeValue)) return `👀 ${sharedSafeValue}`;
1017
+ if (/^Tool \(apply_patch\)/i.test(sharedSafeValue)) return `🪡 ${sharedSafeValue}`;
1018
+ if (/^Tool \(powershell\)/i.test(sharedSafeValue)) return `🪓 ${sharedSafeValue}`;
1019
+ if (/^Tool \(edit\)/i.test(sharedSafeValue)) return `📝 ${sharedSafeValue}`;
1020
+ if (/^Tool \(read_file\)/i.test(sharedSafeValue)) return `📄 ${sharedSafeValue}`;
1021
+ if (/^Tool \((grep_search|file_search)\)/i.test(sharedSafeValue)) return `🔎 ${sharedSafeValue}`;
1022
+ if (/^Tool \(semantic_search\)/i.test(sharedSafeValue)) return `🧭 ${sharedSafeValue}`;
1023
+ if (/^Tool \(vscode_listCodeUsages\)/i.test(sharedSafeValue)) return `🔗 ${sharedSafeValue}`;
1024
+ if (/^Tool \(vscode_renameSymbol\)/i.test(sharedSafeValue)) return `✏️ ${sharedSafeValue}`;
1025
+ if (/^Tool \(list_dir\)/i.test(sharedSafeValue)) return `📂 ${sharedSafeValue}`;
1026
+ if (/^Tool \(create_directory\)/i.test(sharedSafeValue)) return `📁 ${sharedSafeValue}`;
1027
+ if (/^Tool \((delete|remove)\)/i.test(sharedSafeValue)) return `🗑️ ${sharedSafeValue}`;
1028
+ if (/^Tool \(execution_subagent\)/i.test(sharedSafeValue)) return `🚀 ${sharedSafeValue}`;
1029
+ if (/^Tool \(get_errors\)/i.test(sharedSafeValue)) return `🚨 ${sharedSafeValue}`;
1030
+ if (/^Tool \(debug_[^)]+\)/i.test(sharedSafeValue)) return `🐞 ${sharedSafeValue}`;
1031
+ if (/^Tool \(fetch_webpage\)/i.test(sharedSafeValue)) return `🌐 ${sharedSafeValue}`;
1032
+ if (/^Tool \(github_[^)]+\)/i.test(sharedSafeValue)) return `🐙 ${sharedSafeValue}`;
1033
+ if (/^Tool \(run_in_terminal\)/i.test(sharedSafeValue)) return `🖥️ ${sharedSafeValue}`;
1034
+ if (/^Tool \((create_file|write)\)/i.test(sharedSafeValue)) return `🆕 ${sharedSafeValue}`;
1035
+ if (/^Tool \((bash|shell|terminal)\)/i.test(sharedSafeValue)) return `🔧 ${sharedSafeValue}`;
1036
+ if (/^Tool \((sql|sqlite)\)/i.test(sharedSafeValue)) return `🗄️ ${sharedSafeValue}`;
1037
+ if (/^Tool \(/i.test(sharedSafeValue)) return `🛠️ ${sharedSafeValue}`;
1038
+ return `ℹ️ ${sharedSafeValue}`;
1039
+ }
1040
+
1041
+ export function renderThoughtsMarkup(thoughts) {
1042
+ const items = (Array.isArray(thoughts) ? thoughts : [])
1043
+ .map((thought) => ({
1044
+ reasoningId: String(thought?.reasoningId || '').trim(),
1045
+ text: String(thought?.text || '').trim(),
1046
+ }))
1047
+ .filter((thought) => thought.text);
1048
+ if (!items.length) return '';
1049
+ const blocks = items
1050
+ .map((thought) => `<div class="msg-thought-item"${thought.reasoningId ? ` data-reasoning-id="${escHtml(thought.reasoningId)}"` : ''}>${renderMarkdownPreview(thought.text, false)}</div>`)
1051
+ .join('');
1052
+ return `
1053
+ <details class="msg-thoughts">
1054
+ <summary>💭 Thoughts (${items.length})</summary>
1055
+ <div class="msg-thoughts-list">${blocks}</div>
1056
+ </details>`;
1057
+ }
1058
+
1059
+ function enhanceThoughtMarkup(root) {
1060
+ if (!(root instanceof Element)) return;
1061
+ rewriteLocalAssetUrlsInNode(root, { preferDrive: true });
1062
+ linkifyWorkspaceMentionsInNode(root);
1063
+ root.querySelectorAll('pre code').forEach((block) => {
1064
+ if (globalThis.hljs?.highlightElement) globalThis.hljs.highlightElement(block);
1065
+ });
1066
+ attachCodeCopyButtons(root);
1067
+ }
1068
+
1069
+ // Streamed markdown grows at the tail, so only nodes from the first divergent
1070
+ // top-level block onward are replaced. Signatures are cached on the container:
1071
+ // enhancement passes (hljs, link rewriting) mutate the rendered nodes, so
1072
+ // re-deriving signatures from the DOM would break the stable prefix at the
1073
+ // first code block. Returns the appended nodes for scoped enhancement.
1074
+ function patchRenderedMarkdown(box, html) {
1075
+ const template = document.createElement('template');
1076
+ template.innerHTML = html;
1077
+ const nextNodes = [...template.content.childNodes];
1078
+ const signatureOf = (node) => (node.nodeType === 1 ? node.outerHTML : `#text:${node.textContent}`);
1079
+ const nextSignatures = nextNodes.map(signatureOf);
1080
+ const cached = box.__streamSignatures;
1081
+ const prevSignatures = Array.isArray(cached) && cached.length === box.childNodes.length
1082
+ ? cached
1083
+ : [...box.childNodes].map(signatureOf);
1084
+ const stable = computeStablePrefixLength(prevSignatures, nextSignatures);
1085
+ while (box.childNodes.length > stable) box.lastChild.remove();
1086
+ const appended = [];
1087
+ for (let i = stable; i < nextNodes.length; i += 1) {
1088
+ box.appendChild(nextNodes[i]);
1089
+ appended.push(nextNodes[i]);
1090
+ }
1091
+ box.__streamSignatures = nextSignatures;
1092
+ return appended;
1093
+ }
1094
+
1095
+ function renderThoughtBody(body, text) {
1096
+ if (!body) return;
1097
+ if (selectionIntersectsNode(body)) return;
1098
+ const value = String(text || '');
1099
+ const fingerprint = `${value.length}:${value.slice(-32)}`;
1100
+ if (body.dataset.thoughtFingerprint === fingerprint) return;
1101
+ body.dataset.thoughtFingerprint = fingerprint;
1102
+ const appended = patchRenderedMarkdown(body, renderMarkdownPreview(value, false));
1103
+ for (const node of appended) {
1104
+ if (node.nodeType === 1) enhanceThoughtMarkup(node);
1105
+ }
1106
+ }
1107
+
1108
+ export function renderActivityMarkup(activities, promotedBoundaryEntry = undefined) {
1109
+ // The compaction boundary that got promoted to a transcript break row must
1110
+ // not be repeated inside the collapsed tool-activity details. A turn that
1111
+ // compacted more than once only promotes its LAST boundary, so the earlier
1112
+ // ones stay visible here as prose rather than disappearing from the
1113
+ // transcript altogether (one break row per message is all the separator
1114
+ // planner can place).
1115
+ const list = Array.isArray(activities) ? activities : [];
1116
+ const promoted = promotedBoundaryEntry === undefined
1117
+ ? promotedCompactBoundaryEntry(list)
1118
+ : promotedBoundaryEntry;
1119
+ const visible = list.filter((item) => item !== promoted);
1120
+ const progress = visible.filter((item) => relayActivityEntryText(item).startsWith('● '));
1121
+ const tools = visible.filter((item) => !relayActivityEntryText(item).startsWith('● '));
1122
+ const progressHtml = progress.length
1123
+ ? `<div class="msg-activity-list">${progress.map((item) => `<div class="msg-activity-item">${escHtml(decorateActivityText(relayActivityEntryText(item)))}</div>`).join('')}</div>`
1124
+ : '';
1125
+ const toolsHtml = tools.length
1126
+ ? `
1127
+ <details class="msg-activity">
1128
+ <summary>🔧 Tool activity (${tools.length})</summary>
1129
+ <div class="msg-activity-list">${tools.map((item) => `<div class="msg-activity-item">${escHtml(decorateActivityText(relayActivityEntryText(item)))}</div>`).join('')}</div>
1130
+ </details>`
1131
+ : '';
1132
+ return `${progressHtml}${toolsHtml}`;
1133
+ }
1134
+
1135
+ // Persisted nested subagent sections: groups a finished message's activities
1136
+ // and thoughts by subagentRunId and renders one collapsible block per run,
1137
+ // nested by parentSubagentId — mirroring the live sub-bubbles.
1138
+ export function renderSubagentRunsMarkup(subagentRuns, activities, thoughts) {
1139
+ const activityByRun = new Map();
1140
+ for (const item of (Array.isArray(activities) ? activities : [])) {
1141
+ const entry = normalizeRelayActivityEntry(item);
1142
+ if (!entry?.subagentRunId) continue;
1143
+ const list = activityByRun.get(entry.subagentRunId) || [];
1144
+ list.push(entry.text);
1145
+ activityByRun.set(entry.subagentRunId, list);
1146
+ }
1147
+ const thoughtsByRun = new Map();
1148
+ for (const thought of (Array.isArray(thoughts) ? thoughts : [])) {
1149
+ const runId = thought?.subagentRunId ? String(thought.subagentRunId).trim() : '';
1150
+ const text = String(thought?.text || '').trim();
1151
+ if (!runId || !text) continue;
1152
+ const list = thoughtsByRun.get(runId) || [];
1153
+ list.push({ reasoningId: String(thought?.reasoningId || '').trim(), text });
1154
+ thoughtsByRun.set(runId, list);
1155
+ }
1156
+ const runsById = new Map();
1157
+ for (const run of (Array.isArray(subagentRuns) ? subagentRuns : [])) {
1158
+ const runId = String(run?.subagentRunId || '').trim();
1159
+ if (!runId) continue;
1160
+ runsById.set(runId, {
1161
+ subagentRunId: runId,
1162
+ parentSubagentId: run?.parentSubagentId ? String(run.parentSubagentId).trim() : null,
1163
+ displayName: String(run?.displayName || '').trim() || `Subagent ${runId.slice(0, 8)}`,
1164
+ status: String(run?.status || 'completed').trim().toLowerCase(),
1165
+ });
1166
+ }
1167
+ // Items may reference runs whose metadata rows are unavailable; synthesize.
1168
+ for (const runId of new Set([...activityByRun.keys(), ...thoughtsByRun.keys()])) {
1169
+ if (!runsById.has(runId)) {
1170
+ runsById.set(runId, {
1171
+ subagentRunId: runId,
1172
+ parentSubagentId: null,
1173
+ displayName: `Subagent ${runId.slice(0, 8)}`,
1174
+ status: 'completed',
1175
+ });
1176
+ }
1177
+ }
1178
+ if (!runsById.size) return '';
1179
+ const childrenByParent = new Map();
1180
+ const roots = [];
1181
+ for (const run of runsById.values()) {
1182
+ const parentId = run.parentSubagentId && runsById.has(run.parentSubagentId) ? run.parentSubagentId : null;
1183
+ if (parentId) {
1184
+ const children = childrenByParent.get(parentId) || [];
1185
+ children.push(run);
1186
+ childrenByParent.set(parentId, children);
1187
+ } else {
1188
+ roots.push(run);
1189
+ }
1190
+ }
1191
+ const renderRun = (run) => {
1192
+ const runActivities = activityByRun.get(run.subagentRunId) || [];
1193
+ const runThoughts = thoughtsByRun.get(run.subagentRunId) || [];
1194
+ const children = childrenByParent.get(run.subagentRunId) || [];
1195
+ const thoughtsBlock = runThoughts.length
1196
+ ? `<details class="msg-thoughts msg-subagent-thoughts"><summary>💭 Thoughts (${runThoughts.length})</summary><div class="msg-thoughts-list">${runThoughts.map((thought) => `<div class="msg-thought-item"${thought.reasoningId ? ` data-reasoning-id="${escHtml(thought.reasoningId)}"` : ''}>${renderMarkdownPreview(thought.text, false)}</div>`).join('')}</div></details>`
1197
+ : '';
1198
+ const activitiesBlock = runActivities.length
1199
+ ? `<div class="msg-activity-list">${runActivities.map((text) => `<div class="msg-activity-item">${escHtml(decorateActivityText(text))}</div>`).join('')}</div>`
1200
+ : '';
1201
+ const childrenBlock = children.map(renderRun).join('');
1202
+ const statusLabel = run.status && run.status !== 'completed' ? ` · ${escHtml(run.status)}` : '';
1203
+ return `
1204
+ <details class="msg-activity msg-subagent-run" data-subagent-run-id="${escHtml(run.subagentRunId)}">
1205
+ <summary>🤖 ${escHtml(run.displayName)}${statusLabel}${runActivities.length ? ` (${runActivities.length})` : ''}</summary>
1206
+ ${thoughtsBlock}${activitiesBlock}${childrenBlock}
1207
+ </details>`;
1208
+ };
1209
+ return roots.map(renderRun).join('');
1210
+ }
1211
+
1212
+ export function showThinking(messageId = null, autoScroll = true) {
1213
+ const nextMessageId = String(messageId || '').trim();
1214
+ if (nextMessageId) thinkingMessageId = nextMessageId;
1215
+ const existing = document.getElementById('thinking-indicator');
1216
+ if (existing && (!nextMessageId || String(existing.dataset.messageId || '') === nextMessageId)) {
1217
+ // Reuse the live bubble: rebuilding it every poll tick destroys any text
1218
+ // selection anchored inside and drops in-progress subagent bubbles.
1219
+ const stopBtn = existing.querySelector('[data-action="stop-turn"]');
1220
+ if (stopBtn && nextMessageId) {
1221
+ const stopping = bubbleCancelInFlight.has(nextMessageId);
1222
+ stopBtn.disabled = stopping;
1223
+ stopBtn.textContent = stopping ? 'Stopping…' : 'Stop';
1224
+ stopBtn.classList.toggle('stopping', stopping);
1225
+ }
1226
+ if (autoScroll) scrollBottom();
1227
+ return;
1228
+ }
1229
+ existing?.remove();
1230
+ const el = document.getElementById('messages');
1231
+ const div = document.createElement('div');
1232
+ div.className = 'msg assistant';
1233
+ div.id = 'thinking-indicator';
1234
+ if (nextMessageId) div.dataset.messageId = nextMessageId;
1235
+ const isCancelInFlight = nextMessageId && bubbleCancelInFlight.has(nextMessageId);
1236
+ const stopBtnHtml = (!IS_SHARED_VIEW && nextMessageId)
1237
+ ? `<button type="button" class="bubble-action-btn${isCancelInFlight ? ' stopping' : ''}" data-action="stop-turn" data-message-id="${escHtml(nextMessageId)}"${isCancelInFlight ? ' disabled' : ''}>${isCancelInFlight ? 'Stopping…' : 'Stop'}</button>`
1238
+ : '';
1239
+ div.innerHTML = `
1240
+ <div class="thinking-bubble">
1241
+ <div class="thinking-bubble-header">${stopBtnHtml}</div>
1242
+ <details id="thinking-thoughts" class="thinking-thoughts-panel" open>
1243
+ <summary>💭 Thoughts</summary>
1244
+ <div class="thinking-thoughts-list"></div>
1245
+ </details>
1246
+ <div id="thinking-stream" class="thinking-stream" hidden></div>
1247
+ <div class="dots"><span></span><span></span><span></span></div>
1248
+ <div id="thinking-activity" class="thinking-activity"></div>
1249
+ <div class="subagent-bubbles-container" data-subagent-bubbles-root="1"></div>
1250
+ </div>`;
1251
+ const target = nextMessageId ? el.querySelector(`[data-message-id="${nextMessageId}"]`) : null;
1252
+ if (target && target.parentNode === el) {
1253
+ const next = target.nextSibling;
1254
+ if (next) el.insertBefore(div, next);
1255
+ else el.appendChild(div);
1256
+ } else {
1257
+ el.appendChild(div);
1258
+ }
1259
+ renderThinkingThoughts();
1260
+ renderThinkingStream();
1261
+ if (autoScroll) scrollBottom();
1262
+ }
1263
+
1264
+ export function removeThinking() {
1265
+ thinkingMessageId = null;
1266
+ lastInFlightSnapshotKey = '';
1267
+ document.getElementById('thinking-indicator')?.remove();
1268
+ }
1269
+
1270
+ export function collapseThinkingThoughts() {
1271
+ const panel = document.getElementById('thinking-thoughts');
1272
+ if (!(panel instanceof HTMLDetailsElement)) return;
1273
+ panel.open = false;
1274
+ panel.querySelectorAll('.thinking-thought').forEach((row) => {
1275
+ if (row instanceof HTMLDetailsElement) row.open = false;
1276
+ });
1277
+ }
1278
+
1279
+ function clearRelayStreamState(messageId = null) {
1280
+ const id = String(messageId || '').trim();
1281
+ if (!id) {
1282
+ relayStreamStateByMessageId.clear();
1283
+ relayStreamTextByMessageId.clear();
1284
+ return;
1285
+ }
1286
+ relayStreamStateByMessageId.delete(id);
1287
+ relayStreamTextByMessageId.delete(id);
1288
+ }
1289
+
1290
+ function rememberRelayStreamState(messageId, seq, done = false) {
1291
+ const id = String(messageId || '').trim();
1292
+ if (!id) return null;
1293
+ const normalizedSeq = normalizeStreamSeq(seq);
1294
+ const prev = relayStreamStateByMessageId.get(id) || { seq: 0, done: false };
1295
+ const next = {
1296
+ seq: normalizedSeq === null ? prev.seq : normalizedSeq,
1297
+ done: prev.done || !!done,
1298
+ };
1299
+ relayStreamStateByMessageId.set(id, next);
1300
+ return next;
1301
+ }
1302
+
1303
+ // Live reply preview: the assistant's text as it streams, rendered with the
1304
+ // same markdown path the finished bubble uses. Replaced by the real assistant
1305
+ // message when the turn completes and `removeThinking()` drops this bubble.
1306
+ function renderThinkingStream() {
1307
+ const box = document.getElementById('thinking-stream');
1308
+ if (!box) return;
1309
+ // The store keeps the latest text; the next frame after the selection is
1310
+ // released repaints, so skipping here never loses content.
1311
+ if (selectionIntersectsNode(box)) return;
1312
+ const text = thinkingMessageId ? String(relayStreamTextByMessageId.get(thinkingMessageId) || '') : '';
1313
+ if (!text.trim()) {
1314
+ box.hidden = true;
1315
+ box.innerHTML = '';
1316
+ return;
1317
+ }
1318
+ box.hidden = false;
1319
+ patchRenderedMarkdown(box, renderMarkdownPreview(text, false));
1320
+ }
1321
+
1322
+ function renderSubagentStream(subagentRunId, text) {
1323
+ const id = String(subagentRunId || '').trim();
1324
+ if (!id) return;
1325
+ const bubble = ensureSubagentBubble(id);
1326
+ if (!bubble) return;
1327
+ let box = bubble.querySelector(':scope > .subagent-stream');
1328
+ if (!box) {
1329
+ box = document.createElement('div');
1330
+ box.className = 'subagent-stream';
1331
+ // Above the nested-children container so a subagent's own text stays with
1332
+ // its bubble rather than reading as part of a child run.
1333
+ const childrenContainer = bubble.querySelector(':scope > .subagent-bubbles-container');
1334
+ if (childrenContainer) bubble.insertBefore(box, childrenContainer);
1335
+ else bubble.appendChild(box);
1336
+ }
1337
+ if (selectionIntersectsNode(box)) return;
1338
+ const value = String(text || '');
1339
+ if (!value.trim()) {
1340
+ box.hidden = true;
1341
+ box.innerHTML = '';
1342
+ return;
1343
+ }
1344
+ box.hidden = false;
1345
+ patchRenderedMarkdown(box, renderMarkdownPreview(value, false));
1346
+ }
1347
+
1348
+ function patchActivityList(box, expectedTexts, className) {
1349
+ const current = Array.from(box.children, (row) => row.textContent || '');
1350
+ const plan = planListPatch(current, expectedTexts);
1351
+ if (plan.reset) box.innerHTML = '';
1352
+ for (const text of plan.appends) {
1353
+ const row = document.createElement('div');
1354
+ row.className = className;
1355
+ row.textContent = text;
1356
+ box.appendChild(row);
1357
+ }
1358
+ }
1359
+
1360
+ export function renderThinkingActivities() {
1361
+ const items = thinkingMessageId ? (relayActivities.get(thinkingMessageId) || []) : [];
1362
+ const box = document.getElementById('thinking-activity');
1363
+ if (!box) return;
1364
+ // Idempotent replay: the live bubble is reused across poll ticks, so a
1365
+ // clear-and-replay (or the append path's last-row-only dedupe) would either
1366
+ // wipe selections or duplicate rows on every changed payload.
1367
+ const mainExpected = [];
1368
+ const bySubagentRun = new Map();
1369
+ for (const item of items) {
1370
+ const entry = normalizeRelayActivityEntry(item);
1371
+ if (!entry) continue;
1372
+ // The persisted message promotes a compaction boundary to a full-width
1373
+ // break row, so the live bubble must not show it as a prose line — it
1374
+ // would otherwise flip presentation the moment the turn lands.
1375
+ if (isCompactBoundaryActivityEntry(entry)) continue;
1376
+ const decorated = decorateActivityText(entry.text);
1377
+ if (entry.subagentRunId) {
1378
+ const list = bySubagentRun.get(entry.subagentRunId) || [];
1379
+ if (list[list.length - 1] !== decorated) list.push(decorated);
1380
+ bySubagentRun.set(entry.subagentRunId, list);
1381
+ } else if (mainExpected[mainExpected.length - 1] !== decorated) {
1382
+ mainExpected.push(decorated);
1383
+ }
1384
+ }
1385
+ if (!selectionIntersectsNode(box)) {
1386
+ patchActivityList(box, mainExpected, 'thinking-activity-item');
1387
+ }
1388
+ for (const [subagentRunId, expected] of bySubagentRun) {
1389
+ const bubble = ensureSubagentBubble(subagentRunId);
1390
+ const runBox = bubble?.querySelector('.subagent-activity');
1391
+ if (runBox && !selectionIntersectsNode(runBox)) {
1392
+ patchActivityList(runBox, expected, 'subagent-activity-item');
1393
+ }
1394
+ }
1395
+ }
1396
+
1397
+ export function restoreInFlightThinking(inFlight, autoScroll = true) {
1398
+ const messageId = String(inFlight?.messageId || '').trim();
1399
+ const status = String(inFlight?.status || '').trim().toLowerCase();
1400
+ if (!messageId || status !== 'processing') {
1401
+ clearRelayStreamState();
1402
+ setConversationTurnState(currentConvId, null);
1403
+ thinkingMessageId = null;
1404
+ removeThinking();
1405
+ return;
1406
+ }
1407
+ // Skip the rebuild when the payload matches the previous tick: the 900ms
1408
+ // live poll would otherwise churn the DOM (and the user's selection) with
1409
+ // identical content.
1410
+ const snapshotKey = buildInFlightSnapshotKey(inFlight);
1411
+ if (snapshotKey === lastInFlightSnapshotKey && document.getElementById('thinking-indicator')) {
1412
+ return;
1413
+ }
1414
+ lastInFlightSnapshotKey = snapshotKey;
1415
+ clearRelayStreamState();
1416
+ setConversationTurnState(currentConvId, { messageId, status: 'processing' });
1417
+ const activities = mergeRelayActivityTexts(
1418
+ relayActivities.get(messageId) || [],
1419
+ Array.isArray(inFlight.activities) ? inFlight.activities : [],
1420
+ );
1421
+ relayActivities.set(messageId, activities);
1422
+ const inFlightThoughts = Array.isArray(inFlight.thoughts) ? inFlight.thoughts : [];
1423
+ if (inFlightThoughts.length) {
1424
+ const thoughtMap = relayThoughts.get(messageId) || new Map();
1425
+ for (const entry of inFlightThoughts) {
1426
+ const key = String(entry?.reasoningId || `seq-${entry?.seq || thoughtMap.size}`);
1427
+ thoughtMap.set(key, { reasoningId: key, text: String(entry?.text || ''), done: !!entry?.done, subagentRunId: entry?.subagentRunId || null });
1428
+ }
1429
+ relayThoughts.set(messageId, thoughtMap);
1430
+ }
1431
+ const inFlightSubagentRuns = Array.isArray(inFlight.subagentRuns) ? inFlight.subagentRuns : [];
1432
+ clearSubagentRunsForMessage(messageId);
1433
+ for (const entry of inFlightSubagentRuns) {
1434
+ upsertSubagentRun({
1435
+ subagentRunId: entry?.subagentRunId,
1436
+ messageId: entry?.messageId || messageId,
1437
+ conversationId: entry?.conversationId || currentConvId,
1438
+ parentSubagentId: entry?.parentSubagentId || null,
1439
+ displayName: entry?.displayName || null,
1440
+ status: entry?.status || 'running',
1441
+ timestamp: entry?.updatedAt || entry?.startedAt || null,
1442
+ });
1443
+ }
1444
+ for (const item of activities) {
1445
+ const entry = normalizeRelayActivityEntry(item);
1446
+ if (!entry?.subagentRunId) continue;
1447
+ upsertSubagentRun({
1448
+ subagentRunId: entry.subagentRunId,
1449
+ messageId,
1450
+ conversationId: currentConvId,
1451
+ });
1452
+ }
1453
+ // Seed the per-thread stream text before the bubbles are built so both the
1454
+ // reply preview and each subagent bubble repaint on reload.
1455
+ const streamTextByThread = deriveInFlightStreamTextByThread(inFlight);
1456
+ if (streamTextByThread.main) {
1457
+ relayStreamTextByMessageId.set(messageId, streamTextByThread.main.text);
1458
+ }
1459
+ for (const [runId, entry] of streamTextByThread.bySubagentRunId) {
1460
+ upsertSubagentRun({ subagentRunId: runId, messageId, conversationId: currentConvId });
1461
+ setSubagentStreamText(runId, entry.text);
1462
+ }
1463
+ showThinking(messageId, autoScroll);
1464
+ renderThinkingActivities();
1465
+ renderThinkingThoughts();
1466
+ renderRestoredSubagentBubbles(messageId);
1467
+ const streamState = deriveLatestInFlightStreamEvent(inFlight);
1468
+ if (streamState) {
1469
+ rememberRelayStreamState(messageId, streamState.seq, streamState.done || !!inFlight?.streamDone);
1470
+ updateThinkingStreamStatus(messageId, streamState.done || !!inFlight?.streamDone, autoScroll);
1471
+ return;
1472
+ }
1473
+ const fallbackSeq = normalizeStreamSeq(inFlight?.lastStreamSeq);
1474
+ if (fallbackSeq !== null || inFlight?.streamDone) {
1475
+ rememberRelayStreamState(messageId, fallbackSeq === null ? 0 : fallbackSeq, !!inFlight?.streamDone);
1476
+ }
1477
+ }
1478
+
1479
+ // `item` is either the plain activity text or a full activity entry
1480
+ // ({ text, subagentRunId, metadata }); the entry form is what lets the live
1481
+ // bubble recognise a compaction boundary.
1482
+ export function appendThinkingActivity(item, subagentRunId = null, autoScroll = true) {
1483
+ const entry = normalizeRelayActivityEntry(
1484
+ item && typeof item === 'object' ? item : { text: item, subagentRunId },
1485
+ );
1486
+ if (!entry) return;
1487
+ // Same rule as renderThinkingActivities: a boundary becomes a break row on
1488
+ // the persisted message, so it never shows as live prose.
1489
+ if (isCompactBoundaryActivityEntry(entry)) return;
1490
+ const decorated = decorateActivityText(entry.text);
1491
+
1492
+ if (subagentRunId) {
1493
+ const subagentBubble = ensureSubagentBubble(subagentRunId);
1494
+ if (subagentBubble) {
1495
+ const activityBox = subagentBubble.querySelector('.subagent-activity');
1496
+ if (activityBox) {
1497
+ const lastItem = activityBox.lastElementChild?.textContent || '';
1498
+ if (lastItem !== decorated) {
1499
+ const row = document.createElement('div');
1500
+ row.className = 'subagent-activity-item';
1501
+ row.textContent = decorated;
1502
+ activityBox.appendChild(row);
1503
+ }
1504
+ }
1505
+ if (autoScroll) scrollBottom();
1506
+ }
1507
+ // Without a bubble the store still holds the entry; it replays when the
1508
+ // bubble appears. Never demote subagent activity into the parent box.
1509
+ return;
1510
+ }
1511
+
1512
+ const box = document.getElementById('thinking-activity');
1513
+ if (!box) return;
1514
+ const last = box.lastElementChild?.textContent || '';
1515
+ if (last === decorated) return;
1516
+ const row = document.createElement('div');
1517
+ row.className = 'thinking-activity-item';
1518
+ if (subagentRunId) row.dataset.subagentRunId = subagentRunId;
1519
+ row.textContent = decorated;
1520
+ box.appendChild(row);
1521
+ if (autoScroll) scrollBottom();
1522
+ }
1523
+
1524
+ function getSubagentDisplayName(subagentRunId) {
1525
+ const entry = typeof getSubagentRun === 'function' ? getSubagentRun(subagentRunId) : null;
1526
+ if (entry?.displayName) return entry.displayName;
1527
+ const id = String(subagentRunId || '').trim();
1528
+ if (!id) return 'Subagent';
1529
+ const short = id.length > 12 ? `${id.slice(0, 8)}…` : id;
1530
+ return `Subagent ${short}`;
1531
+ }
1532
+
1533
+ function getSubagentStatus(subagentRunId) {
1534
+ const entry = typeof getSubagentRun === 'function' ? getSubagentRun(subagentRunId) : null;
1535
+ return entry?.status || 'running';
1536
+ }
1537
+
1538
+ function normalizeSubagentBubbleStatus(status) {
1539
+ const normalized = String(status || 'running').trim().toLowerCase() || 'running';
1540
+ if (normalized === 'processing') return 'running';
1541
+ return normalized;
1542
+ }
1543
+
1544
+ function isSubagentTerminalStatus(status) {
1545
+ return SUBAGENT_TERMINAL_STATUSES.has(normalizeSubagentBubbleStatus(status));
1546
+ }
1547
+
1548
+ function getSubagentParentId(subagentRunId) {
1549
+ const entry = typeof getSubagentRun === 'function' ? getSubagentRun(subagentRunId) : null;
1550
+ return entry?.parentSubagentId || null;
1551
+ }
1552
+
1553
+ function findSubagentBubbleContainer(parentSubagentId, depth = 0) {
1554
+ if (!parentSubagentId) {
1555
+ return document.querySelector('#thinking-indicator .subagent-bubbles-container[data-subagent-bubbles-root="1"]');
1556
+ }
1557
+ let parentBubble = document.querySelector(`.subagent-bubble[data-subagent-run-id="${CSS.escape(parentSubagentId)}"]`);
1558
+ if (!parentBubble && depth < 8) {
1559
+ // Child events can arrive before the parent bubble exists; build the
1560
+ // parent chain on demand so the child still nests correctly.
1561
+ parentBubble = ensureSubagentBubble(parentSubagentId, depth + 1);
1562
+ }
1563
+ if (!parentBubble) return null;
1564
+ let container = parentBubble.querySelector(':scope > .subagent-bubbles-container');
1565
+ if (!container) {
1566
+ container = document.createElement('div');
1567
+ container.className = 'subagent-bubbles-container';
1568
+ parentBubble.appendChild(container);
1569
+ }
1570
+ return container;
1571
+ }
1572
+
1573
+ function ensureSubagentBubble(subagentRunId, depth = 0) {
1574
+ const id = String(subagentRunId || '').trim();
1575
+ if (!id) return null;
1576
+
1577
+ let bubble = document.querySelector(`.subagent-bubble[data-subagent-run-id="${CSS.escape(id)}"]`);
1578
+ if (bubble) {
1579
+ const status = getSubagentStatus(id);
1580
+ updateSubagentBubbleStatus(bubble, status);
1581
+ updateSubagentStopButton(id, isSubagentCancelInFlight(id), status);
1582
+ return bubble;
1583
+ }
1584
+
1585
+ const parentSubagentId = getSubagentParentId(id);
1586
+ const container = findSubagentBubbleContainer(parentSubagentId, depth);
1587
+ if (!container) return null;
1588
+
1589
+ bubble = document.createElement('div');
1590
+ bubble.className = 'subagent-bubble';
1591
+ bubble.dataset.subagentRunId = id;
1592
+
1593
+ const header = document.createElement('div');
1594
+ header.className = 'subagent-bubble-header';
1595
+
1596
+ const nameSpan = document.createElement('span');
1597
+ nameSpan.className = 'subagent-bubble-name';
1598
+ nameSpan.textContent = getSubagentDisplayName(id);
1599
+
1600
+ const statusSpan = document.createElement('span');
1601
+ statusSpan.className = 'subagent-bubble-status';
1602
+ const status = getSubagentStatus(id);
1603
+ statusSpan.dataset.status = normalizeSubagentBubbleStatus(status);
1604
+ statusSpan.textContent = normalizeSubagentBubbleStatus(status) === 'running'
1605
+ ? '● Running'
1606
+ : normalizeSubagentBubbleStatus(status).charAt(0).toUpperCase() + normalizeSubagentBubbleStatus(status).slice(1);
1607
+
1608
+ const controls = document.createElement('div');
1609
+ controls.className = 'subagent-bubble-controls';
1610
+ controls.appendChild(statusSpan);
1611
+
1612
+ const stopBtn = document.createElement('button');
1613
+ stopBtn.type = 'button';
1614
+ stopBtn.className = 'bubble-action-btn subagent-stop-btn';
1615
+ stopBtn.dataset.action = 'stop-subagent';
1616
+ stopBtn.dataset.subagentRunId = id;
1617
+ controls.appendChild(stopBtn);
1618
+
1619
+ header.appendChild(nameSpan);
1620
+ header.appendChild(controls);
1621
+
1622
+ const activityBox = document.createElement('div');
1623
+ activityBox.className = 'subagent-activity';
1624
+
1625
+ const thoughtsBox = document.createElement('div');
1626
+ thoughtsBox.className = 'subagent-thoughts';
1627
+
1628
+ bubble.appendChild(header);
1629
+ bubble.appendChild(thoughtsBox);
1630
+ bubble.appendChild(activityBox);
1631
+ container.appendChild(bubble);
1632
+ updateSubagentStopButton(id, isSubagentCancelInFlight(id), status);
1633
+
1634
+ const entry = getSubagentRun(id);
1635
+ if (entry?.thoughts?.length) {
1636
+ for (const item of entry.thoughts) {
1637
+ appendThinkingThought(
1638
+ item?.reasoningId || `restored-${thoughtsBox.childElementCount}`,
1639
+ String(item?.text || ''),
1640
+ !!item?.done,
1641
+ id,
1642
+ false,
1643
+ );
1644
+ }
1645
+ }
1646
+ if (entry?.activities?.length) {
1647
+ for (const item of entry.activities) {
1648
+ const text = typeof item === 'string' ? item : String(item?.text || '').trim();
1649
+ if (!text) continue;
1650
+ const decorated = decorateActivityText(text);
1651
+ const lastItem = activityBox.lastElementChild?.textContent || '';
1652
+ if (lastItem === decorated) continue;
1653
+ const row = document.createElement('div');
1654
+ row.className = 'subagent-activity-item';
1655
+ row.textContent = decorated;
1656
+ activityBox.appendChild(row);
1657
+ }
1658
+ }
1659
+ if (entry?.streamText) {
1660
+ renderSubagentStream(id, entry.streamText);
1661
+ }
1662
+
1663
+ return bubble;
1664
+ }
1665
+
1666
+ function updateSubagentBubbleStatus(bubble, status) {
1667
+ if (!bubble) return;
1668
+ const statusSpan = bubble.querySelector('.subagent-bubble-status');
1669
+ if (!statusSpan) return;
1670
+ const normalized = normalizeSubagentBubbleStatus(status);
1671
+ statusSpan.dataset.status = normalized;
1672
+ statusSpan.textContent = normalized === 'running' ? '● Running' : normalized.charAt(0).toUpperCase() + normalized.slice(1);
1673
+ }
1674
+
1675
+ const subagentStopUnsupported = new Set();
1676
+
1677
+ /** The provider answered "not supported": the control must not re-arm. */
1678
+ export function markSubagentStopUnsupported(subagentRunId) {
1679
+ const id = String(subagentRunId || '').trim();
1680
+ if (!id) return;
1681
+ subagentStopUnsupported.add(id);
1682
+ updateSubagentStopButton(id, false);
1683
+ }
1684
+
1685
+ function updateSubagentStopButton(subagentRunId, isStopping = false, statusOverride = null) {
1686
+ const id = String(subagentRunId || '').trim();
1687
+ if (!id) return;
1688
+ const btn = document.querySelector(`.subagent-stop-btn[data-action="stop-subagent"][data-subagent-run-id="${CSS.escape(id)}"]`);
1689
+ if (!btn) return;
1690
+ if (IS_SHARED_VIEW) {
1691
+ btn.hidden = true;
1692
+ btn.disabled = true;
1693
+ return;
1694
+ }
1695
+ if (subagentStopUnsupported.has(id)) {
1696
+ btn.disabled = true;
1697
+ btn.textContent = 'Stop unavailable';
1698
+ btn.title = 'Targeted subagent stop is not supported by this provider; use Stop on the whole turn.';
1699
+ btn.classList.remove('stopping');
1700
+ return;
1701
+ }
1702
+ const status = normalizeSubagentBubbleStatus(statusOverride || getSubagentStatus(id));
1703
+ const terminal = isSubagentTerminalStatus(status);
1704
+ const stopping = !!isStopping;
1705
+ btn.disabled = terminal || stopping;
1706
+ btn.textContent = stopping ? 'Stopping…' : 'Stop';
1707
+ btn.classList.toggle('stopping', stopping);
1708
+ }
1709
+
1710
+ export function updateSubagentBubbleFromStatus(subagentRunId, status) {
1711
+ const id = String(subagentRunId || '').trim();
1712
+ if (!id) return;
1713
+ ensureSubagentBubble(id);
1714
+ const bubble = document.querySelector(`.subagent-bubble[data-subagent-run-id="${CSS.escape(id)}"]`);
1715
+ if (bubble) {
1716
+ updateSubagentBubbleStatus(bubble, status);
1717
+ }
1718
+ if (isSubagentTerminalStatus(status)) {
1719
+ clearSubagentCancelInFlight(id);
1720
+ }
1721
+ updateSubagentStopButton(id, isSubagentCancelInFlight(id), status);
1722
+ }
1723
+
1724
+ function renderSubagentBubbleRecursive(entry) {
1725
+ if (!entry?.subagentRunId) return;
1726
+ ensureSubagentBubble(entry.subagentRunId);
1727
+ const children = getChildSubagentRuns(entry.subagentRunId);
1728
+ for (const child of children) {
1729
+ renderSubagentBubbleRecursive(child);
1730
+ }
1731
+ }
1732
+
1733
+ export function renderRestoredSubagentBubbles(messageId) {
1734
+ const id = String(messageId || '').trim();
1735
+ if (!id) return;
1736
+ const rootRuns = getRootSubagentRunsByMessage(id);
1737
+ for (const entry of rootRuns) {
1738
+ renderSubagentBubbleRecursive(entry);
1739
+ }
1740
+ }
1741
+
1742
+ function thoughtSummaryText(text) {
1743
+ const value = String(text || '').replace(/\s+/g, ' ').trim();
1744
+ if (!value) return 'Thinking…';
1745
+ return value.length > 80 ? `${value.slice(0, 80)}…` : value;
1746
+ }
1747
+
1748
+ export function setLiveThinkingThoughtState(row, done = false) {
1749
+ if (!row) return;
1750
+ row.dataset.done = done ? '1' : '0';
1751
+ // The temporary panel represents an active turn, so every thought remains visible
1752
+ // until the completed assistant message replaces it with collapsed history.
1753
+ row.open = true;
1754
+ }
1755
+
1756
+ export function appendThinkingThought(reasoningId, text, done = false, subagentRunId = null, autoScroll = true) {
1757
+ const key = String(reasoningId || 'reasoning');
1758
+ const value = String(text || '');
1759
+
1760
+ if (subagentRunId) {
1761
+ const subagentBubble = ensureSubagentBubble(subagentRunId);
1762
+ if (subagentBubble) {
1763
+ let thoughtsBox = subagentBubble.querySelector('.subagent-thoughts');
1764
+ if (!thoughtsBox) {
1765
+ thoughtsBox = document.createElement('div');
1766
+ thoughtsBox.className = 'subagent-thoughts';
1767
+ const activityBox = subagentBubble.querySelector('.subagent-activity');
1768
+ if (activityBox) subagentBubble.insertBefore(thoughtsBox, activityBox);
1769
+ else subagentBubble.appendChild(thoughtsBox);
1770
+ }
1771
+ let row = thoughtsBox.querySelector(`.thinking-thought[data-reasoning-id="${CSS.escape(key)}"]`);
1772
+ if (!row) {
1773
+ row = document.createElement('details');
1774
+ row.className = 'thinking-thought';
1775
+ row.open = !done;
1776
+ row.dataset.reasoningId = key;
1777
+ row.dataset.subagentRunId = subagentRunId;
1778
+ const summary = document.createElement('summary');
1779
+ const body = document.createElement('div');
1780
+ body.className = 'thinking-thought-body';
1781
+ row.appendChild(summary);
1782
+ row.appendChild(body);
1783
+ thoughtsBox.appendChild(row);
1784
+ }
1785
+ const summaryEl = row.querySelector('summary');
1786
+ const bodyEl = row.querySelector('.thinking-thought-body');
1787
+ if (summaryEl) summaryEl.textContent = `💭 ${thoughtSummaryText(value)}`;
1788
+ renderThoughtBody(bodyEl, value);
1789
+ setLiveThinkingThoughtState(row, done);
1790
+ if (autoScroll) scrollBottom();
1791
+ }
1792
+ // Keep subagent thoughts out of the parent panel; the store replays them
1793
+ // once the bubble exists.
1794
+ return;
1795
+ }
1796
+
1797
+ const box = document.querySelector('#thinking-thoughts > .thinking-thoughts-list');
1798
+ if (!box) return;
1799
+ let row = box.querySelector(`.thinking-thought[data-reasoning-id="${CSS.escape(key)}"]`);
1800
+ if (!row) {
1801
+ row = document.createElement('details');
1802
+ row.className = 'thinking-thought';
1803
+ row.open = !done;
1804
+ row.dataset.reasoningId = key;
1805
+ if (subagentRunId) row.dataset.subagentRunId = subagentRunId;
1806
+ const summary = document.createElement('summary');
1807
+ const body = document.createElement('div');
1808
+ body.className = 'thinking-thought-body';
1809
+ row.appendChild(summary);
1810
+ row.appendChild(body);
1811
+ box.appendChild(row);
1812
+ }
1813
+ const summaryEl = row.querySelector('summary');
1814
+ const bodyEl = row.querySelector('.thinking-thought-body');
1815
+ if (summaryEl) summaryEl.textContent = `💭 ${thoughtSummaryText(value)}`;
1816
+ renderThoughtBody(bodyEl, value);
1817
+ setLiveThinkingThoughtState(row, done);
1818
+ if (autoScroll) scrollBottom();
1819
+ }
1820
+
1821
+ export function renderThinkingThoughts() {
1822
+ const box = document.querySelector('#thinking-thoughts > .thinking-thoughts-list');
1823
+ if (!box) return;
1824
+ const thoughtMap = thinkingMessageId ? relayThoughts.get(thinkingMessageId) : null;
1825
+ if (!thoughtMap || !thoughtMap.size) return;
1826
+ for (const entry of thoughtMap.values()) {
1827
+ appendThinkingThought(entry.reasoningId, entry.text, entry.done, entry.subagentRunId || null, false);
1828
+ }
1829
+ }
1830
+
1831
+ export function updateThinkingStreamStatus(messageId = null, done = false, autoScroll = true) {
1832
+ if (messageId) {
1833
+ if (thinkingMessageId && thinkingMessageId !== messageId) return;
1834
+ thinkingMessageId = messageId;
1835
+ }
1836
+ if (!document.getElementById('thinking-indicator')) {
1837
+ if (done) return;
1838
+ showThinking(thinkingMessageId, autoScroll);
1839
+ }
1840
+ if (done) {
1841
+ const dots = document.querySelector('#thinking-indicator .dots');
1842
+ if (dots) dots.style.display = 'none';
1843
+ }
1844
+ if (autoScroll) scrollBottom();
1845
+ }
1846
+
1847
+ export function applyRelayStreamEvent({
1848
+ messageId,
1849
+ text,
1850
+ done = false,
1851
+ seq = null,
1852
+ subagentRunId = null,
1853
+ autoScroll = true,
1854
+ } = {}) {
1855
+ const id = String(messageId || '').trim();
1856
+ if (!id) return false;
1857
+ if (completedMessageIds.has(id)) return false;
1858
+ const previous = relayStreamStateByMessageId.get(id) || { seq: 0, done: false };
1859
+ const transition = computeNextRelayStreamState(previous, { seq, done });
1860
+ if (!transition.accept) return false;
1861
+ rememberRelayStreamState(id, transition.state.seq, transition.state.done);
1862
+ if (isOpaqueRelayText(text)) return true;
1863
+ const runId = String(subagentRunId || '').trim();
1864
+ if (runId) {
1865
+ // Subagent text belongs to its own bubble; the store keeps it so the bubble
1866
+ // can replay it if it is built after this frame arrived.
1867
+ setSubagentStreamText(runId, text);
1868
+ } else {
1869
+ relayStreamTextByMessageId.set(id, String(text || ''));
1870
+ }
1871
+ // Runs before rendering: it creates the thinking bubble when one is missing,
1872
+ // and adopts `messageId` as the active thinking message.
1873
+ updateThinkingStreamStatus(id, !!done, autoScroll);
1874
+ if (id !== thinkingMessageId) return true;
1875
+ if (runId) renderSubagentStream(runId, text);
1876
+ else renderThinkingStream();
1877
+ return true;
1878
+ }
1879
+
1880
+ export function flushDeferredMessageRender() {
1881
+ if (!deferredMessageRender) return;
1882
+ const { msgs, scroll, meta } = deferredMessageRender;
1883
+ deferredMessageRender = null;
1884
+ const deferredConversationId = String(meta?.conversationId || '').trim();
1885
+ if (deferredConversationId && deferredConversationId !== String(currentConvId || '').trim()) return;
1886
+ renderMessages(msgs, scroll, meta);
1887
+ }
1888
+
1889
+ export function clearRelayStreamStateForMessage(messageId) {
1890
+ const id = String(messageId || '').trim();
1891
+ if (id) {
1892
+ completedMessageIds.add(id);
1893
+ if (completedMessageIds.size > 100) {
1894
+ completedMessageIds.delete(completedMessageIds.values().next().value);
1895
+ }
1896
+ }
1897
+ clearRelayStreamState(messageId);
1898
+ }
1899
+
1900
+ export function applyConversationTurnStatus({ conversationId, messageId, status }) {
1901
+ const conversationKey = String(conversationId || '').trim();
1902
+ const messageKey = String(messageId || '').trim();
1903
+ const normalizedStatus = String(status || '').trim().toLowerCase();
1904
+ if (!conversationKey) {
1905
+ syncSendButtonState();
1906
+ return;
1907
+ }
1908
+ if (normalizedStatus === 'processing' && messageKey) {
1909
+ const previous = getActiveTurnForConversation(conversationKey);
1910
+ setConversationTurnState(conversationKey, {
1911
+ messageId: messageKey,
1912
+ status: normalizedStatus,
1913
+ cancelRequested: previous?.messageId === messageKey ? previous.cancelRequested === true : false,
1914
+ });
1915
+ return;
1916
+ }
1917
+ if (['done', 'failed', 'dropped', 'pending', 'parked', 'cancelled'].includes(normalizedStatus)) {
1918
+ const previous = getActiveTurnForConversation(conversationKey);
1919
+ if (!previous || !messageKey || previous.messageId === messageKey) {
1920
+ setConversationTurnState(conversationKey, null);
1921
+ return;
1922
+ }
1923
+ }
1924
+ syncSendButtonState();
1925
+ }
1926
+
1927
+ async function stopCurrentConversationTurn(conversationId) {
1928
+ const conversationKey = String(conversationId || '').trim();
1929
+ const activeTurn = getActiveTurnForConversation(conversationKey);
1930
+ if (!conversationKey || !activeTurn?.messageId) return;
1931
+ if (activeTurn.cancelRequested) return;
1932
+ if (isMobileComposerViewport() && !confirm('Stop the current turn?')) return;
1933
+ setConversationTurnState(conversationKey, {
1934
+ messageId: activeTurn.messageId,
1935
+ status: activeTurn.status || 'processing',
1936
+ cancelRequested: true,
1937
+ });
1938
+ const expectedMessageId = activeTurn.messageId;
1939
+ const result = await cancelConversationTurn(conversationKey, {
1940
+ clientId: CLIENT_ID,
1941
+ messageId: expectedMessageId,
1942
+ });
1943
+ if (!result?.ok) {
1944
+ setConversationTurnState(conversationKey, {
1945
+ messageId: expectedMessageId,
1946
+ status: activeTurn.status || 'processing',
1947
+ cancelRequested: false,
1948
+ });
1949
+ showTransientRelayNotice('Could not stop the current turn.');
1950
+ return;
1951
+ }
1952
+ if (
1953
+ result.acknowledgement === 'already-finished'
1954
+ || result.acknowledgement === 'no-active-turn'
1955
+ || result.acknowledgement === 'message-mismatch'
1956
+ ) {
1957
+ const latestTurn = getActiveTurnForConversation(conversationKey);
1958
+ if (latestTurn?.messageId === expectedMessageId) {
1959
+ setConversationTurnState(conversationKey, null);
1960
+ }
1961
+ showTransientRelayNotice('That turn already finished.');
1962
+ return;
1963
+ }
1964
+ if (result.acknowledgement === 'active-turn-unbound') {
1965
+ const latestTurn = getActiveTurnForConversation(conversationKey);
1966
+ if (latestTurn?.messageId === expectedMessageId) {
1967
+ setConversationTurnState(conversationKey, {
1968
+ messageId: expectedMessageId,
1969
+ status: activeTurn.status || 'processing',
1970
+ cancelRequested: false,
1971
+ });
1972
+ }
1973
+ showTransientRelayNotice('The active turn is not bound to a live SDK session.');
1974
+ return;
1975
+ }
1976
+ showTransientRelayNotice('Stopping the current turn…');
1977
+ }
1978
+
1979
+ async function stopTurnByMessageId(conversationId, messageId) {
1980
+ const conversationKey = String(conversationId || '').trim();
1981
+ const targetMessageId = String(messageId || '').trim();
1982
+ if (!conversationKey || !targetMessageId) return;
1983
+ if (bubbleCancelInFlight.has(targetMessageId)) return;
1984
+
1985
+ bubbleCancelInFlight.add(targetMessageId);
1986
+ updateBubbleStopButton(targetMessageId, true);
1987
+
1988
+ const activeTurn = getActiveTurnForConversation(conversationKey);
1989
+ if (activeTurn?.messageId === targetMessageId) {
1990
+ setConversationTurnState(conversationKey, {
1991
+ messageId: targetMessageId,
1992
+ status: activeTurn.status || 'processing',
1993
+ cancelRequested: true,
1994
+ });
1995
+ }
1996
+
1997
+ const result = await cancelConversationTurn(conversationKey, {
1998
+ clientId: CLIENT_ID,
1999
+ messageId: targetMessageId,
2000
+ });
2001
+
2002
+ if (!result?.ok) {
2003
+ bubbleCancelInFlight.delete(targetMessageId);
2004
+ updateBubbleStopButton(targetMessageId, false);
2005
+ if (activeTurn?.messageId === targetMessageId) {
2006
+ setConversationTurnState(conversationKey, {
2007
+ messageId: targetMessageId,
2008
+ status: activeTurn.status || 'processing',
2009
+ cancelRequested: false,
2010
+ });
2011
+ }
2012
+ showTransientRelayNotice('Could not stop the turn.');
2013
+ return;
2014
+ }
2015
+
2016
+ if (
2017
+ result.acknowledgement === 'already-finished'
2018
+ || result.acknowledgement === 'no-active-turn'
2019
+ || result.acknowledgement === 'message-mismatch'
2020
+ ) {
2021
+ bubbleCancelInFlight.delete(targetMessageId);
2022
+ const latestTurn = getActiveTurnForConversation(conversationKey);
2023
+ if (latestTurn?.messageId === targetMessageId) {
2024
+ setConversationTurnState(conversationKey, null);
2025
+ }
2026
+ showTransientRelayNotice('That turn already finished.');
2027
+ return;
2028
+ }
2029
+
2030
+ if (result.acknowledgement === 'active-turn-unbound') {
2031
+ bubbleCancelInFlight.delete(targetMessageId);
2032
+ updateBubbleStopButton(targetMessageId, false);
2033
+ const latestTurn = getActiveTurnForConversation(conversationKey);
2034
+ if (latestTurn?.messageId === targetMessageId) {
2035
+ setConversationTurnState(conversationKey, {
2036
+ messageId: targetMessageId,
2037
+ status: activeTurn?.status || 'processing',
2038
+ cancelRequested: false,
2039
+ });
2040
+ }
2041
+ showTransientRelayNotice('The turn is not bound to a live SDK session.');
2042
+ return;
2043
+ }
2044
+
2045
+ showTransientRelayNotice('Stopping the turn…');
2046
+ }
2047
+
2048
+ function updateBubbleStopButton(messageId, isStopping) {
2049
+ const btn = document.querySelector(`.bubble-action-btn[data-message-id="${messageId}"][data-action="stop-turn"]`);
2050
+ if (!btn) return;
2051
+ btn.disabled = isStopping;
2052
+ btn.textContent = isStopping ? 'Stopping…' : 'Stop';
2053
+ btn.classList.toggle('stopping', isStopping);
2054
+ }
2055
+
2056
+ function updateUserBubbleCancelButton(messageId, isCancelling) {
2057
+ const btn = document.querySelector(`.bubble-action-btn[data-message-id="${messageId}"][data-action="cancel-queued"]`);
2058
+ if (!btn) return;
2059
+ btn.disabled = isCancelling;
2060
+ btn.textContent = isCancelling ? 'Cancelling…' : 'Cancel';
2061
+ btn.classList.toggle('stopping', isCancelling);
2062
+ }
2063
+
2064
+ export function removeUserBubbleCancelButton(messageId) {
2065
+ const id = String(messageId || '').trim();
2066
+ if (!id) return;
2067
+ const actionsContainer = document.querySelector(`.msg.user[data-message-id="${id}"] .msg-bubble-actions`);
2068
+ if (actionsContainer) actionsContainer.remove();
2069
+ }
2070
+
2071
+ export function clearBubbleCancelState(messageId) {
2072
+ const id = String(messageId || '').trim();
2073
+ if (!id) return;
2074
+ bubbleCancelInFlight.delete(id);
2075
+ }
2076
+
2077
+ async function cancelQueuedTurnByMessageId(conversationId, messageId) {
2078
+ const conversationKey = String(conversationId || '').trim();
2079
+ const targetMessageId = String(messageId || '').trim();
2080
+ if (!conversationKey || !targetMessageId) return;
2081
+ if (bubbleCancelInFlight.has(targetMessageId)) return;
2082
+
2083
+ bubbleCancelInFlight.add(targetMessageId);
2084
+ updateUserBubbleCancelButton(targetMessageId, true);
2085
+
2086
+ const result = await cancelQueuedConversationTurn(conversationKey, {
2087
+ clientId: CLIENT_ID,
2088
+ messageId: targetMessageId,
2089
+ });
2090
+
2091
+ if (!result?.ok) {
2092
+ bubbleCancelInFlight.delete(targetMessageId);
2093
+ updateUserBubbleCancelButton(targetMessageId, false);
2094
+ showTransientRelayNotice('Could not cancel the queued message.');
2095
+ return;
2096
+ }
2097
+
2098
+ if (result.acknowledgement === 'not-found' || result.acknowledgement === 'conversation-mismatch') {
2099
+ bubbleCancelInFlight.delete(targetMessageId);
2100
+ removeUserBubbleCancelButton(targetMessageId);
2101
+ showTransientRelayNotice('Message not found in queue.');
2102
+ return;
2103
+ }
2104
+
2105
+ if (result.acknowledgement === 'already-processing') {
2106
+ bubbleCancelInFlight.delete(targetMessageId);
2107
+ removeUserBubbleCancelButton(targetMessageId);
2108
+ showTransientRelayNotice('Message is already being processed. Use Stop on the thinking bubble instead.');
2109
+ return;
2110
+ }
2111
+
2112
+ if (result.acknowledgement === 'already-finished') {
2113
+ bubbleCancelInFlight.delete(targetMessageId);
2114
+ removeUserBubbleCancelButton(targetMessageId);
2115
+ showTransientRelayNotice('Message already finished.');
2116
+ return;
2117
+ }
2118
+
2119
+ if (result.acknowledgement === 'cancelled') {
2120
+ bubbleCancelInFlight.delete(targetMessageId);
2121
+ removeUserBubbleCancelButton(targetMessageId);
2122
+ showTransientRelayNotice('Queued message cancelled.');
2123
+ return;
2124
+ }
2125
+
2126
+ bubbleCancelInFlight.delete(targetMessageId);
2127
+ updateUserBubbleCancelButton(targetMessageId, false);
2128
+ }
2129
+
2130
+ async function cancelSubagentByRunId(conversationId, subagentRunId) {
2131
+ const conversationKey = String(conversationId || '').trim();
2132
+ const targetSubagentRunId = String(subagentRunId || '').trim();
2133
+ if (!conversationKey || !targetSubagentRunId) return;
2134
+ if (isSubagentCancelInFlight(targetSubagentRunId)) return;
2135
+
2136
+ markSubagentCancelInFlight(targetSubagentRunId);
2137
+ updateSubagentStopButton(targetSubagentRunId, true);
2138
+
2139
+ const runEntry = getSubagentRun(targetSubagentRunId);
2140
+ const parentMessageId = String(runEntry?.messageId || '').trim() || null;
2141
+ const result = await cancelSubagentRun(conversationKey, targetSubagentRunId, {
2142
+ clientId: CLIENT_ID,
2143
+ parentMessageId,
2144
+ });
2145
+
2146
+ if (!result?.ok) {
2147
+ clearSubagentCancelInFlight(targetSubagentRunId);
2148
+ updateSubagentStopButton(targetSubagentRunId, false);
2149
+ showTransientRelayNotice('Could not stop that subagent.');
2150
+ return;
2151
+ }
2152
+
2153
+ if (result.acknowledgement === 'already-finished') {
2154
+ clearSubagentCancelInFlight(targetSubagentRunId);
2155
+ updateSubagentStopButton(targetSubagentRunId, false);
2156
+ showTransientRelayNotice('That subagent already finished.');
2157
+ return;
2158
+ }
2159
+
2160
+ if (result.acknowledgement === 'already-cancelled') {
2161
+ clearSubagentCancelInFlight(targetSubagentRunId);
2162
+ updateSubagentBubbleFromStatus(targetSubagentRunId, 'cancelled');
2163
+ showTransientRelayNotice('That subagent is already cancelled.');
2164
+ return;
2165
+ }
2166
+
2167
+ if (result.acknowledgement === 'not-found') {
2168
+ clearSubagentCancelInFlight(targetSubagentRunId);
2169
+ updateSubagentStopButton(targetSubagentRunId, false);
2170
+ showTransientRelayNotice('Subagent run not found.');
2171
+ return;
2172
+ }
2173
+
2174
+ if (result.acknowledgement === 'message-mismatch') {
2175
+ clearSubagentCancelInFlight(targetSubagentRunId);
2176
+ updateSubagentStopButton(targetSubagentRunId, false);
2177
+ showTransientRelayNotice('Could not stop subagent due to message mismatch.');
2178
+ return;
2179
+ }
2180
+
2181
+ if (result.acknowledgement === 'cancelled') {
2182
+ showTransientRelayNotice('Stopping subagent…');
2183
+ return;
2184
+ }
2185
+
2186
+ clearSubagentCancelInFlight(targetSubagentRunId);
2187
+ updateSubagentStopButton(targetSubagentRunId, false);
2188
+ }
2189
+
2190
+ async function toggleMessageShareVisibility(conversationId, messageId, hiddenFromShares) {
2191
+ const conversationKey = String(conversationId || '').trim();
2192
+ const targetMessageId = String(messageId || '').trim();
2193
+ if (!conversationKey || !targetMessageId || shareVisibilityInFlight.has(targetMessageId)) return;
2194
+
2195
+ shareVisibilityInFlight.add(targetMessageId);
2196
+ const button = document.querySelector(`.msg-share-visibility-btn[data-message-id="${CSS.escape(targetMessageId)}"]`);
2197
+ if (button) {
2198
+ button.disabled = true;
2199
+ button.textContent = hiddenFromShares ? 'Unhiding…' : 'Hiding…';
2200
+ }
2201
+ const result = await updateMessageShareVisibility(conversationKey, targetMessageId, !hiddenFromShares);
2202
+ shareVisibilityInFlight.delete(targetMessageId);
2203
+ if (!result?.ok) {
2204
+ if (button) {
2205
+ button.disabled = false;
2206
+ button.textContent = hiddenFromShares ? 'Unhide' : 'Hide';
2207
+ }
2208
+ showTransientRelayNotice('Could not update shared-message visibility.');
2209
+ return;
2210
+ }
2211
+
2212
+ showTransientRelayNotice(result.hiddenFromShares
2213
+ ? 'Message hidden from shared viewers.'
2214
+ : 'Message visible to shared viewers.');
2215
+ // The reload below only refreshes the open view; if the user switched
2216
+ // conversations while the toggle round trip was in flight, rendering it
2217
+ // would replace the new conversation's messages with this one's.
2218
+ if (String(currentConvId || '').trim() !== conversationKey) return;
2219
+ const response = await loadConversationApi(conversationKey, {
2220
+ limit: Math.max(CONVERSATION_HISTORY_PAGE_SIZE, getConversationLoadedMessageCount()),
2221
+ });
2222
+ if (String(currentConvId || '').trim() !== conversationKey) return;
2223
+ if (response?.messages) {
2224
+ renderMessages(response.messages, false, response);
2225
+ }
2226
+ }
2227
+
2228
+ // Each CTA lands the user where the state actually lives: the provider panel
2229
+ // shows the install log / the device code, so the settings modal is opened
2230
+ // first and the action then runs on top of it.
2231
+ function runRelayErrorCta(cta) {
2232
+ if (cta === 'install-grok-cli') {
2233
+ openSettingsModal('providers', 'grok');
2234
+ // The confirm sheet — never a bare curl | bash from a chat bubble.
2235
+ void confirmCliInstall('grok', 'install');
2236
+ return;
2237
+ }
2238
+ if (cta === 'sign-in-to-grok') {
2239
+ openSettingsModal('providers', 'grok');
2240
+ void startGrokSignIn();
2241
+ return;
2242
+ }
2243
+ if (cta === 'open-grok-settings') openSettingsModal('providers', 'grok');
2244
+ if (cta === 'open-claude-settings') openSettingsModal('providers', 'claude');
2245
+ }
2246
+
2247
+ function handleBubbleActionClick(event) {
2248
+ const btn = event.target.closest('.bubble-action-btn, .msg-share-visibility-btn');
2249
+ if (!btn) return;
2250
+ const action = btn.dataset.action;
2251
+ const messageId = btn.dataset.messageId;
2252
+ const subagentRunId = btn.dataset.subagentRunId;
2253
+
2254
+ if (action === 'relay-error-cta') {
2255
+ event.preventDefault();
2256
+ event.stopPropagation();
2257
+ runRelayErrorCta(String(btn.dataset.cta || ''));
2258
+ }
2259
+
2260
+ if (action === 'toggle-share-visibility' && messageId) {
2261
+ event.preventDefault();
2262
+ event.stopPropagation();
2263
+ void toggleMessageShareVisibility(
2264
+ currentConvId,
2265
+ messageId,
2266
+ btn.dataset.hiddenFromShares === 'true',
2267
+ );
2268
+ }
2269
+
2270
+ if (action === 'stop-turn' && messageId) {
2271
+ event.preventDefault();
2272
+ event.stopPropagation();
2273
+ void stopTurnByMessageId(currentConvId, messageId);
2274
+ }
2275
+
2276
+ if (action === 'cancel-queued' && messageId) {
2277
+ event.preventDefault();
2278
+ event.stopPropagation();
2279
+ void cancelQueuedTurnByMessageId(currentConvId, messageId);
2280
+ }
2281
+
2282
+ if (action === 'stop-subagent' && subagentRunId) {
2283
+ event.preventDefault();
2284
+ event.stopPropagation();
2285
+ void cancelSubagentByRunId(currentConvId, subagentRunId);
2286
+ }
2287
+ }
2288
+
2289
+ export function initBubbleActionHandlers() {
2290
+ const messagesEl = document.getElementById('messages');
2291
+ if (!messagesEl) return;
2292
+ messagesEl.addEventListener('click', handleBubbleActionClick);
2293
+ }
2294
+
2295
+ export function appendMessage(msg, scroll = true, msgId = null, force = false, insertAfterId = null, trackHistory = true) {
2296
+ const el = getMessagesElement();
2297
+ if (!el) return null;
2298
+ const empty = el.querySelector('.empty-state');
2299
+ if (empty) empty.remove();
2300
+ const node = createMessageNode(msg, msgId, force);
2301
+ const isNewNode = !!node && !node.parentNode;
2302
+ // Insert without scrolling: the separator pass below can add a row above
2303
+ // this message, and scrolling before it runs leaves the viewport a
2304
+ // separator-height short of the bottom (which then reads as "not at
2305
+ // bottom" and suppresses auto-scroll for the next message too).
2306
+ // renderMessages orders it the same way.
2307
+ const insertedNode = insertMessageNode(node, insertAfterId);
2308
+ if (trackHistory && isNewNode && insertedNode) {
2309
+ const messageId = String(msgId || msg?.id || '').trim();
2310
+ const messageTimestamp = String(msg?.timestamp || '').trim();
2311
+ setConversationHistoryState({
2312
+ ...conversationHistoryState,
2313
+ newestMessageId: messageId || conversationHistoryState.newestMessageId,
2314
+ newestMessageTimestamp: messageTimestamp || conversationHistoryState.newestMessageTimestamp,
2315
+ loadedMessageCount: getConversationLoadedMessageCount() + 1,
2316
+ });
2317
+ if (conversationHistoryState.hasMoreNewer) {
2318
+ conversationFutureLoader.reset({
2319
+ hasMore: conversationHistoryState.hasMoreNewer,
2320
+ nextCursor: {
2321
+ afterMessageId: messageId || getConversationFutureCursor() || null,
2322
+ afterTimestamp: messageTimestamp || conversationHistoryState.newestMessageTimestamp || null,
2323
+ },
2324
+ });
2325
+ }
2326
+ }
2327
+ if (isNewNode && insertedNode) {
2328
+ syncSeparatorsNow();
2329
+ if (scroll) scrollBottom();
2330
+ }
2331
+ return insertedNode;
2332
+ }
2333
+
2334
+ export function getRenderedConversationMessageFingerprints(limit = 24) {
2335
+ const el = getMessagesElement();
2336
+ if (!el) return [];
2337
+ const rows = Array.from(el.querySelectorAll('.msg'));
2338
+ const tail = rows.slice(-Math.max(1, Number(limit) || 24));
2339
+ return tail.map((node) => ({
2340
+ id: String(node.dataset.messageId || '').trim(),
2341
+ role: String(node.dataset.messageRole || '').trim(),
2342
+ text: String(node.dataset.messageTextFingerprint || '').trim(),
2343
+ timestamp: String(node.dataset.messageTimestamp || '').trim(),
2344
+ sourceMessageId: String(node.dataset.sourceMessageId || '').trim(),
2345
+ }));
2346
+ }
2347
+
2348
+ function buildMessageSnapshotKey(messages = [], meta = {}) {
2349
+ const conversationId = String(meta.conversationId || currentConvId || '').trim();
2350
+ const pageInfo = meta.pageInfo && typeof meta.pageInfo === 'object' ? meta.pageInfo : null;
2351
+ const hasMoreOlder = typeof meta.hasMoreOlder === 'boolean'
2352
+ ? meta.hasMoreOlder
2353
+ : (typeof meta.hasMoreHistory === 'boolean' ? meta.hasMoreHistory : !!pageInfo?.hasMoreOlder || !!pageInfo?.hasMore);
2354
+ const hasMoreNewer = typeof meta.hasMoreNewer === 'boolean'
2355
+ ? meta.hasMoreNewer
2356
+ : !!pageInfo?.hasMoreNewer;
2357
+ return JSON.stringify({
2358
+ conversationId,
2359
+ hasMoreOlder: !!hasMoreOlder,
2360
+ hasMoreNewer: !!hasMoreNewer,
2361
+ messages: (Array.isArray(messages) ? messages : []).map((item) => ({
2362
+ id: String(item?.id || '').trim(),
2363
+ role: String(item?.role || '').trim(),
2364
+ text: String(item?.text || ''),
2365
+ timestamp: String(item?.timestamp || '').trim(),
2366
+ model: String(item?.model || '').trim(),
2367
+ mode: String(item?.mode || '').trim(),
2368
+ attachments: Array.isArray(item?.attachments) ? item.attachments.length : 0,
2369
+ hiddenFromShares: item?.hiddenFromShares === true,
2370
+ // Without this, a payload that differs from the last render only by a
2371
+ // newly linked compaction activity short-circuits below and the break
2372
+ // row never appears until some unrelated field changes.
2373
+ compactBoundary: (() => {
2374
+ const boundary = compactBoundaryFromActivities(item?.activities);
2375
+ return boundary ? `${boundary.preTokens ?? ''}|${boundary.postTokens ?? ''}` : '';
2376
+ })(),
2377
+ thoughts: (Array.isArray(item?.thoughts) ? item.thoughts : []).map((thought) => ({
2378
+ reasoningId: String(thought?.reasoningId || '').trim(),
2379
+ seq: Number.isFinite(Number(thought?.seq)) ? Number(thought.seq) : null,
2380
+ text: String(thought?.text || ''),
2381
+ done: !!thought?.done,
2382
+ timestamp: String(thought?.timestamp || '').trim(),
2383
+ subagentRunId: String(thought?.subagentRunId || '').trim(),
2384
+ })),
2385
+ // Cheap signature only — runs are immutable once persisted, so runId +
2386
+ // status is enough to catch a payload that differs only in its runs
2387
+ // (without one, such a payload would short-circuit and the card would
2388
+ // stay invisible until an unrelated change).
2389
+ workflowRuns: (Array.isArray(item?.workflowRuns) ? item.workflowRuns : []).map((run) => ({
2390
+ runId: String(run?.runId || '').trim(),
2391
+ status: String(run?.status || '').trim(),
2392
+ })),
2393
+ })),
2394
+ });
2395
+ }
2396
+
2397
+ export function renderMessages(msgs, scroll = true, meta = {}) {
2398
+ const el = getMessagesElement();
2399
+ if (!el) return false;
2400
+ const ordered = sortConversationMessages(msgs || []);
2401
+ const snapshotKey = buildMessageSnapshotKey(ordered, meta);
2402
+ const statusViewMounted = !!el.querySelector('.status-view');
2403
+ if (snapshotKey && snapshotKey === lastRenderedMessageSnapshotKey && !statusViewMounted) {
2404
+ renderRelayQuestions();
2405
+ renderRelayBoards();
2406
+ deferredMessageRender = null;
2407
+ return false;
2408
+ }
2409
+ if (isChatInteractionHeld()) {
2410
+ // A full rebuild wipes the user's selection; park the latest payload and
2411
+ // replay it once the selection/drag is released.
2412
+ deferredMessageRender = { msgs, scroll, meta };
2413
+ return false;
2414
+ }
2415
+ deferredMessageRender = null;
2416
+ const messageById = new Map(
2417
+ ordered
2418
+ .map((item) => [String(item?.id || '').trim(), item])
2419
+ .filter(([id]) => !!id),
2420
+ );
2421
+ if (!ordered.length) {
2422
+ el.innerHTML = `<div class="empty-state">
2423
+ <div class="icon">${currentConvId ? '💬' : '🚀'}</div>
2424
+ <h3>${currentConvId ? 'No messages yet' : 'New Conversation'}</h3>
2425
+ <p>${currentConvId ? 'Start the conversation below' : 'Type your first message below'}</p>
2426
+ </div>`;
2427
+ resetConversationHistoryState();
2428
+ renderRelayQuestions();
2429
+ renderRelayBoards();
2430
+ lastRenderedMessageSnapshotKey = snapshotKey;
2431
+ return true;
2432
+ }
2433
+ const conversationId = String(meta.conversationId || currentConvId || '').trim();
2434
+ const pageInfo = meta.pageInfo && typeof meta.pageInfo === 'object' ? meta.pageInfo : null;
2435
+ const hasMoreOlder = typeof meta.hasMoreOlder === 'boolean'
2436
+ ? meta.hasMoreOlder
2437
+ : (typeof meta.hasMoreHistory === 'boolean' ? meta.hasMoreHistory : !!pageInfo?.hasMoreOlder || !!pageInfo?.hasMore);
2438
+ const hasMoreNewer = typeof meta.hasMoreNewer === 'boolean'
2439
+ ? meta.hasMoreNewer
2440
+ : !!pageInfo?.hasMoreNewer;
2441
+ const oldestMessageId = String(
2442
+ meta.historyCursor
2443
+ || pageInfo?.olderCursor?.beforeMessageId
2444
+ || pageInfo?.nextCursor?.beforeMessageId
2445
+ || ordered[0]?.id
2446
+ || '',
2447
+ ).trim();
2448
+ const oldestMessageTimestamp = String(
2449
+ pageInfo?.olderCursor?.beforeTimestamp
2450
+ || pageInfo?.nextCursor?.beforeTimestamp
2451
+ || ordered[0]?.timestamp
2452
+ || '',
2453
+ ).trim();
2454
+ const newestMessageId = String(meta.historyNewestMessageId || ordered[ordered.length - 1]?.id || '').trim();
2455
+ const newestMessageTimestamp = String(
2456
+ pageInfo?.newerCursor?.afterTimestamp
2457
+ || ordered[ordered.length - 1]?.timestamp
2458
+ || '',
2459
+ ).trim();
2460
+ el.innerHTML = hasMoreOlder ? buildHistoryLoadMoreMarkup(false) : '';
2461
+ setConversationHistoryState({
2462
+ conversationId,
2463
+ hasMoreOlder,
2464
+ hasMoreNewer,
2465
+ oldestMessageId,
2466
+ oldestMessageTimestamp,
2467
+ newestMessageId,
2468
+ newestMessageTimestamp,
2469
+ loadedMessageCount: ordered.length,
2470
+ loadingOlder: false,
2471
+ loadingNewer: false,
2472
+ });
2473
+ conversationHistoryLoader.reset({
2474
+ hasMore: hasMoreOlder,
2475
+ nextCursor: hasMoreOlder ? (pageInfo?.olderCursor || pageInfo?.nextCursor || {
2476
+ beforeMessageId: oldestMessageId || null,
2477
+ beforeTimestamp: null,
2478
+ }) : null,
2479
+ });
2480
+ conversationFutureLoader.reset({
2481
+ hasMore: hasMoreNewer,
2482
+ nextCursor: hasMoreNewer ? (pageInfo?.newerCursor || {
2483
+ afterMessageId: newestMessageId || null,
2484
+ afterTimestamp: newestMessageTimestamp || null,
2485
+ }) : null,
2486
+ });
2487
+ withSuspendedSeparatorSync(() => {
2488
+ for (const m of ordered) appendMessage(m, false, m.id || null, true, getMessageThreadAnchor(m, messageById), false);
2489
+ });
2490
+ syncSeparatorsNow();
2491
+ renderRelayQuestions();
2492
+ renderRelayBoards();
2493
+ lastRenderedMessageSnapshotKey = snapshotKey;
2494
+ if (scroll) scrollBottom();
2495
+ requestAnimationFrame(() => {
2496
+ const box = getMessagesElement();
2497
+ if (!box) return;
2498
+ void conversationHistoryLoader.handleBoundaryDistance(box.scrollTop);
2499
+ const forwardDistance = Math.max(0, box.scrollHeight - box.clientHeight - box.scrollTop);
2500
+ void conversationFutureLoader.handleBoundaryDistance(forwardDistance);
2501
+ });
2502
+ return true;
2503
+ }
2504
+
2505
+ export async function loadOlderConversationMessages() {
2506
+ await conversationHistoryLoader.loadMore();
2507
+ }
2508
+
2509
+ export async function loadNewerConversationMessages() {
2510
+ await conversationFutureLoader.loadMore();
2511
+ }
2512
+
2513
+ export function focusConversationMessageById(messageId, { behavior = 'smooth', block = 'center' } = {}) {
2514
+ const id = String(messageId || '').trim();
2515
+ if (!id) return false;
2516
+ const el = getMessagesElement();
2517
+ if (!el) return false;
2518
+ const target = el.querySelector(`[data-message-id="${id}"]`);
2519
+ if (!target) return false;
2520
+ target.scrollIntoView({ behavior, block, inline: 'nearest' });
2521
+ target.classList.add('msg-search-target');
2522
+ window.setTimeout(() => {
2523
+ target.classList.remove('msg-search-target');
2524
+ }, 2200);
2525
+ return true;
2526
+ }
2527
+
2528
+ export function compactCurrentConversation() {
2529
+ if (!currentConvId || compactInFlight) return;
2530
+ const id = currentConvId;
2531
+ const conv = conversations[id];
2532
+ if (!conv || conv.archived) return;
2533
+ if (!confirm('Compact this conversation into a new one with carry-over summary?')) return;
2534
+
2535
+ setCompactInFlight(true);
2536
+ try {
2537
+ const doCompact = async () => {
2538
+ const r = await compactConversationApi(id);
2539
+ if (!r?.compactedConversationId) throw new Error('Compaction failed');
2540
+ await window.refreshConversations?.();
2541
+ await window.openConversation?.(r.compactedConversationId);
2542
+ };
2543
+ void doCompact().catch((e) => {
2544
+ alert(e.message || 'Failed to compact conversation');
2545
+ }).finally(() => {
2546
+ setCompactInFlight(false);
2547
+ });
2548
+ } catch (e) {
2549
+ setCompactInFlight(false);
2550
+ alert(e.message || 'Failed to compact conversation');
2551
+ }
2552
+ }
2553
+
2554
+ // Warn-once slot for the unknown-command guard; keyed by exact text so any
2555
+ // edit re-arms the warning.
2556
+ let unknownCommandWarned = null;
2557
+
2558
+ export async function sendMessage() {
2559
+ const input = document.getElementById('msg-input');
2560
+ const originalComposerText = String(input?.value || '');
2561
+ const text = originalComposerText.trim();
2562
+ const mobileSend = isMobileComposerViewport();
2563
+ const activeTurn = getActiveTurnForConversation(currentConvId);
2564
+ const hasDraft = hasComposerDraft({ text, attachmentCount: selectedAttachments.length });
2565
+ if (sendInFlight) {
2566
+ showTransientRelayNotice('Please wait for the current message to finish sending.');
2567
+ return;
2568
+ }
2569
+ if (!hasDraft) return;
2570
+ closeSlashAutocomplete();
2571
+
2572
+ // Warn-once typo guard: a message that looks like a command but matches none
2573
+ // would otherwise burn an agent turn as plain text.
2574
+ const guard = evaluateUnknownCommandGuard(text, {
2575
+ slot: unknownCommandWarned,
2576
+ hasAttachments: selectedAttachments.length > 0,
2577
+ });
2578
+ unknownCommandWarned = guard.slot;
2579
+ if (guard.warn) {
2580
+ showTransientRelayNotice(guard.notice);
2581
+ return;
2582
+ }
2583
+
2584
+ if (text.toLowerCase() === '/compact' && selectedAttachments.length === 0) {
2585
+ input.value = '';
2586
+ autoResize(input);
2587
+ releaseComposerFocusAfterSend(input);
2588
+ compactCurrentConversation();
2589
+ scrollBottomAfterSend();
2590
+ return;
2591
+ }
2592
+
2593
+ const previewCommand = selectedAttachments.length === 0 ? parsePreviewCommand(text) : null;
2594
+ if (previewCommand) {
2595
+ input.value = '';
2596
+ autoResize(input);
2597
+ releaseComposerFocusAfterSend(input);
2598
+ const result = await runPreviewCommand(previewCommand, { conversationId: currentConvId });
2599
+ showTransientRelayNotice(result.notice);
2600
+ return;
2601
+ }
2602
+
2603
+ // Capture the send target and its composer payload before any await: the
2604
+ // message belongs to the conversation the user typed it in. If the user
2605
+ // opens another conversation while a round trip below is in flight, the
2606
+ // message still posts to this validated conversation and is simply not
2607
+ // rendered into (or drafted onto) the newly opened one — it shows up there
2608
+ // when that conversation is next loaded. The model/effort/tier/mode selects
2609
+ // and the image-edit target are captured here too: opening another
2610
+ // conversation rewrites the selects (applyConversationPreferences) and
2611
+ // clears the image-edit target, so a mid-await switch must not respell this
2612
+ // send with the other conversation's preferences.
2613
+ const targetConversationId = String(currentConvId || '').trim() || null;
2614
+ const draftAttachments = selectedAttachments.slice();
2615
+ const selectedModel = document.getElementById('model-select').value || '';
2616
+ const selectedReasoningEffort = String(document.getElementById('reasoning-effort-select')?.value || '').trim().toLowerCase();
2617
+ const selectedContextTier = String(document.getElementById('context-tier-select')?.value || 'default').trim();
2618
+ const selectedMode = document.getElementById('mode-select').value || 'agent';
2619
+ const draftImageEditTarget = imageEditTarget;
2620
+ let composerConversationId = targetConversationId;
2621
+ const viewingSendConversation = () => String(currentConvId || '').trim() === String(composerConversationId || '').trim();
2622
+ if (!(await validateSelectedConversationBeforeSend(targetConversationId))) {
2623
+ return;
2624
+ }
2625
+ if (window.isModelMetadataBlocked?.()) {
2626
+ showTransientRelayNotice('Model metadata is unavailable. Refresh models to continue.');
2627
+ return;
2628
+ }
2629
+ if (hasPendingUserMessageDuplicate(targetConversationId, text)) {
2630
+ showTransientRelayNotice('That message is already pending.');
2631
+ return;
2632
+ }
2633
+ if (targetConversationId) {
2634
+ clearDraftTimerForConversation(targetConversationId);
2635
+ }
2636
+
2637
+ setSendInFlight(true);
2638
+ let attachments = [];
2639
+ let clientMessageId = null;
2640
+ try {
2641
+ attachments = await uploadAttachments(draftAttachments);
2642
+
2643
+ const isNew = !targetConversationId;
2644
+ const msgTimestamp = new Date().toISOString();
2645
+ if (!selectedReasoningEffort) {
2646
+ showTransientRelayNotice('Select a reasoning effort after refreshing model metadata.');
2647
+ return;
2648
+ }
2649
+ const titleSeed = text || (attachments[0]?.name || 'Attachment');
2650
+ clientMessageId = generateId();
2651
+ trackPendingUserMessage(clientMessageId, targetConversationId, text);
2652
+ pendingUserMessageIds.add(clientMessageId);
2653
+ if (viewingSendConversation()) {
2654
+ input.value = '';
2655
+ autoResize(input);
2656
+ releaseComposerFocusAfterSend(input);
2657
+ appendMessage({ role: 'user', text, model: selectedModel, mode: selectedMode, timestamp: msgTimestamp, attachments }, true, clientMessageId, true);
2658
+ scrollBottomAfterSend();
2659
+ }
2660
+
2661
+ const body = {
2662
+ messageId: clientMessageId,
2663
+ clientId: CLIENT_ID,
2664
+ text,
2665
+ model: selectedModel,
2666
+ reasoningEffort: selectedReasoningEffort,
2667
+ contextTier: selectedContextTier,
2668
+ relayMode: selectedMode,
2669
+ conversationId: targetConversationId || undefined,
2670
+ newConversation: isNew || undefined,
2671
+ attachments,
2672
+ imageTarget: draftImageEditTarget
2673
+ ? {
2674
+ messageId: draftImageEditTarget.messageId,
2675
+ imageId: draftImageEditTarget.imageId,
2676
+ nodeId: draftImageEditTarget.nodeId,
2677
+ }
2678
+ : undefined,
2679
+ };
2680
+
2681
+ const r = await sendMessageApi(body);
2682
+ if (!r) {
2683
+ // Offline: park the send in the durable outbox instead of bouncing it
2684
+ // back into the composer. The client-generated messageId makes a replay
2685
+ // after an ambiguous failure idempotent (the server answers 409 for a
2686
+ // send that already landed). Only existing conversations queue — a new
2687
+ // conversation needs the server's response to become usable.
2688
+ if (navigator.onLine === false && targetConversationId) {
2689
+ const queued = await enqueueOutboxRequest({
2690
+ kind: 'message',
2691
+ path: '/api/message',
2692
+ body: JSON.stringify(body),
2693
+ });
2694
+ if (queued) {
2695
+ void registerOutboxSync();
2696
+ showTransientRelayNotice('You are offline. Message queued — it will send when the connection returns.', 7000);
2697
+ if (viewingSendConversation()) clearAttachments();
2698
+ return;
2699
+ }
2700
+ }
2701
+ clearPendingUserMessage(clientMessageId);
2702
+ const pendingNode = document.querySelector(`[data-message-id="${clientMessageId}"]`);
2703
+ pendingNode?.remove();
2704
+ pendingUserMessageIds.delete(clientMessageId);
2705
+ seenMessageIds.delete(clientMessageId);
2706
+ if (viewingSendConversation()) {
2707
+ input.value = originalComposerText;
2708
+ autoResize(input);
2709
+ }
2710
+ void scheduleConversationDraftSave({
2711
+ conversationId: targetConversationId,
2712
+ draftText: originalComposerText,
2713
+ immediate: true,
2714
+ });
2715
+ if (!mobileSend && viewingSendConversation()) input.focus();
2716
+ setModelBanner('⚠️ Message could not be sent. Please try again.');
2717
+ return;
2718
+ }
2719
+
2720
+ if (r.duplicate) {
2721
+ clearPendingUserMessage(clientMessageId);
2722
+ const pendingNode = document.querySelector(`[data-message-id="${clientMessageId}"]`);
2723
+ pendingNode?.remove();
2724
+ pendingUserMessageIds.delete(clientMessageId);
2725
+ seenMessageIds.delete(clientMessageId);
2726
+ if (!mobileSend && viewingSendConversation()) input.focus();
2727
+ showTransientRelayNotice('That message was already sent recently.');
2728
+ return;
2729
+ }
2730
+
2731
+ if ((r.workspaceRootName || r.workspaceRootEntries || r.workspaceRootPath) && viewingSendConversation()) {
2732
+ updateWorkspaceRootHints(r);
2733
+ if (repoBrowserState.open && repoBrowserState.activeRoot === 'workspace') {
2734
+ // Restoring refresh: if the root really changed, the restore walk
2735
+ // finds nothing to re-open and falls back to the new root on its own.
2736
+ window.refreshRepoBrowser?.();
2737
+ }
2738
+ }
2739
+ if (r.compactedConversationId) {
2740
+ await window.refreshConversations?.();
2741
+ // Only follow the auto-compact redirect while the user is still in the
2742
+ // conversation that was compacted.
2743
+ if (viewingSendConversation()) {
2744
+ await window.openConversation?.(r.compactedConversationId);
2745
+ clearAttachments();
2746
+ if (!mobileSend) input.focus();
2747
+ scrollBottomAfterSend();
2748
+ }
2749
+ return;
2750
+ }
2751
+ if (r.warning) setModelBanner(`⚠️ ${r.warning}`);
2752
+ if (r.workspaceRootWarning) setModelBanner(`⚠️ ${r.workspaceRootWarning}`);
2753
+ const skippedRefs = Array.isArray(r.skippedReferenceAttachments) ? r.skippedReferenceAttachments : [];
2754
+ if (skippedRefs.length) {
2755
+ const firstReason = String(skippedRefs[0]?.reason || 'reference skipped');
2756
+ setModelBanner(`⚠️ Some referenced images were not attached (${firstReason}).`);
2757
+ }
2758
+ // Clear only while the user still views the send conversation: a mid-await
2759
+ // switch may have set a fresh image-edit target on the newly opened one.
2760
+ if (draftImageEditTarget && viewingSendConversation()) clearImageEditTarget();
2761
+ if (isNew || !targetConversationId) {
2762
+ // Adopt the created conversation as the current view only while the
2763
+ // user is still on the blank view the message was sent from.
2764
+ const adoptNewConversation = viewingSendConversation();
2765
+ if (adoptNewConversation) {
2766
+ setCurrentConv(r.conversationId);
2767
+ composerConversationId = r.conversationId;
2768
+ }
2769
+ conversations[r.conversationId] = {
2770
+ id: r.conversationId,
2771
+ title: titleSeed.slice(0, 60),
2772
+ updatedAt: new Date().toISOString(),
2773
+ messageCount: 1,
2774
+ runtimeSessionId: r.runtimeSessionId || null,
2775
+ runtimeProviderType: r.runtimeProviderType || 'github',
2776
+ runtimeProviderModel: r.runtimeProviderModel || null,
2777
+ preferredRelayMode: r.preferredRelayMode || selectedMode,
2778
+ preferredModel: r.preferredModel || selectedModel,
2779
+ preferredReasoningEffort: r.preferredReasoningEffort || selectedReasoningEffort || 'none',
2780
+ };
2781
+ window.syncAutoModelAvailability?.();
2782
+ if (adoptNewConversation) {
2783
+ document.getElementById('chat-title').textContent = titleSeed.slice(0, 60);
2784
+ window.syncChatTitleControls?.();
2785
+ updateCompactButton();
2786
+ }
2787
+ window.renderConvList?.();
2788
+ if (adoptNewConversation) {
2789
+ applyContextUsageBar(null);
2790
+ scheduleContextUsageRefresh(r.conversationId, 0);
2791
+ }
2792
+ }
2793
+ if (conversations[r.conversationId]) {
2794
+ conversations[r.conversationId] = {
2795
+ ...conversations[r.conversationId],
2796
+ messageCount: Math.max(1, Number(conversations[r.conversationId].messageCount || 0)),
2797
+ runtimeSessionId: r.runtimeSessionId || conversations[r.conversationId].runtimeSessionId || null,
2798
+ runtimeProviderType: r.runtimeProviderType || conversations[r.conversationId].runtimeProviderType || 'github',
2799
+ runtimeProviderModel: r.runtimeProviderModel ?? conversations[r.conversationId].runtimeProviderModel ?? null,
2800
+ };
2801
+ window.syncAutoModelAvailability?.();
2802
+ }
2803
+ const persistedConversationId = String(r.conversationId || targetConversationId || '').trim();
2804
+ if (persistedConversationId) {
2805
+ await scheduleConversationDraftSave({
2806
+ conversationId: persistedConversationId,
2807
+ draftText: '',
2808
+ immediate: true,
2809
+ });
2810
+ upsertConversationDraftState(persistedConversationId, {
2811
+ draftText: '',
2812
+ draftUpdatedAt: new Date().toISOString(),
2813
+ draftUpdatedByClientId: CLIENT_ID,
2814
+ });
2815
+ }
2816
+ if (cliOnline && viewingSendConversation()) showThinking(r.messageId || null);
2817
+
2818
+ if (viewingSendConversation()) {
2819
+ clearAttachments();
2820
+ if (!mobileSend) input.focus();
2821
+ scrollBottomAfterSend();
2822
+ }
2823
+ } catch (e) {
2824
+ if (clientMessageId) {
2825
+ clearPendingUserMessage(clientMessageId);
2826
+ const pendingNode = document.querySelector(`[data-message-id="${clientMessageId}"]`);
2827
+ pendingNode?.remove();
2828
+ pendingUserMessageIds.delete(clientMessageId);
2829
+ seenMessageIds.delete(clientMessageId);
2830
+ }
2831
+ if (viewingSendConversation()) {
2832
+ input.value = originalComposerText;
2833
+ autoResize(input);
2834
+ }
2835
+ void scheduleConversationDraftSave({
2836
+ conversationId: targetConversationId,
2837
+ draftText: originalComposerText,
2838
+ immediate: true,
2839
+ });
2840
+ alert(e.message || 'Failed to send message');
2841
+ } finally {
2842
+ setSendInFlight(false);
2843
+ }
2844
+ }
2845
+
2846
+ // Textarea input hook (inline oninput beside autoResize): keeps the slash
2847
+ // menu in sync with the caret. Cheap no-op for non-slash text.
2848
+ export function updateComposerSlashMenu(input) {
2849
+ updateSlashAutocomplete(input, { conversationId: currentConvId });
2850
+ }
2851
+
2852
+ export function handleKey(e) {
2853
+ // The open menu owns navigation keys; Ctrl/Cmd+Enter still falls through to
2854
+ // send because the menu never consumes it.
2855
+ if (handleSlashAutocompleteKey(e, e.target)) return;
2856
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
2857
+ e.preventDefault();
2858
+ sendMessage();
2859
+ }
2860
+ }
2861
+
2862
+ async function validateSelectedConversationBeforeSend(conversationId = currentConvId) {
2863
+ const convId = String(conversationId || '').trim();
2864
+ if (!convId) return true;
2865
+
2866
+ const current = await loadConversationApi(convId, { limit: 1 });
2867
+ if (!current) {
2868
+ setModelBanner('⚠️ Selected conversation is unavailable. Please choose another conversation.');
2869
+ await window.refreshConversations?.();
2870
+ return false;
2871
+ }
2872
+
2873
+ const conversationSessionId = String(current.sdkSessionId || current.sdk_session_id || '').trim();
2874
+ const runtimeSessionSessionId = String(current.runtimeSession?.sdkSessionId || current.runtimeSession?.sdk_session_id || '').trim();
2875
+ if (!conversationSessionId) {
2876
+ setModelBanner('⚠️ This conversation is waiting to be claimed by the relay. Please wait, or open another conversation.');
2877
+ return false;
2878
+ }
2879
+ if (!runtimeSessionSessionId || conversationSessionId !== runtimeSessionSessionId) {
2880
+ setModelBanner('⚠️ This conversation is bound to a different relay session. Wait for the matching session to claim it, or open another conversation.');
2881
+ return false;
2882
+ }
2883
+
2884
+ conversations[convId] = {
2885
+ ...(conversations[convId] || {}),
2886
+ ...current,
2887
+ sdkSessionId: conversationSessionId,
2888
+ runtimeSessionId: current.runtimeSession?.id || null,
2889
+ };
2890
+ // The pill and repo browser describe the conversation being viewed; if the
2891
+ // user opened another conversation during the round trip above, applying
2892
+ // this one's session info would clobber the new view's.
2893
+ if (String(currentConvId || '').trim() === convId) {
2894
+ setRepoBrowserSessionInfo(current.sessionRootPath || '', current.sessionRootName || current.title || '');
2895
+ updateSessionPill(conversations[convId], current.runtimeSession || null);
2896
+ }
2897
+ return true;
2898
+ }