@yadsh/dsh-qa-surface 0.13.0 → 0.14.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 (336) hide show
  1. package/README.md +148 -22
  2. package/compatibility.json +7 -3
  3. package/lib/access/capability-catalog.js +22 -6
  4. package/lib/access/capability-catalog.js.map +1 -1
  5. package/lib/access/model.js +36 -2
  6. package/lib/access/model.js.map +1 -1
  7. package/lib/access/service.js +19 -4
  8. package/lib/access/service.js.map +1 -1
  9. package/lib/account-remotes.js +4 -0
  10. package/lib/account-remotes.js.map +1 -1
  11. package/lib/accounts/database.js +49 -9
  12. package/lib/accounts/database.js.map +1 -1
  13. package/lib/accounts/errors.js.map +1 -1
  14. package/lib/accounts/file.js.map +1 -1
  15. package/lib/accounts/store.js +53 -8
  16. package/lib/accounts/store.js.map +1 -1
  17. package/lib/admin/permissions.js +6 -0
  18. package/lib/admin/permissions.js.map +1 -1
  19. package/lib/admin/quality-store.js +95 -19
  20. package/lib/admin/quality-store.js.map +1 -1
  21. package/lib/admin/service.js +178 -0
  22. package/lib/admin/service.js.map +1 -1
  23. package/lib/cli.js +24 -4
  24. package/lib/cli.js.map +1 -1
  25. package/lib/client/QaAccountsController.js +52 -5
  26. package/lib/client/QaAccountsController.js.map +1 -1
  27. package/lib/client/QaConfigController.js.map +1 -1
  28. package/lib/client/QaSessionController.js +626 -63
  29. package/lib/client/QaSessionController.js.map +1 -1
  30. package/lib/client/QaSurface.js +279 -43
  31. package/lib/client/QaSurface.js.map +1 -1
  32. package/lib/client/QaSurfaceGuard.js +2 -2
  33. package/lib/client/QaSurfaceGuard.js.map +1 -1
  34. package/lib/client/QaTranscriptAdapter.js +4 -2
  35. package/lib/client/QaTranscriptAdapter.js.map +1 -1
  36. package/lib/client/admin/QaAdmin.js +59 -34
  37. package/lib/client/admin/QaAdmin.js.map +1 -1
  38. package/lib/client/admin/pages/Conversations.js +12 -12
  39. package/lib/client/admin/pages/Conversations.js.map +1 -1
  40. package/lib/client/admin/pages/ExpertMemory.js +210 -0
  41. package/lib/client/admin/pages/ExpertMemory.js.map +1 -0
  42. package/lib/client/admin/pages/Overview.js +4 -4
  43. package/lib/client/admin/pages/Overview.js.map +1 -1
  44. package/lib/client/admin/pages/Quality.js +5 -5
  45. package/lib/client/admin/pages/Quality.js.map +1 -1
  46. package/lib/client/admin/pages/Review.js +5 -5
  47. package/lib/client/admin/pages/Review.js.map +1 -1
  48. package/lib/client/admin/pages/SkillFiles.js +3 -3
  49. package/lib/client/admin/pages/SkillFiles.js.map +1 -1
  50. package/lib/client/admin/pages/Users.js +12 -12
  51. package/lib/client/admin/pages/Users.js.map +1 -1
  52. package/lib/client/admin/routes.js +4 -0
  53. package/lib/client/admin/routes.js.map +1 -1
  54. package/lib/client/admin/shared.js +15 -6
  55. package/lib/client/admin/shared.js.map +1 -1
  56. package/lib/client/admin/styles.js +3 -0
  57. package/lib/client/admin/styles.js.map +1 -1
  58. package/lib/client/attestation.js +11 -2
  59. package/lib/client/attestation.js.map +1 -1
  60. package/lib/client/audit/QaAuditDialog.js +2 -2
  61. package/lib/client/audit/QaAuditDialog.js.map +1 -1
  62. package/lib/client/audit/QaAuditRowBadge.js +1 -1
  63. package/lib/client/audit/QaAuditRowBadge.js.map +1 -1
  64. package/lib/client/components/Markdown.js +7 -2
  65. package/lib/client/components/Markdown.js.map +1 -1
  66. package/lib/client/components/QaAgentsDrawer.js +7 -5
  67. package/lib/client/components/QaAgentsDrawer.js.map +1 -1
  68. package/lib/client/components/QaApproval.js +2 -2
  69. package/lib/client/components/QaApproval.js.map +1 -1
  70. package/lib/client/components/QaAuthGate.js +6 -6
  71. package/lib/client/components/QaAuthGate.js.map +1 -1
  72. package/lib/client/components/QaChangelog.js +64 -0
  73. package/lib/client/components/QaChangelog.js.map +1 -1
  74. package/lib/client/components/QaComposer.js +9 -6
  75. package/lib/client/components/QaComposer.js.map +1 -1
  76. package/lib/client/components/QaFileAttachment.js +1 -1
  77. package/lib/client/components/QaFileAttachment.js.map +1 -1
  78. package/lib/client/components/QaFilesPanel.js +7 -7
  79. package/lib/client/components/QaFilesPanel.js.map +1 -1
  80. package/lib/client/components/QaHeader.js +4 -4
  81. package/lib/client/components/QaHeader.js.map +1 -1
  82. package/lib/client/components/QaMessage.js +12 -12
  83. package/lib/client/components/QaMessage.js.map +1 -1
  84. package/lib/client/components/QaModal.js +11 -2
  85. package/lib/client/components/QaModal.js.map +1 -1
  86. package/lib/client/components/QaQuestions.js +4 -4
  87. package/lib/client/components/QaQuestions.js.map +1 -1
  88. package/lib/client/components/QaQueueDock.js +137 -0
  89. package/lib/client/components/QaQueueDock.js.map +1 -0
  90. package/lib/client/components/QaRequestQueue.js +22 -0
  91. package/lib/client/components/QaRequestQueue.js.map +1 -0
  92. package/lib/client/components/QaRightRail.js +2 -2
  93. package/lib/client/components/QaRightRail.js.map +1 -1
  94. package/lib/client/components/QaSidebar.js +66 -6
  95. package/lib/client/components/QaSidebar.js.map +1 -1
  96. package/lib/client/components/QaSourcesPanel.js +10 -8
  97. package/lib/client/components/QaSourcesPanel.js.map +1 -1
  98. package/lib/client/components/QaThemeSwitcher.js +19 -0
  99. package/lib/client/components/QaThemeSwitcher.js.map +1 -0
  100. package/lib/client/components/QaTurnNotice.js +40 -0
  101. package/lib/client/components/QaTurnNotice.js.map +1 -0
  102. package/lib/client/components/QaTurnRail.js +3 -3
  103. package/lib/client/components/QaTurnRail.js.map +1 -1
  104. package/lib/client/components/QaWelcomeNotice.js +2 -6
  105. package/lib/client/components/QaWelcomeNotice.js.map +1 -1
  106. package/lib/client/components/QaWidthHandle.js +2 -2
  107. package/lib/client/components/QaWidthHandle.js.map +1 -1
  108. package/lib/client/components/QaWorkGroup.js +5 -5
  109. package/lib/client/components/QaWorkGroup.js.map +1 -1
  110. package/lib/client/components/QaWorkspaceBrowser.js +10 -10
  111. package/lib/client/components/QaWorkspaceBrowser.js.map +1 -1
  112. package/lib/client/components/VariantSwitcher.js +1 -1
  113. package/lib/client/components/VariantSwitcher.js.map +1 -1
  114. package/lib/client/failure-copy.js +26 -8
  115. package/lib/client/failure-copy.js.map +1 -1
  116. package/lib/client/focus-ring.js +193 -0
  117. package/lib/client/focus-ring.js.map +1 -0
  118. package/lib/client/index.js +40 -20
  119. package/lib/client/index.js.map +1 -1
  120. package/lib/client/lineage.js +3 -16
  121. package/lib/client/lineage.js.map +1 -1
  122. package/lib/client/markdown/CodeBlock.js +2 -2
  123. package/lib/client/markdown/CodeBlock.js.map +1 -1
  124. package/lib/client/markdown/blocks.js +118 -31
  125. package/lib/client/markdown/blocks.js.map +1 -1
  126. package/lib/client/markdown/math.js +1 -1
  127. package/lib/client/markdown/math.js.map +1 -1
  128. package/lib/client/markdown/render.js +32 -16
  129. package/lib/client/markdown/render.js.map +1 -1
  130. package/lib/client/markdown/source-chip.js +3 -3
  131. package/lib/client/markdown/source-chip.js.map +1 -1
  132. package/lib/client/notifications/notification-dispatcher.js +90 -0
  133. package/lib/client/notifications/notification-dispatcher.js.map +1 -0
  134. package/lib/client/notifications/preferences.js +61 -0
  135. package/lib/client/notifications/preferences.js.map +1 -0
  136. package/lib/client/notifications/turn-completion-source.js +82 -0
  137. package/lib/client/notifications/turn-completion-source.js.map +1 -0
  138. package/lib/client/notifications/use-turn-notifications.js +108 -0
  139. package/lib/client/notifications/use-turn-notifications.js.map +1 -0
  140. package/lib/client/panels/PanelHost.js +5 -5
  141. package/lib/client/panels/PanelHost.js.map +1 -1
  142. package/lib/client/panels/PanelLauncher.js +2 -2
  143. package/lib/client/panels/PanelLauncher.js.map +1 -1
  144. package/lib/client/project-session-state.js +156 -4
  145. package/lib/client/project-session-state.js.map +1 -1
  146. package/lib/client/role/RoleSelector.js +9 -4
  147. package/lib/client/role/RoleSelector.js.map +1 -1
  148. package/lib/client/settings/card.js +58 -17
  149. package/lib/client/settings/card.js.map +1 -1
  150. package/lib/client/settings/fields.js +11 -10
  151. package/lib/client/settings/fields.js.map +1 -1
  152. package/lib/client/settings/format.js +0 -9
  153. package/lib/client/settings/format.js.map +1 -1
  154. package/lib/client/settings/sections/access.js +5 -5
  155. package/lib/client/settings/sections/access.js.map +1 -1
  156. package/lib/client/settings/sections/accounts.js +10 -10
  157. package/lib/client/settings/sections/accounts.js.map +1 -1
  158. package/lib/client/settings/sections/attachments.js +6 -6
  159. package/lib/client/settings/sections/attachments.js.map +1 -1
  160. package/lib/client/settings/sections/branding.js +7 -7
  161. package/lib/client/settings/sections/branding.js.map +1 -1
  162. package/lib/client/settings/sections/common.js +4 -2
  163. package/lib/client/settings/sections/common.js.map +1 -1
  164. package/lib/client/settings/sections/docs.js +4 -3
  165. package/lib/client/settings/sections/docs.js.map +1 -1
  166. package/lib/client/settings/sections/embedding.js +2 -2
  167. package/lib/client/settings/sections/embedding.js.map +1 -1
  168. package/lib/client/settings/sections/interface.js +12 -12
  169. package/lib/client/settings/sections/interface.js.map +1 -1
  170. package/lib/client/settings/sections/lockdown.js +9 -9
  171. package/lib/client/settings/sections/lockdown.js.map +1 -1
  172. package/lib/client/settings/sections/notes.js +12 -12
  173. package/lib/client/settings/sections/notes.js.map +1 -1
  174. package/lib/client/settings/sections/session.js +15 -12
  175. package/lib/client/settings/sections/session.js.map +1 -1
  176. package/lib/client/settings/sections/slash.js +9 -9
  177. package/lib/client/settings/sections/slash.js.map +1 -1
  178. package/lib/client/settings/sections/sources.js +26 -26
  179. package/lib/client/settings/sections/sources.js.map +1 -1
  180. package/lib/client/settings/sections/status.js +2 -2
  181. package/lib/client/settings/sections/status.js.map +1 -1
  182. package/lib/client/settings/styles.js +15 -9
  183. package/lib/client/settings/styles.js.map +1 -1
  184. package/lib/client/slash/SlashPalette.js +4 -4
  185. package/lib/client/slash/SlashPalette.js.map +1 -1
  186. package/lib/client/styles.js +133 -83
  187. package/lib/client/styles.js.map +1 -1
  188. package/lib/client/theme-preference.js +162 -0
  189. package/lib/client/theme-preference.js.map +1 -0
  190. package/lib/client/types.js +8 -0
  191. package/lib/client/types.js.map +1 -1
  192. package/lib/client/use-session-ui-state.js +7 -5
  193. package/lib/client/use-session-ui-state.js.map +1 -1
  194. package/lib/client/user-settings/GeneralSettingsPage.js +6 -6
  195. package/lib/client/user-settings/GeneralSettingsPage.js.map +1 -1
  196. package/lib/client/user-settings/IntegrationTokensPage.js +6 -6
  197. package/lib/client/user-settings/IntegrationTokensPage.js.map +1 -1
  198. package/lib/client/user-settings/NotificationSettingsPage.js +107 -0
  199. package/lib/client/user-settings/NotificationSettingsPage.js.map +1 -0
  200. package/lib/client/user-settings/PasswordSettingsPage.js +1 -1
  201. package/lib/client/user-settings/PasswordSettingsPage.js.map +1 -1
  202. package/lib/client/user-settings/ProfileSettingsPage.js +2 -2
  203. package/lib/client/user-settings/ProfileSettingsPage.js.map +1 -1
  204. package/lib/client/user-settings/SkillCatalog.js +3 -3
  205. package/lib/client/user-settings/SkillCatalog.js.map +1 -1
  206. package/lib/client/user-settings/SkillEditor.js +26 -8
  207. package/lib/client/user-settings/SkillEditor.js.map +1 -1
  208. package/lib/client/user-settings/SkillToolPicker.js +3 -3
  209. package/lib/client/user-settings/SkillToolPicker.js.map +1 -1
  210. package/lib/client/user-settings/SkillsSettingsPage.js +1 -1
  211. package/lib/client/user-settings/SkillsSettingsPage.js.map +1 -1
  212. package/lib/client/user-settings/StartersSettingsPage.js +6 -6
  213. package/lib/client/user-settings/StartersSettingsPage.js.map +1 -1
  214. package/lib/client/user-settings/UserSettingsDialog.js +7 -2
  215. package/lib/client/user-settings/UserSettingsDialog.js.map +1 -1
  216. package/lib/client/user-settings/copy.js +2 -0
  217. package/lib/client/user-settings/copy.js.map +1 -1
  218. package/lib/client/user-settings/draft.js +4 -1
  219. package/lib/client/user-settings/draft.js.map +1 -1
  220. package/lib/client/user-settings/fields.js +5 -5
  221. package/lib/client/user-settings/fields.js.map +1 -1
  222. package/lib/client.js +7228 -4002
  223. package/lib/client.js.map +1 -1
  224. package/lib/config-resolvers/defaults.js +7 -0
  225. package/lib/config-resolvers/defaults.js.map +1 -1
  226. package/lib/config-resolvers/lockdown.js +7 -0
  227. package/lib/config-resolvers/lockdown.js.map +1 -1
  228. package/lib/config-resolvers/notifications.js +15 -0
  229. package/lib/config-resolvers/notifications.js.map +1 -0
  230. package/lib/config-resolvers/session.js +11 -1
  231. package/lib/config-resolvers/session.js.map +1 -1
  232. package/lib/config.js +67 -18
  233. package/lib/config.js.map +1 -1
  234. package/lib/enforcement/tool-grants.js +6 -6
  235. package/lib/enforcement/tool-grants.js.map +1 -1
  236. package/lib/index.js +145 -30
  237. package/lib/index.js.map +1 -1
  238. package/lib/integration/answer.js +74 -8
  239. package/lib/integration/answer.js.map +1 -1
  240. package/lib/integration/expert-memory.js +39 -0
  241. package/lib/integration/expert-memory.js.map +1 -0
  242. package/lib/integration/host-runner.js +26 -14
  243. package/lib/integration/host-runner.js.map +1 -1
  244. package/lib/notifications.js +66 -0
  245. package/lib/notifications.js.map +1 -0
  246. package/lib/personal-skills/errors.js.map +1 -1
  247. package/lib/personal-skills/service.js +81 -7
  248. package/lib/personal-skills/service.js.map +1 -1
  249. package/lib/prompt-notes.js +2 -10
  250. package/lib/prompt-notes.js.map +1 -1
  251. package/lib/qa-tools/docs-tools.js +1 -1
  252. package/lib/qa-tools/docs-tools.js.map +1 -1
  253. package/lib/qa-tools/lifecycle.js +6 -3
  254. package/lib/qa-tools/lifecycle.js.map +1 -1
  255. package/lib/request-queue.js +32 -0
  256. package/lib/request-queue.js.map +1 -0
  257. package/lib/resolve-config.js +2 -0
  258. package/lib/resolve-config.js.map +1 -1
  259. package/lib/secure-session.js +7 -2
  260. package/lib/secure-session.js.map +1 -1
  261. package/lib/shared/settings.js +19 -2
  262. package/lib/shared/settings.js.map +1 -1
  263. package/lib/turn-failure-log.js +67 -0
  264. package/lib/turn-failure-log.js.map +1 -0
  265. package/lib/typert.host.js +1398 -651
  266. package/lib/typert.remote-client.d.ts +15 -1
  267. package/lib/typert.remote-client.js +1315 -645
  268. package/lib/types/access/capability-catalog.d.ts +20 -4
  269. package/lib/types/access/model.d.ts +23 -0
  270. package/lib/types/access/service.d.ts +2 -3
  271. package/lib/types/account-remotes.d.ts +8 -1
  272. package/lib/types/accounts/database.d.ts +13 -3
  273. package/lib/types/accounts/errors.d.ts +9 -1
  274. package/lib/types/accounts/file.d.ts +12 -0
  275. package/lib/types/accounts/store.d.ts +30 -2
  276. package/lib/types/admin/quality-store.d.ts +33 -0
  277. package/lib/types/admin/service.d.ts +60 -1
  278. package/lib/types/client/QaAccountsController.d.ts +25 -2
  279. package/lib/types/client/QaConfigController.d.ts +2 -2
  280. package/lib/types/client/QaSessionController.d.ts +199 -6
  281. package/lib/types/client/QaSurface.d.ts +6 -1
  282. package/lib/types/client/admin/pages/ExpertMemory.d.ts +19 -0
  283. package/lib/types/client/admin/routes.d.ts +3 -1
  284. package/lib/types/client/admin/shared.d.ts +4 -0
  285. package/lib/types/client/components/Markdown.d.ts +8 -1
  286. package/lib/types/client/components/QaHeader.d.ts +3 -1
  287. package/lib/types/client/components/QaModal.d.ts +7 -0
  288. package/lib/types/client/components/QaQueueDock.d.ts +27 -0
  289. package/lib/types/client/components/QaRequestQueue.d.ts +20 -0
  290. package/lib/types/client/components/QaThemeSwitcher.d.ts +14 -0
  291. package/lib/types/client/components/QaTurnNotice.d.ts +55 -0
  292. package/lib/types/client/focus-ring.d.ts +65 -0
  293. package/lib/types/client/lineage.d.ts +3 -13
  294. package/lib/types/client/markdown/blocks.d.ts +30 -2
  295. package/lib/types/client/markdown/render.d.ts +4 -1
  296. package/lib/types/client/notifications/notification-dispatcher.d.ts +68 -0
  297. package/lib/types/client/notifications/preferences.d.ts +44 -0
  298. package/lib/types/client/notifications/turn-completion-source.d.ts +114 -0
  299. package/lib/types/client/notifications/use-turn-notifications.d.ts +51 -0
  300. package/lib/types/client/project-session-state.d.ts +35 -1
  301. package/lib/types/client/role/RoleSelector.d.ts +7 -1
  302. package/lib/types/client/settings/card.d.ts +36 -5
  303. package/lib/types/client/settings/fields.d.ts +24 -0
  304. package/lib/types/client/settings/format.d.ts +0 -5
  305. package/lib/types/client/settings/sections/common.d.ts +3 -1
  306. package/lib/types/client/settings/sections/session.d.ts +2 -1
  307. package/lib/types/client/settings/styles.d.ts +9 -4
  308. package/lib/types/client/settings-extensions/user-session.d.ts +1 -1
  309. package/lib/types/client/theme-preference.d.ts +69 -0
  310. package/lib/types/client/types.d.ts +25 -1
  311. package/lib/types/client/use-session-ui-state.d.ts +6 -4
  312. package/lib/types/client/user-settings/GeneralSettingsPage.d.ts +5 -5
  313. package/lib/types/client/user-settings/NotificationSettingsPage.d.ts +40 -0
  314. package/lib/types/client/user-settings/UserSettingsDialog.d.ts +12 -2
  315. package/lib/types/client/user-settings/draft.d.ts +4 -1
  316. package/lib/types/client/user-settings/fields.d.ts +7 -0
  317. package/lib/types/config-resolvers/notifications.d.ts +10 -0
  318. package/lib/types/config-resolvers/session.d.ts +6 -0
  319. package/lib/types/config.d.ts +20 -1
  320. package/lib/types/index.d.ts +53 -5
  321. package/lib/types/integration/answer.d.ts +27 -0
  322. package/lib/types/integration/expert-memory.d.ts +49 -0
  323. package/lib/types/notifications.d.ts +32 -0
  324. package/lib/types/personal-skills/errors.d.ts +1 -1
  325. package/lib/types/personal-skills/service.d.ts +6 -0
  326. package/lib/types/prompt-notes.d.ts +17 -4
  327. package/lib/types/request-queue.d.ts +32 -0
  328. package/lib/types/shared/settings.d.ts +19 -2
  329. package/lib/types/turn-failure-log.d.ts +18 -0
  330. package/lib/types/types.d.ts +180 -4
  331. package/package.json +64 -63
  332. package/scripts/attach-workspace-sessions.mjs +24 -5
  333. package/scripts/repair-session-events.mjs +25 -5
  334. package/lib/client/markdown/MermaidBlock.js +0 -80
  335. package/lib/client/markdown/MermaidBlock.js.map +0 -1
  336. package/lib/types/client/markdown/MermaidBlock.d.ts +0 -6
@@ -4,7 +4,7 @@ import { resolveSlashRoute } from "./slash/slash-router.js";
4
4
  import { SLASH_DISABLED_COPY, slashAttachmentsCopy, slashExecuteFailureCopy, slashHostRefusalCopy, slashRefusalCopy, slashWithheldCopy, } from "./slash/copy.js";
5
5
  import { canOpenAsCompatibilityReadOnly, QaPolicyAttestationError, } from "./attestation.js";
6
6
  import { QaChatIndex } from "./chat-index.js";
7
- import { isDelegatedSession, visibleSubagentCandidates, } from "./lineage.js";
7
+ import { isDelegatedSession, visibleSubagentCandidates } from "./lineage.js";
8
8
  import { SessionAssetRepository } from "./session-assets.js";
9
9
  import { createQaSession } from "./create-session.js";
10
10
  import { buildQaPromptContent, buildQaSlashAttachments, stageQaFiles, } from "./prompt-content.js";
@@ -17,8 +17,14 @@ import { QA_SESSION_IDLE_STATE } from "./types.js";
17
17
  import { waitFor } from "./wait-for.js";
18
18
  import { QA_REGENERATE_MARKER } from "./QaTranscriptAdapter.js";
19
19
  import { readableSubagentName } from "./settlement.js";
20
- import { projectBoundSessionState } from "./project-session-state.js";
21
- import { projectTurnSources, sourceAnchorRoot } from "./turn-sources.js";
20
+ import { hostNamedSubmissionIds, projectBoundSessionState, } from "./project-session-state.js";
21
+ /**
22
+ * `rc.2` deleted the Host-wide navigation: a Session lives while the view that
23
+ * retained it holds the reference, so binding a chat means retaining it for as
24
+ * long as this controller projects it.
25
+ */
26
+ const QA_SURFACE_SESSION_SOURCE = "qaSurface";
27
+ import { chatLegacyOf, projectTurnSources, sourceAnchorRoot, } from "./turn-sources.js";
22
28
  const CONFIGURATION_ERROR = "Настройки помощника недоступны.";
23
29
  /**
24
30
  * How long a catalog answer stays fresh enough to skip a re-read when the
@@ -32,6 +38,46 @@ const SLASH_CATALOG_STALE_MS = 3_000;
32
38
  * second of latency is invisible; the poll only runs while a turn runs.
33
39
  */
34
40
  const PENDING_POLL_MS = 1_000;
41
+ /**
42
+ * Source of chat identities. Page-wide rather than per controller: the surface
43
+ * re-creates this controller whenever the account, the config or the route
44
+ * changes, and a counter that restarted at its first value would name the new
45
+ * controller's chat exactly as it named the old one's — which is how a draft,
46
+ * its attachments and its per-chat UI state walked from one chat into another.
47
+ * `0` stays with `QA_SESSION_IDLE_STATE`, the snapshot the surface reads before
48
+ * any controller exists.
49
+ */
50
+ let chatKeySequence = 0;
51
+ function nextChatKey() {
52
+ chatKeySequence += 1;
53
+ return chatKeySequence;
54
+ }
55
+ /** What the strip says when the Host refuses one queue operation. */
56
+ const QUEUE_FAILURE_COPY = {
57
+ edit: "Не удалось изменить сообщение в очереди. Возможно, оно уже отправлено.",
58
+ remove: "Не удалось убрать сообщение из очереди. Возможно, оно уже отправлено.",
59
+ steer: "Не удалось отправить сообщение сразу. Попробуйте ещё раз.",
60
+ };
61
+ /**
62
+ * Image previews for the Host echo of a queued send. A data URL, not the
63
+ * composer's blob URL: the composer revokes the blob the moment the send is
64
+ * accepted, while the Host keeps the echo until its queue occurrence lands.
65
+ * Files are left out — the Host echo wants a durable attachment reference, and
66
+ * a staged receipt is a write handle, not one.
67
+ */
68
+ function submissionImages(attachments) {
69
+ return attachments.flatMap((attachment) => attachment.kind === "image"
70
+ ? [
71
+ {
72
+ type: "image",
73
+ value: {
74
+ previewUrl: `data:${attachment.mediaType};base64,${attachment.data}`,
75
+ name: attachment.name,
76
+ },
77
+ },
78
+ ]
79
+ : []);
80
+ }
35
81
  /**
36
82
  * The only module that couples QA behavior to DSH Session/client APIs. It owns
37
83
  * the chat lifecycle state machine; the observable wait, browser-local chat
@@ -59,8 +105,11 @@ export class QaSessionController {
59
105
  streamPublisher;
60
106
  state = QA_SESSION_IDLE_STATE;
61
107
  session;
108
+ /** The reference that keeps {@link session} alive; released with the binding. */
109
+ sessionReference;
62
110
  conversationBinding;
63
111
  unsubscribeSession;
112
+ unsubscribeInbox;
64
113
  unsubscribeChat;
65
114
  unsubscribeConnection;
66
115
  ensuring;
@@ -69,6 +118,18 @@ export class QaSessionController {
69
118
  admissionPending = false;
70
119
  pendingSubmission;
71
120
  pendingSequence = 0;
121
+ /**
122
+ * Request ids this binding has seen the Host name — in the Inbox queue or as a
123
+ * durable transcript row. The mask that keeps an echo the session library
124
+ * failed to retire from coming back as a question still crossing the
125
+ * transport: the retirement is `@deepseek-ai/dsh-api-session-controller`'s own
126
+ * contract, deferred to an animation frame that a surface with no frame clock
127
+ * never runs, and `projectQueue` in project-session-state.ts carries the
128
+ * citation, what the substitute costs and does not cost, and the point where
129
+ * this set is dead code. Kept for the whole binding: a request id is minted per
130
+ * submission, so an id the Host has named can never belong to a later send.
131
+ */
132
+ admittedSubmissions = new Set();
72
133
  policyReady = false;
73
134
  /** Historical transcript retained after the Host classifies policy drift. */
74
135
  compatibilityReadOnly = false;
@@ -79,6 +140,31 @@ export class QaSessionController {
79
140
  connectedOnce;
80
141
  disposed = false;
81
142
  generation = 0;
143
+ /**
144
+ * The session the current chat identity names, or null while the chat it
145
+ * names has no session yet. This is what makes the identity a chat identity
146
+ * rather than a counter: {@link bind} compares the session it is about to
147
+ * adopt against it, so any path that ends up in another session takes another
148
+ * identity — and a draft adopting its first session keeps the one it holds.
149
+ * It only ever names a session this controller holds: {@link bind} hands it
150
+ * out once the adoption has cleared every wait that caller asked it to take —
151
+ * a chat is opened and attested, a subagent transcript is only opened — and
152
+ * both a failed adoption and {@link unbind} take it back. The converse does
153
+ * not hold: a held session may name nothing, which is the transcript of an
154
+ * attestation this chat could not pass and whose caller still reads it. So a
155
+ * chat whose first send fell short keeps its identity while naming no session,
156
+ * and the retry — which brings a fresh id — stays in the composer the question
157
+ * was typed into instead of reading as a move to another chat.
158
+ */
159
+ namedSession = null;
160
+ /**
161
+ * Chat identity handed to the surface (`QaSessionState.chatKey`). Taken from
162
+ * the page-wide sequence on every move to another chat, and never when the
163
+ * bound draft merely creates its session: a component that keeps per-chat
164
+ * state in React therefore survives the first prompt of a new chat, while two
165
+ * chats never read the same identity.
166
+ */
167
+ chatKey = nextChatKey();
82
168
  chatsRevision = 0;
83
169
  selectedSubrole;
84
170
  adminPreview;
@@ -88,6 +174,9 @@ export class QaSessionController {
88
174
  hostApprovals;
89
175
  /** Host-side question requests of the bound chat waiting for the operator. */
90
176
  hostQuestions;
177
+ queueRemote;
178
+ /** Last send the stand had no room for; drives the request-ceiling dialog. */
179
+ requestQueueNotice = null;
91
180
  /** Set while a running turn is polled for parked requests. */
92
181
  pendingTimer;
93
182
  /**
@@ -146,6 +235,7 @@ export class QaSessionController {
146
235
  this.hostSources = new QaHostSourceBridge(this.sourceApi, () => this.accounts?.token() ?? "");
147
236
  this.hostApprovals = new QaHostApprovalBridge(options.approvalApi);
148
237
  this.hostQuestions = new QaHostQuestionBridge(options.questionApi);
238
+ this.queueRemote = options.queueStatus;
149
239
  this.timeoutMs = options.timeoutMs ?? 15_000;
150
240
  this.streamPublisher = new StreamPublisher(options.streamIntervalMs ?? 66);
151
241
  this.connectedOnce = this.connection.getSnapshot() !== undefined;
@@ -192,11 +282,22 @@ export class QaSessionController {
192
282
  if (this.session === undefined) {
193
283
  // A draft chat materializes its session only now: nothing was created
194
284
  // when the user pressed "New chat", so the first submission pays for it.
195
- if (!this.drafting || this.materializing !== undefined)
285
+ if (this.materializing !== undefined) {
286
+ // A first prompt is already paying for the session; this one would
287
+ // only race it. Say so, because the alternative is a silent drop.
288
+ this.refuseSend("Чат ещё создаётся. Отправьте сообщение ещё раз.");
196
289
  return undefined;
197
- if (!(await this.materializeDraft()) || this.session === undefined) {
290
+ }
291
+ if (!this.drafting) {
292
+ this.refuseSend("Не удалось отправить сообщение: чат не открыт.");
198
293
  return undefined;
199
294
  }
295
+ const created = await this.materializeDraft();
296
+ // A refusal reports itself inside materializeDraft, with the Host's
297
+ // reason, and a draft the user left while it was being created is
298
+ // attested by whichever chat took its place: neither answer here.
299
+ if (!created || this.session === undefined)
300
+ return undefined;
200
301
  }
201
302
  const step = await this.attestPolicy();
202
303
  if (step.kind === "stale") {
@@ -269,13 +370,43 @@ export class QaSessionController {
269
370
  this.publish();
270
371
  return false;
271
372
  }
373
+ // A message that joins the queue of a chat which is already answering costs
374
+ // the stand no second place — the turn is running anyway — so the ceiling
375
+ // has nothing to say about it. Read before the round-trip below: asking a
376
+ // busy Host how busy it is, in order to refuse the very queue that keeps
377
+ // the busy chat from stalling, would be the wrong answer twice over.
378
+ const joiningQueue = this.session?.getSnapshot().running === true;
379
+ if (this.config.session.maxActiveRequests > 0 && !joiningQueue) {
380
+ // The ceiling is read before anything is spent. A draft chat has no
381
+ // session yet, so a question this stand has no room for must not
382
+ // materialize one, must not enter the transcript, and must not leave the
383
+ // composer — which is what a refusal ahead of `beginSubmission` buys.
384
+ const bound = this.session;
385
+ const ceiling = await this.readQueueStatus();
386
+ // A chat that went away during the read answers for its own sends. One
387
+ // that merely arrived is this draft's session landing underneath, which
388
+ // the submission rides exactly the way it always did.
389
+ if (this.disposed || (bound !== undefined && this.session !== bound))
390
+ return false;
391
+ if (ceiling !== null && ceiling.full) {
392
+ this.requestQueueNotice = ceiling;
393
+ this.publish();
394
+ return false;
395
+ }
396
+ }
272
397
  this.operationError = null;
398
+ this.requestQueueNotice = null;
399
+ // A running turn has nowhere for a new question to land but its queue, and
400
+ // the queue is the Host's state, not the transcript's: echoing it into the
401
+ // transcript would show the same message twice — once waiting, once sent.
402
+ const queueing = this.session?.getSnapshot().running === true;
273
403
  // Regeneration rides a hidden marker and must never leak it into the UI.
274
- const submission = prompt === QA_REGENERATE_MARKER
404
+ const submission = prompt === QA_REGENERATE_MARKER || queueing
275
405
  ? undefined
276
406
  : this.beginSubmission(prompt, attachments);
277
407
  let accepted = false;
278
408
  let target;
409
+ let echo;
279
410
  try {
280
411
  target = await this.liveTarget();
281
412
  if (target === undefined)
@@ -284,6 +415,7 @@ export class QaSessionController {
284
415
  // Recovery may have replaced the original chat with a fresh session;
285
416
  // reconcile against the actual prompt target, not the abandoned one.
286
417
  submission.baselineUserCount = this.state.messages.filter((message) => message.role === "user").length;
418
+ submission.baselineTurnEnds = this.durableTurnEndCount();
287
419
  }
288
420
  let receipts = new Map();
289
421
  if (files.length > 0) {
@@ -299,11 +431,30 @@ export class QaSessionController {
299
431
  return false;
300
432
  }
301
433
  receipts = outcome.receipts;
434
+ // The upload was an external round-trip: the user may have left this
435
+ // chat or closed the surface while it ran. Re-checked before anything is
436
+ // written to the state and before the prompt is sent, so a draft meant
437
+ // for the abandoned chat cannot land in it or in its replacement.
438
+ if (this.disposed || this.session !== target)
439
+ return false;
302
440
  }
441
+ const content = buildQaPromptContent(prompt, attachments, receipts);
442
+ // A queued send needs the Host's own echo, not the transcript one: the
443
+ // Host mints the identity the prompt carries, retires the echo when its
444
+ // queue occurrence arrives, and retires it again when an identified
445
+ // prompt fails, so the strip never keeps a row the server refused.
446
+ echo = queueing
447
+ ? target.beginSubmission({
448
+ mode: "queue",
449
+ text: prompt,
450
+ attachments: submissionImages(attachments),
451
+ })
452
+ : undefined;
303
453
  this.admissionPending = true;
304
454
  this.publish();
305
- const content = buildQaPromptContent(prompt, attachments, receipts);
306
- const result = await target.prompt(content, "queue");
455
+ const result = queueing
456
+ ? await target.prompt(content, "queue", undefined, echo?.requestId)
457
+ : await target.prompt(content, "queue");
307
458
  if (this.session !== target)
308
459
  return false;
309
460
  if (!result.ok) {
@@ -321,6 +472,9 @@ export class QaSessionController {
321
472
  }
322
473
  catch (error) {
323
474
  this.admissionPending = false;
475
+ // The prompt call never completed, so nothing settled the echo; a queued
476
+ // row would otherwise sit in the strip claiming to be on its way.
477
+ echo?.abandon();
324
478
  console.error("dsh-qa-surface: prompt failed", error);
325
479
  if (this.session === target) {
326
480
  this.operationError = "Не удалось отправить сообщение.";
@@ -337,6 +491,37 @@ export class QaSessionController {
337
491
  }
338
492
  }
339
493
  }
494
+ /**
495
+ * What the stand answers about its load, or null when it does not answer.
496
+ *
497
+ * Every failure here sends the question anyway. The ceiling exists to keep a
498
+ * weak model from drowning, not to be a door a browser cannot open: an
499
+ * unreadable count says nothing about the load, and refusing a visitor on
500
+ * that would be a worse answer than the one they would have got.
501
+ */
502
+ async readQueueStatus() {
503
+ if (this.queueRemote === undefined)
504
+ return null;
505
+ try {
506
+ const result = await this.queueRemote();
507
+ return result.ok ? result.value : null;
508
+ }
509
+ catch (error) {
510
+ console.error("dsh-qa-surface: queue status unavailable", error);
511
+ return null;
512
+ }
513
+ }
514
+ /**
515
+ * Close the request-ceiling dialog. Nothing is retried and nothing is
516
+ * dropped: the held-back question is still the composer's own text, so asking
517
+ * it again is the visitor's keystroke, taken once the stand has a place free.
518
+ */
519
+ dismissRequestQueueNotice() {
520
+ if (this.requestQueueNotice === null)
521
+ return;
522
+ this.requestQueueNotice = null;
523
+ this.publish();
524
+ }
340
525
  /**
341
526
  * Run one admitted human command. The Host answers it and writes the
342
527
  * lifecycle into the session log; this method only decides what the composer
@@ -378,6 +563,12 @@ export class QaSessionController {
378
563
  return false;
379
564
  }
380
565
  receipts = outcome.receipts;
566
+ // The upload was an external round-trip: the user may have left this
567
+ // chat or closed the surface while it ran. Re-checked before the command
568
+ // reaches the Host, so a command meant for the abandoned chat cannot run
569
+ // in it or in its replacement.
570
+ if (this.disposed || this.session !== target)
571
+ return false;
381
572
  }
382
573
  const result = await this.slashApi.execute(this.accounts?.token() ?? "", String(target.sessionId), line, buildQaSlashAttachments(attachments, receipts));
383
574
  if (this.session !== target)
@@ -529,6 +720,16 @@ export class QaSessionController {
529
720
  this.slashViewCache = view;
530
721
  return view;
531
722
  }
723
+ /**
724
+ * Turns the loaded Chat window has recorded as durably finished. The Host
725
+ * writes one per `turn/end`, so the count only moves on a fact the
726
+ * transcript itself carries — unlike the session's running bit, which the
727
+ * list relay can also deliver stale.
728
+ */
729
+ durableTurnEndCount() {
730
+ return chatLegacyOf(this.conversationBinding?.snapshot.getSnapshot())
731
+ .turnEnds.size;
732
+ }
532
733
  /** Publish a browser-only copy before any network or Host admission awaits. */
533
734
  beginSubmission(text, attachments) {
534
735
  const submission = {
@@ -560,8 +761,8 @@ export class QaSessionController {
560
761
  : []),
561
762
  },
562
763
  baselineUserCount: this.state.messages.filter((message) => message.role === "user").length,
764
+ baselineTurnEnds: this.durableTurnEndCount(),
563
765
  accepted: false,
564
- sawRunning: false,
565
766
  };
566
767
  this.pendingSubmission = submission;
567
768
  this.publish();
@@ -596,11 +797,54 @@ export class QaSessionController {
596
797
  }
597
798
  this.publish();
598
799
  }
800
+ /**
801
+ * Edit, send now, or drop one message that still waits for its turn. Every
802
+ * operation addresses the occurrence id the Host's queue frame carried, so a
803
+ * row the agent claimed a moment ago is refused by the Host rather than
804
+ * quietly dropped here.
805
+ *
806
+ * The answer is the refusal text rather than a flag, and the strip beside the
807
+ * composer keeps showing it: a session frame follows a refusal within
808
+ * milliseconds, and the shared error line is cleared by exactly that frame.
809
+ */
810
+ async queueAction(id, action, text = "") {
811
+ const target = this.session;
812
+ if (target === undefined || !this.state.canEditQueue)
813
+ return null;
814
+ const wire = action === "remove"
815
+ ? { kind: "remove" }
816
+ : action === "steer"
817
+ ? { kind: "steer" }
818
+ : { kind: "edit", content: [{ type: "text", text }] };
819
+ try {
820
+ const result = await target.updateQueue(
821
+ // The strip holds the id as text; only the Host's brand knows it back.
822
+ id, wire);
823
+ if (this.session !== target)
824
+ return null;
825
+ if (!result.ok)
826
+ return QUEUE_FAILURE_COPY[action];
827
+ this.publish();
828
+ return null;
829
+ }
830
+ catch (error) {
831
+ console.error("dsh-qa-surface: queue operation failed", error);
832
+ if (this.session !== target)
833
+ return null;
834
+ return QUEUE_FAILURE_COPY[action];
835
+ }
836
+ }
599
837
  /**
600
838
  * Enter the "new chat" draft: show an empty composer without creating any
601
839
  * session. Nothing appears in the chat list and no Host session is spent
602
840
  * until the first prompt is actually sent, which materializes the session
603
841
  * lazily ({@link materializeDraft}). Fixed-policy deployments cannot draft.
842
+ *
843
+ * A draft is a chat of its own, so entering one takes the screen the way
844
+ * {@link switchTo} does; see the note where the generation moves. Stopping the
845
+ * turn that was running first takes a round-trip, and a chat opened through it
846
+ * keeps the screen: the draft is what the visitor asked for before that click,
847
+ * so this call gives up rather than taking the chat back from under them.
604
848
  */
605
849
  async startDraft(policyChange = false) {
606
850
  if (this.disposed ||
@@ -610,6 +854,7 @@ export class QaSessionController {
610
854
  !this.config.lockdown.allowSessionReset))
611
855
  return;
612
856
  const previous = this.session;
857
+ const owned = this.generation;
613
858
  if (!this.compatibilityReadOnly &&
614
859
  previous?.getSnapshot().running === true) {
615
860
  try {
@@ -618,15 +863,32 @@ export class QaSessionController {
618
863
  catch (error) {
619
864
  console.error("dsh-qa-surface: stop before draft failed", error);
620
865
  }
866
+ // Asking the Host to stop a running turn is a round-trip, and the visitor
867
+ // reaches the chat list through it. Whoever took the screen during that
868
+ // window owns it: a draft asked for before the trip would otherwise take
869
+ // the screen back, unbind the chat that had just been adopted, and answer
870
+ // its send with nothing — the same takeover this method's own generation
871
+ // raise exists to stop.
872
+ if (this.disposed || this.generation !== owned)
873
+ return;
621
874
  }
875
+ // Taking the screen from the chat it was showing moves the generation, as
876
+ // every other path that retires a binding does: an adoption still looking
877
+ // for its session measures itself against that generation, and would
878
+ // otherwise take the screen back for the chat the visitor just left. Its
879
+ // materialization goes the same way — it belongs to the ending chat, and must
880
+ // not hold this draft in "chat still being created" until a round-trip nobody
881
+ // is waiting for answers.
882
+ this.generation += 1;
883
+ this.materializing = undefined;
622
884
  this.drafting = true;
623
- this.pendingSubmission = undefined;
885
+ this.openChat();
624
886
  this.unbind();
625
887
  this.operationError = null;
626
- this.admissionPending = false;
627
888
  this.policyReady = false;
628
889
  this.state = {
629
890
  ...QA_SESSION_IDLE_STATE,
891
+ chatKey: this.chatKey,
630
892
  chatsRevision: this.chatsRevision,
631
893
  };
632
894
  this.publish();
@@ -652,7 +914,12 @@ export class QaSessionController {
652
914
  });
653
915
  if (this.disposed || operation !== this.generation)
654
916
  return false;
655
- await this.bind(id);
917
+ // A step back is not a materialized draft: this adoption never ended up
918
+ // holding a session, so the draft has to stay a draft. Calling it done
919
+ // leaves a chat with neither a session nor a draft behind, and every
920
+ // later question in it answered with "chat not open".
921
+ if (!(await this.bind(id, { operation })))
922
+ return false;
656
923
  if (this.disposed || operation !== this.generation)
657
924
  return false;
658
925
  this.drafting = false;
@@ -692,14 +959,14 @@ export class QaSessionController {
692
959
  return;
693
960
  const operation = ++this.generation;
694
961
  this.drafting = false;
695
- this.pendingSubmission = undefined;
962
+ this.openChat();
696
963
  this.viewingSubagent = null;
697
964
  this.unbind();
698
965
  this.operationError = null;
699
- this.admissionPending = false;
700
966
  this.policyReady = false;
701
967
  this.state = {
702
968
  ...QA_SESSION_IDLE_STATE,
969
+ chatKey: this.chatKey,
703
970
  chatsRevision: this.chatsRevision,
704
971
  phase: "creating",
705
972
  };
@@ -721,7 +988,15 @@ export class QaSessionController {
721
988
  this.forgetChat(sessionId);
722
989
  throw new Error("Этот чат больше недоступен.");
723
990
  }
724
- await this.bind(sessionId, { allowCompatibilityReadOnly: true });
991
+ const adopted = await this.bind(sessionId, {
992
+ operation,
993
+ allowCompatibilityReadOnly: true,
994
+ });
995
+ // The chat this switch was asked for is on screen only if this adoption
996
+ // finished: a step back means another chat took the screen in the meantime,
997
+ // and persisting this id as the open one would reopen it on the next load.
998
+ if (!adopted)
999
+ return;
725
1000
  if (this.disposed || operation !== this.generation)
726
1001
  return;
727
1002
  this.chats.saveActive(sessionId);
@@ -751,14 +1026,14 @@ export class QaSessionController {
751
1026
  }
752
1027
  const operation = ++this.generation;
753
1028
  this.drafting = false;
754
- this.pendingSubmission = undefined;
1029
+ this.openChat();
755
1030
  this.viewingSubagent = { id, title };
756
1031
  this.unbind();
757
1032
  this.operationError = null;
758
- this.admissionPending = false;
759
1033
  this.policyReady = false;
760
1034
  this.state = {
761
1035
  ...QA_SESSION_IDLE_STATE,
1036
+ chatKey: this.chatKey,
762
1037
  chatsRevision: this.chatsRevision,
763
1038
  viewingSubagent: this.viewingSubagent,
764
1039
  phase: "creating",
@@ -766,7 +1041,16 @@ export class QaSessionController {
766
1041
  this.emit();
767
1042
  try {
768
1043
  await this.waitForConnection();
769
- await this.bind(id, { attest: false, track: false, report: false });
1044
+ // A step back here is the visitor having left this view for another chat,
1045
+ // which shows its own transcript — there is nothing of ours left to publish
1046
+ // and nothing of theirs to mark as a failed open.
1047
+ if (!(await this.bind(id, {
1048
+ operation,
1049
+ attest: false,
1050
+ track: false,
1051
+ report: false,
1052
+ })))
1053
+ return;
770
1054
  }
771
1055
  catch (error) {
772
1056
  // A newer operation superseded this view request: leave its state alone.
@@ -875,6 +1159,7 @@ export class QaSessionController {
875
1159
  this.drafting = false;
876
1160
  this.state = {
877
1161
  ...QA_SESSION_IDLE_STATE,
1162
+ chatKey: this.chatKey,
878
1163
  chatsRevision: this.chatsRevision,
879
1164
  phase: "creating",
880
1165
  };
@@ -926,10 +1211,15 @@ export class QaSessionController {
926
1211
  // replace it. A freshly created session must report a refusal at once —
927
1212
  // it is the deployment's only chance to learn the reason.
928
1213
  try {
929
- await this.bind(id, {
1214
+ // A step back is not a refusal: another operation owns the screen now,
1215
+ // so this bootstrap must neither run its recovery ladder over it nor
1216
+ // persist a chat it never adopted.
1217
+ if (!(await this.bind(id, {
1218
+ operation,
930
1219
  report: !restored,
931
1220
  allowCompatibilityReadOnly: existing,
932
- });
1221
+ })))
1222
+ return;
933
1223
  }
934
1224
  catch (error) {
935
1225
  // A stale restore must not start its recovery ladder against a newer
@@ -947,6 +1237,9 @@ export class QaSessionController {
947
1237
  this.unbind();
948
1238
  this.operationError = null;
949
1239
  this.chats.clearActive();
1240
+ // Nothing is published for the replacement yet: until it exists this is
1241
+ // still the chat on screen, and a fresh session the stand refuses to
1242
+ // create must not cost the visitor the draft they were reading.
950
1243
  id = await createQaSession({
951
1244
  createSession: this.createSessionRemote,
952
1245
  token: this.accounts?.token() ?? "",
@@ -955,7 +1248,15 @@ export class QaSessionController {
955
1248
  });
956
1249
  if (this.disposed || operation !== this.generation)
957
1250
  return;
958
- await this.bind(id);
1251
+ // The replacement is another chat — empty, and not one the user asked to
1252
+ // open — so it takes an identity of its own, dropping the refused chat's
1253
+ // unsent text, staged attachments and drawers rather than handing them
1254
+ // to a conversation nobody chose. The bind that refused it left the
1255
+ // identity naming no session, so without this the replacement would
1256
+ // adopt the refused chat's.
1257
+ this.openChat();
1258
+ if (!(await this.bind(id, { operation })))
1259
+ return;
959
1260
  }
960
1261
  if (this.disposed || operation !== this.generation)
961
1262
  return;
@@ -991,14 +1292,14 @@ export class QaSessionController {
991
1292
  }
992
1293
  const operation = ++this.generation;
993
1294
  this.unbind();
994
- this.admissionPending = false;
995
1295
  this.policyReady = false;
996
1296
  this.state = { ...this.state, phase: "creating" };
997
1297
  this.emit();
998
1298
  try {
999
1299
  await this.waitForConnection();
1000
1300
  try {
1001
- await this.bind(id);
1301
+ if (!(await this.bind(id, { operation })))
1302
+ return null;
1002
1303
  if (this.disposed || operation !== this.generation)
1003
1304
  return null;
1004
1305
  if (this.policyReady)
@@ -1014,6 +1315,11 @@ export class QaSessionController {
1014
1315
  this.fail(CONFIGURATION_ERROR, new QaPolicyAttestationError());
1015
1316
  return null;
1016
1317
  }
1318
+ // Only a chat that never received a prompt is replaced here, so the fresh
1319
+ // id continues the chat the user is writing into: leaving the binding
1320
+ // above takes the identity's name back, and bind() then adopts the fresh
1321
+ // id under the identity still on screen — the question this send carries
1322
+ // has not left the composer.
1017
1323
  this.unbind();
1018
1324
  this.chats.clearActive();
1019
1325
  const created = await createQaSession({
@@ -1024,7 +1330,8 @@ export class QaSessionController {
1024
1330
  });
1025
1331
  if (this.disposed || operation !== this.generation)
1026
1332
  return null;
1027
- await this.bind(created);
1333
+ if (!(await this.bind(created, { operation })))
1334
+ return null;
1028
1335
  if (this.disposed || operation !== this.generation)
1029
1336
  return null;
1030
1337
  this.chats.saveActive(created);
@@ -1039,17 +1346,92 @@ export class QaSessionController {
1039
1346
  return null;
1040
1347
  }
1041
1348
  }
1042
- async bind(id, options = {}) {
1043
- const { report = true, attest = true, track = true, allowCompatibilityReadOnly = false, } = options;
1044
- this.sessions.open(id);
1349
+ /**
1350
+ * Begin a chat of its own: take a fresh identity from the page-wide sequence,
1351
+ * leave it naming no session, and retire the send the ending chat had in
1352
+ * flight. Called on every move between chats — reset, switch, subagent view —
1353
+ * by {@link bind} when it ends up in a session the identity does not name, and
1354
+ * by the bootstrap whose restored chat was refused, for the replacement it is
1355
+ * about to open.
1356
+ */
1357
+ openChat() {
1358
+ this.namedSession = null;
1359
+ this.chatKey = nextChatKey();
1360
+ // A send in flight belongs to the chat that is ending. The projection reads
1361
+ // the optimistic row and the busy flag out of it without asking which chat
1362
+ // is on screen, so unless it dies here, the chat that takes this identity
1363
+ // shows a question it never received and waits for a send it never made.
1364
+ this.pendingSubmission = undefined;
1365
+ this.admissionPending = false;
1366
+ }
1367
+ /**
1368
+ * Adopt one Host session as the chat on screen: take its binding, wait for it
1369
+ * to open, attest the policy, and only then hand the chat identity to the
1370
+ * session, so {@link namedSession} never names one this controller does not
1371
+ * hold. A caller that asks for no attestation — the subagent view, whose
1372
+ * transcript is read rather than written into — is named once its session is
1373
+ * open.
1374
+ *
1375
+ * Answers whether the adoption happened. `true` is a finished one: this call
1376
+ * holds the session and the chat identity names it, so the caller may publish
1377
+ * what it kept for that chat — the draft it retired, the id it persisted.
1378
+ * `false` is a step back: the chat on screen moved on while this call waited,
1379
+ * it undoes nothing of the chat that replaced it and leaves the screen holding
1380
+ * nothing of this one ({@link stepBack}), and the caller must keep its own
1381
+ * state as unclaimed — a draft that was never materialized stays a draft, and a
1382
+ * chat that was never adopted is not persisted as the open one.
1383
+ *
1384
+ * Any of those waits can outlive the operation it belongs to — the user moves
1385
+ * to another chat while a first send is still looking for its session. Two
1386
+ * questions decide that at each wait: is this still the generation the caller
1387
+ * owns, and is this still the session this call took? The first is the page's
1388
+ * own — every path that takes the screen raises the generation. The second is
1389
+ * kept beside it because a generation only records who was asked to start, not
1390
+ * what the controller holds by the time an adoption resumes: a binding
1391
+ * somebody else retired in between is no more this one's to finish.
1392
+ */
1393
+ async bind(id, options) {
1394
+ const { operation, report = true, attest = true, track = true, allowCompatibilityReadOnly = false, } = options;
1395
+ const reference = this.sessions.retain(id, {
1396
+ source: QA_SURFACE_SESSION_SOURCE,
1397
+ });
1045
1398
  let binding = this.sessions.binding(id);
1046
- if (binding === undefined) {
1047
- await waitFor(this.sessions.list, (snapshot) => Object.hasOwn(snapshot.byId, id), this.timeoutMs);
1048
- binding = this.sessions.binding(id);
1399
+ try {
1400
+ if (binding === undefined) {
1401
+ await reference.ready;
1402
+ binding = this.sessions.binding(id);
1403
+ }
1404
+ if (binding === undefined) {
1405
+ await waitFor(this.sessions.list, (snapshot) => Object.hasOwn(snapshot.byId, id), this.timeoutMs);
1406
+ binding = this.sessions.binding(id);
1407
+ }
1408
+ }
1409
+ finally {
1410
+ // A binding this controller never got to hold must not leave a reference
1411
+ // counting against the Session it could not show.
1412
+ if (binding === undefined)
1413
+ reference.release();
1049
1414
  }
1050
1415
  if (binding === undefined)
1051
1416
  throw new Error("Session binding is unavailable.");
1417
+ if (this.disposed || operation !== this.generation) {
1418
+ // A newer operation owns the chat on screen while this one was still
1419
+ // looking for its session. Adopting now would retire the binding that
1420
+ // newer operation is showing, so this one gives up before it takes
1421
+ // anything; the reference it retained is its own to release.
1422
+ reference.release();
1423
+ return false;
1424
+ }
1425
+ // Another session under the identity that named a different one is another
1426
+ // chat: it takes its own identity, so nothing the previous chat was holding
1427
+ // — an unsent question, staged attachments, an open drawer — walks into it.
1428
+ // A chat that names no session adopts under the identity it already has,
1429
+ // which is what keeps a retried first send — it brings a fresh id — in the
1430
+ // composer the question was typed into.
1431
+ if (this.namedSession !== null && this.namedSession !== id)
1432
+ this.openChat();
1052
1433
  this.unbind();
1434
+ this.sessionReference = reference;
1053
1435
  this.session = binding.session;
1054
1436
  this.conversationBinding = this.conversation.binding(id);
1055
1437
  this.policyReady = false;
@@ -1059,6 +1441,12 @@ export class QaSessionController {
1059
1441
  this.operationError = null;
1060
1442
  this.publishSessionUpdate();
1061
1443
  });
1444
+ // The queue strip reads the Inbox projection, which the Host publishes on
1445
+ // its own channel: a claim or a splice changes no session snapshot, so the
1446
+ // strip would otherwise keep the rows the agent has already taken.
1447
+ this.unsubscribeInbox = binding.session.projections
1448
+ .faceOf("inbox")
1449
+ .subscribe(() => this.publishSessionUpdate());
1062
1450
  // Subscribing the Chat target activates it, so the assembled transcript
1063
1451
  // (nodes, partials, running calls) materializes for the projection.
1064
1452
  this.unsubscribeChat = this.conversationBinding
@@ -1066,42 +1454,128 @@ export class QaSessionController {
1066
1454
  .subscribe(() => {
1067
1455
  this.publishSessionUpdate();
1068
1456
  });
1069
- await waitFor(binding.session, (snapshot) => snapshot.openState === "open" || snapshot.openState === "error", this.timeoutMs);
1070
- if (binding.session.getSnapshot().openState !== "open") {
1071
- throw new Error("Session binding could not be opened.");
1072
- }
1073
- if (attest) {
1074
- const step = await this.attestPolicy(report);
1075
- if (step.kind === "refused") {
1076
- if (!allowCompatibilityReadOnly ||
1077
- !canOpenAsCompatibilityReadOnly(step.reason)) {
1078
- throw new QaPolicyAttestationError(step.reason);
1457
+ try {
1458
+ await waitFor(binding.session, (snapshot) => snapshot.openState === "open" || snapshot.openState === "error", this.timeoutMs);
1459
+ if (binding.session.getSnapshot().openState !== "open") {
1460
+ throw new Error("Session binding could not be opened.");
1461
+ }
1462
+ if (attest) {
1463
+ // Attestation both reads and writes the chat on screen: it proves the
1464
+ // session the controller holds and stamps this adoption's verdict onto
1465
+ // it. Ask the two questions before spending a proof on the wrong chat,
1466
+ // and answer only for the session this call took — not for whichever
1467
+ // one the screen holds by the time the proof comes back.
1468
+ if (this.stepBack(operation, binding.session))
1469
+ return false;
1470
+ const step = await this.attestPolicy(report, binding.session);
1471
+ // A chat that moved on during the proof is attested by whichever
1472
+ // adoption holds it now, so this verdict is not this one's to write.
1473
+ if (this.stepBack(operation, binding.session))
1474
+ return false;
1475
+ if (step.kind === "refused") {
1476
+ if (!allowCompatibilityReadOnly ||
1477
+ !canOpenAsCompatibilityReadOnly(step.reason)) {
1478
+ throw new QaPolicyAttestationError(step.reason);
1479
+ }
1480
+ // The Host classified the existing binding before returning these
1481
+ // reason classes. Preserve the transcript, but never mark the binding
1482
+ // policy-ready: every mutating operation remains disabled.
1483
+ this.compatibilityReadOnly = true;
1484
+ this.operationError = null;
1079
1485
  }
1080
- // The Host classified the existing binding before returning these
1081
- // reason classes. Preserve the transcript, but never mark the binding
1082
- // policy-ready: every mutating operation remains disabled.
1083
- this.compatibilityReadOnly = true;
1084
- this.operationError = null;
1085
1486
  }
1086
- else if (step.kind !== "ok") {
1087
- throw new QaPolicyAttestationError();
1487
+ if (this.stepBack(operation, binding.session)) {
1488
+ // The chat on screen moved on while this adoption waited: what replaced
1489
+ // it holds the identity now, so there is nothing here left to finish —
1490
+ // and this call gives back the binding it installed, which is all the
1491
+ // screen ever took from it.
1492
+ return false;
1493
+ }
1494
+ if (track) {
1495
+ this.chatSessionId = String(binding.session.sessionId);
1496
+ this.viewingSubagent = null;
1497
+ this.chats.addChat(String(binding.session.sessionId));
1498
+ this.accounts?.onSessionCreated(String(binding.session.sessionId));
1088
1499
  }
1089
1500
  }
1090
- if (track) {
1091
- this.chatSessionId = String(this.session.sessionId);
1092
- this.viewingSubagent = null;
1093
- this.chats.addChat(String(this.session.sessionId));
1094
- this.accounts?.onSessionCreated(String(this.session.sessionId));
1501
+ catch (error) {
1502
+ // An adoption that fell short must not leave the chat naming the session
1503
+ // it could not finish with: a retried first send brings another id, and an
1504
+ // identity still pointing here would read that retry as a move to another
1505
+ // chat — rebuilding the composer over the very question that has to go
1506
+ // again. A session that never opened is not one this controller holds, so
1507
+ // it goes too; a session whose attestation was refused stays, because its
1508
+ // caller reads the transcript before deciding whether the chat may be
1509
+ // replaced. Once a newer chat has retired the binding this call took, none
1510
+ // of it is ours to undo — that chat owns the identity from then on, and
1511
+ // there is nothing of ours left installed.
1512
+ if (this.ownsAdoption(operation, binding.session)) {
1513
+ this.namedSession = null;
1514
+ if (!(error instanceof QaPolicyAttestationError))
1515
+ this.unbind();
1516
+ }
1517
+ else if (this.session === binding.session) {
1518
+ // Left behind, and the failure is not a caller's to read any more: the
1519
+ // hand that took the screen was the bootstrap, which retires nothing, so
1520
+ // this adoption's own binding and subscriptions go with this throw.
1521
+ this.unbind();
1522
+ }
1523
+ throw error;
1095
1524
  }
1525
+ // Every wait this adoption was asked to take is behind it: only now does the
1526
+ // chat take this session as its identity.
1527
+ this.namedSession = id;
1096
1528
  this.publish();
1097
1529
  // Deliberately not awaited: the palette is a convenience, and a chat must
1098
1530
  // open at once whether or not the skill and command registries answer.
1099
1531
  void this.refreshSlashCatalog(true);
1532
+ return true;
1533
+ }
1534
+ /**
1535
+ * Give up on an adoption the chat on screen has outgrown, and answer whether
1536
+ * that is what happened (`true` — the caller has nothing left to publish).
1537
+ *
1538
+ * Past the binding this call installed a session of its own: the retained
1539
+ * reference, the three subscriptions beside it, and whatever those
1540
+ * subscriptions write on every frame. The path that took the screen usually
1541
+ * retired all of that with its own {@link unbind} — but the bootstrap raises the
1542
+ * generation while keeping the transcript readable until its own session
1543
+ * exists, and an adoption left behind by it would otherwise go on holding a
1544
+ * Host session nobody is in and publishing that chat's frames into the surface:
1545
+ * each frame clears the error the bootstrap published and answers for a binding
1546
+ * the page has given up on. Undoing the install is therefore this call's own
1547
+ * hand — and only its own: the chat that already replaced this one holds the
1548
+ * name, the subscriptions and the policy proof, and emptying its screen is the
1549
+ * failure this method exists to prevent.
1550
+ */
1551
+ stepBack(operation, session) {
1552
+ if (this.ownsAdoption(operation, session))
1553
+ return false;
1554
+ if (this.session === session)
1555
+ this.unbind();
1556
+ return true;
1557
+ }
1558
+ /**
1559
+ * Is this adoption still the one on screen? The generation says whether the
1560
+ * operation that asked for it still owns the page — every path that takes the
1561
+ * screen raises it, {@link startDraft} among them — and the session says
1562
+ * whether the binding this call took is still the one held, which a generation
1563
+ * alone cannot answer: a newer chat raises it before it has taken any name, and
1564
+ * a caller can retire the binding it holds inside its own generation.
1565
+ */
1566
+ ownsAdoption(operation, session) {
1567
+ return (!this.disposed &&
1568
+ operation === this.generation &&
1569
+ this.session === session);
1100
1570
  }
1101
1571
  unbind() {
1572
+ this.sessionReference?.release();
1573
+ this.sessionReference = undefined;
1102
1574
  this.streamPublisher.clear();
1103
1575
  this.unsubscribeSession?.();
1104
1576
  this.unsubscribeSession = undefined;
1577
+ this.unsubscribeInbox?.();
1578
+ this.unsubscribeInbox = undefined;
1105
1579
  this.unsubscribeChat?.();
1106
1580
  this.unsubscribeChat = undefined;
1107
1581
  this.conversationBinding = undefined;
@@ -1111,6 +1585,10 @@ export class QaSessionController {
1111
1585
  this.assets.release(String(this.session.sessionId));
1112
1586
  }
1113
1587
  this.session = undefined;
1588
+ // A chat that holds no session names no session: {@link bind} hands the
1589
+ // identity out only at the end of a finished adoption, and leaving a
1590
+ // binding is the hand that takes it back.
1591
+ this.namedSession = null;
1114
1592
  this.hostSources.reset();
1115
1593
  this.hostApprovals.reset();
1116
1594
  this.hostQuestions.reset();
@@ -1118,6 +1596,12 @@ export class QaSessionController {
1118
1596
  // The next binding probes the Host again even for the same chat.
1119
1597
  this.pendingProbeKey = "";
1120
1598
  this.admissionPending = false;
1599
+ // Request ids are minted per submission, so another chat's queue listing
1600
+ // says nothing about this one's submissions still crossing the transport.
1601
+ this.admittedSubmissions.clear();
1602
+ // A held-back question belongs to the chat that asked it; another binding
1603
+ // answers for its own sends.
1604
+ this.requestQueueNotice = null;
1121
1605
  this.policyReady = false;
1122
1606
  this.compatibilityReadOnly = false;
1123
1607
  // The catalog belongs to the chat that produced it: another chat has a
@@ -1137,6 +1621,11 @@ export class QaSessionController {
1137
1621
  /** Project one session notification; the running-turn spacing policy
1138
1622
  * (first frame at once, further frames absorbed) lives in the publisher. */
1139
1623
  publishSessionUpdate() {
1624
+ // Read before the spacing, from the notification itself: a frame absorbed
1625
+ // into a running turn's window is dropped rather than replayed, so an
1626
+ // admission landing in such a window would otherwise go unrecorded and the
1627
+ // claim frame after it would draw the ghost row this retires.
1628
+ this.trackAdmittedSubmissions();
1140
1629
  this.streamPublisher.publish(this.session?.getSnapshot().running === true, () => this.publish());
1141
1630
  }
1142
1631
  publish() {
@@ -1150,12 +1639,14 @@ export class QaSessionController {
1150
1639
  this.pendingSubmission !== undefined;
1151
1640
  this.state = {
1152
1641
  ...QA_SESSION_IDLE_STATE,
1642
+ chatKey: this.chatKey,
1153
1643
  phase: materializing
1154
1644
  ? "creating"
1155
1645
  : !connected && this.connectedOnce
1156
1646
  ? "reconnecting"
1157
1647
  : "idle",
1158
1648
  error: this.operationError,
1649
+ requestQueue: this.requestQueueNotice,
1159
1650
  canSend: connected && !materializing,
1160
1651
  pendingMessage: this.pendingSubmission?.message ?? null,
1161
1652
  chatsRevision: this.chatsRevision,
@@ -1168,6 +1659,7 @@ export class QaSessionController {
1168
1659
  ...this.state,
1169
1660
  phase: !connected && this.connectedOnce ? "reconnecting" : this.state.phase,
1170
1661
  error: this.operationError ?? this.state.error,
1662
+ requestQueue: this.requestQueueNotice,
1171
1663
  };
1172
1664
  this.emit();
1173
1665
  return;
@@ -1182,9 +1674,11 @@ export class QaSessionController {
1182
1674
  }
1183
1675
  this.probePending(sessionId, connected);
1184
1676
  this.syncPendingPolling(snapshot.running === true && !this.compatibilityReadOnly);
1677
+ const queuedMessages = this.queuedMessages();
1185
1678
  const projectionInput = {
1186
1679
  connected,
1187
1680
  sessionId,
1681
+ chatKey: this.chatKey,
1188
1682
  sessionSnapshot: snapshot,
1189
1683
  conversationSnapshot,
1190
1684
  sourceBundles,
@@ -1194,11 +1688,16 @@ export class QaSessionController {
1194
1688
  // carries its owner's name when an admin reads it.
1195
1689
  author: this.accounts?.messageAuthorOf(sessionId),
1196
1690
  operationError: this.operationError,
1691
+ requestQueue: this.requestQueueNotice,
1197
1692
  policyReady: this.policyReady,
1198
1693
  compatibilityReadOnly: this.compatibilityReadOnly,
1199
1694
  admissionPending: this.admissionPending || this.pendingSubmission !== undefined,
1200
1695
  chatsRevision: this.chatsRevision,
1201
1696
  viewingSubagent: this.viewingSubagent,
1697
+ queuedMessages,
1698
+ // The live set, not a copy: `projectQueue` only asks it `has` and keeps no
1699
+ // reference, so nothing here could go stale by the next frame.
1700
+ admittedSubmissions: this.admittedSubmissions,
1202
1701
  slash: this.slashView(),
1203
1702
  config: this.config,
1204
1703
  subagentNames: this.subagentNames(),
@@ -1206,11 +1705,16 @@ export class QaSessionController {
1206
1705
  let projected = projectBoundSessionState(projectionInput);
1207
1706
  const pending = this.pendingSubmission;
1208
1707
  if (pending !== undefined) {
1209
- if (snapshot.running)
1210
- pending.sawRunning = true;
1708
+ // The optimistic copy retires on a fact the transcript itself carries —
1709
+ // its own row arriving, or a turn closing — and not on the session's
1710
+ // running bit: the Session list relays that one too, and a stale false
1711
+ // at the start of a turn retired the copy before the Chat slice had
1712
+ // assembled the node, which is the frame the question vanished in.
1211
1713
  const committedUserCount = projected.messages.filter((message) => message.role === "user").length;
1212
1714
  if (committedUserCount > pending.baselineUserCount ||
1213
- (pending.accepted && pending.sawRunning && !snapshot.running)) {
1715
+ (pending.accepted &&
1716
+ chatLegacyOf(conversationSnapshot).turnEnds.size >
1717
+ pending.baselineTurnEnds)) {
1214
1718
  this.pendingSubmission = undefined;
1215
1719
  // The first projection was intentionally busy while the optimistic
1216
1720
  // row existed. Recompute once so the same notification can restore
@@ -1227,6 +1731,52 @@ export class QaSessionController {
1227
1731
  };
1228
1732
  this.emit();
1229
1733
  }
1734
+ /**
1735
+ * The messages the Host has accepted for the agent's next turn. They arrive
1736
+ * through the session's Inbox projection rather than the snapshot the queue
1737
+ * strip used to read: `rc.2` moved pending input onto the projection surface,
1738
+ * and a key the Host never published reads as no queued work.
1739
+ */
1740
+ queuedMessages() {
1741
+ const inbox = this.session?.projections.faceOf("inbox").getSnapshot();
1742
+ return inbox?.["next-turn"] ?? [];
1743
+ }
1744
+ /**
1745
+ * Record the submissions this binding has seen the Host name — the queue rows
1746
+ * it holds and the durable input rows it has written. Naming the message is the
1747
+ * server's own receipt for it, so from that notification on the row belongs to
1748
+ * the chat, not to the transport: an echo the claim leaves behind must not read
1749
+ * again as a question still crossing. The scan is only paid for while some
1750
+ * queued echo still waits for its name.
1751
+ *
1752
+ * Both lists are read because neither is reliable alone, and the transcript is
1753
+ * the durable one: a send admitted and claimed between two notifications never
1754
+ * appears in a queue frame this browser is handed, but its row stays in the
1755
+ * transcript, so the next frame names it. And the receipt is never dropped
1756
+ * while the binding lives — an id the server has named belongs to the submission
1757
+ * that minted it (`beginSubmission` returns a fresh `randomUUID`), so no later
1758
+ * send can need the row this hides, whereas forgetting the receipt re-draws the
1759
+ * ghost the moment the snapshot registers the echo again.
1760
+ *
1761
+ * Read from the notification, not from the projected frame: the projection of a
1762
+ * running turn is spaced, and an absorbed frame is dropped rather than
1763
+ * replayed, so spacing must not decide whether this browser ever saw the name.
1764
+ */
1765
+ trackAdmittedSubmissions() {
1766
+ const snapshot = this.session?.getSnapshot();
1767
+ if (snapshot === undefined)
1768
+ return;
1769
+ // Only an echo still waiting can draw a row, so a chat whose queued sends are
1770
+ // all settled costs no scan of the transcript — and a browser registers the
1771
+ // echo before it sends, so an id with no echo there has nothing to settle.
1772
+ const unsettled = snapshot.pendingSubmissions.some((item) => item.placement === "queued" &&
1773
+ !this.admittedSubmissions.has(String(item.requestId)));
1774
+ if (!unsettled)
1775
+ return;
1776
+ for (const requestId of hostNamedSubmissionIds(this.queuedMessages(), this.conversationBinding?.snapshot.getSnapshot())) {
1777
+ this.admittedSubmissions.add(requestId);
1778
+ }
1779
+ }
1230
1780
  /**
1231
1781
  * The directory the transcript's source paths are anchored on: the chat's
1232
1782
  * own cwd, never the configured pin while the chat is known.
@@ -1266,7 +1816,7 @@ export class QaSessionController {
1266
1816
  const names = {};
1267
1817
  const list = this.sessions.list.getSnapshot();
1268
1818
  const chats = this.visibleChatIds();
1269
- const candidates = visibleSubagentCandidates(list.byId ?? {}, (list.subagentsByParent ?? {}), chats);
1819
+ const candidates = visibleSubagentCandidates(list.byId ?? {}, chats);
1270
1820
  for (const candidate of candidates) {
1271
1821
  const name = readableSubagentName(candidate.label, candidate.id);
1272
1822
  if (name !== undefined)
@@ -1284,6 +1834,14 @@ export class QaSessionController {
1284
1834
  return undefined;
1285
1835
  return new Set(this.accounts.ownedIds());
1286
1836
  }
1837
+ /**
1838
+ * Tell the user that a submission they pressed did not go. The composer keeps
1839
+ * its text through a refused send, so silence here would read as success.
1840
+ */
1841
+ refuseSend(message) {
1842
+ this.operationError = message;
1843
+ this.publish();
1844
+ }
1287
1845
  fail(message, error) {
1288
1846
  // Policy attestation refusals already logged their precise reason; a
1289
1847
  // second stack trace for the wrapper error is only console noise.
@@ -1295,6 +1853,7 @@ export class QaSessionController {
1295
1853
  this.operationError = message;
1296
1854
  this.state = {
1297
1855
  ...QA_SESSION_IDLE_STATE,
1856
+ chatKey: this.chatKey,
1298
1857
  phase: "error",
1299
1858
  error: message,
1300
1859
  chatsRevision: this.chatsRevision,
@@ -1302,20 +1861,24 @@ export class QaSessionController {
1302
1861
  this.emit();
1303
1862
  }
1304
1863
  /**
1305
- * Attest the currently bound session. `ok` carries the session id the proof
1306
- * was issued for; `stale` means the binding changed while the request was
1307
- * in flight, so nothing was written — the newer binding manages its own
1308
- * attestation; `refused` is a genuine admission refusal of this binding.
1864
+ * Attest `session`, which defaults to the one currently bound. `ok` carries
1865
+ * the session id the proof was issued for; `stale` means the binding changed
1866
+ * while the request was in flight, so nothing was written — the newer binding
1867
+ * manages its own attestation; `refused` is a genuine admission refusal of
1868
+ * this binding.
1309
1869
  */
1310
- async attestPolicy(reportFailure = true) {
1311
- const session = this.session;
1870
+ async attestPolicy(reportFailure = true, session = this.session) {
1312
1871
  if (session === undefined)
1313
1872
  return { kind: "refused", reason: null };
1314
1873
  const sessionId = String(session.sessionId);
1315
- if (!this.config.lockdown.enabled) {
1874
+ if (!this.config.lockdown.enabled && this.accounts === undefined) {
1316
1875
  this.policyReady = true;
1317
1876
  return { kind: "ok", sessionId };
1318
1877
  }
1878
+ // With lockdown off there is no policy to pin, but the Host call still
1879
+ // runs: account identity and ownership are admitted there independently of
1880
+ // lockdown, and skipping the call is what let a browser that restored
1881
+ // another account's chat write into it without ever being asked who spoke.
1319
1882
  this.policyReady = false;
1320
1883
  this.publish();
1321
1884
  const outcome = await attestQaPolicy({