@jini-ai/chat 0.5.3 → 0.5.5

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 (138) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/core/ag-ui/entry.d.ts +33 -0
  3. package/dist/core/ag-ui/entry.js +80 -0
  4. package/dist/core/agentic/chat-capabilities.d.ts +2 -4
  5. package/dist/core/agentic/index.d.ts +4 -17
  6. package/dist/core/agentic/index.js +4 -17
  7. package/dist/core/assistant-client/a2ui-action-poster.d.ts +46 -0
  8. package/dist/core/assistant-client/a2ui-action-poster.js +113 -0
  9. package/dist/core/assistant-client/ag-ui-transport.d.ts +68 -0
  10. package/dist/core/assistant-client/ag-ui-transport.js +147 -0
  11. package/dist/core/assistant-client/attachment-liveness.d.ts +37 -0
  12. package/dist/core/assistant-client/attachment-liveness.js +93 -0
  13. package/dist/core/assistant-client/durable-run-subscription.d.ts +39 -0
  14. package/dist/core/assistant-client/durable-run-subscription.js +96 -0
  15. package/dist/core/assistant-client/entry.d.ts +13 -0
  16. package/dist/core/assistant-client/entry.js +7 -0
  17. package/dist/core/assistant-client/messages.d.ts +73 -0
  18. package/dist/core/assistant-client/messages.js +68 -0
  19. package/dist/core/assistant-client/transport.d.ts +164 -0
  20. package/dist/core/assistant-client/transport.js +734 -0
  21. package/dist/core/browser/page-actions.d.ts +121 -0
  22. package/dist/core/browser/page-actions.js +113 -0
  23. package/dist/core/composer-history.d.ts +54 -0
  24. package/dist/core/composer-history.js +57 -0
  25. package/dist/core/durable-projection.d.ts +28 -0
  26. package/dist/core/durable-projection.js +71 -0
  27. package/dist/core/durable-runs/continuation.d.ts +18 -0
  28. package/dist/core/durable-runs/continuation.js +61 -0
  29. package/dist/core/durable-runs/ledger-contracts.d.ts +53 -0
  30. package/dist/core/durable-runs/ledger-contracts.js +2 -0
  31. package/dist/core/durable-runs/policies.d.ts +6 -0
  32. package/dist/core/durable-runs/policies.js +6 -0
  33. package/dist/core/durable-runs/ports.d.ts +123 -0
  34. package/dist/core/durable-runs/ports.js +2 -0
  35. package/dist/core/durable-runs/run-contracts.d.ts +17 -0
  36. package/dist/core/durable-runs/run-contracts.js +2 -0
  37. package/dist/core/durable-runs/run-lock.d.ts +4 -0
  38. package/dist/core/durable-runs/run-lock.js +4 -0
  39. package/dist/core/index.d.ts +9 -19
  40. package/dist/core/index.js +8 -19
  41. package/dist/core/messages.d.ts +2 -0
  42. package/dist/core/question-form/format.d.ts +0 -9
  43. package/dist/core/question-form/format.js +16 -2
  44. package/dist/core/question-form/normalize.js +1 -0
  45. package/dist/core/question-form/secret-answers.d.ts +38 -0
  46. package/dist/core/question-form/secret-answers.js +45 -0
  47. package/dist/core/question-form/types.d.ts +2 -0
  48. package/dist/core/question-form.d.ts +1 -0
  49. package/dist/core/question-form.js +1 -0
  50. package/dist/core/run-events/entry.d.ts +208 -16
  51. package/dist/core/run-events/entry.js +273 -39
  52. package/dist/core/transcript.d.ts +3 -2
  53. package/dist/core/transcript.js +19 -3
  54. package/dist/core/transport.d.ts +4 -0
  55. package/dist/core/user-text-redaction.d.ts +56 -0
  56. package/dist/core/user-text-redaction.js +89 -0
  57. package/dist/embed-widget/browser/page-actions.d.ts +1 -120
  58. package/dist/embed-widget/browser/page-actions.js +2 -112
  59. package/dist/embed-widget/react/embed.d.ts +1 -1
  60. package/dist/embed-widget/react/embed.js +1 -1
  61. package/dist/react/artifact-types.d.ts +9 -15
  62. package/dist/react/components/Composer.js +6 -0
  63. package/dist/react/components/MessageRow.js +24 -11
  64. package/dist/react/components/QuestionForm.d.ts +2 -1
  65. package/dist/react/components/QuestionForm.js +18 -12
  66. package/dist/react/components/QuestionsPanel.d.ts +3 -2
  67. package/dist/react/components/QuestionsPanel.js +2 -2
  68. package/dist/react/features/chat-pane/agent-tools.d.ts +1 -1
  69. package/dist/react/features/chat-pane/agent-tools.js +0 -1
  70. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +5 -2
  71. package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
  72. package/dist/react/features/chat-pane/components/ChatPane.js +9 -4
  73. package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +2 -1
  74. package/dist/react/features/chat-pane/create-typed-answer-poster.js +33 -1
  75. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +3 -2
  76. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +7 -1
  77. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +38 -23
  78. package/dist/react/features/chat-pane/model-options.d.ts +10 -0
  79. package/dist/react/features/chat-pane/model-options.js +51 -0
  80. package/dist/react/features/chat-pane/rules.d.ts +3 -2
  81. package/dist/react/features/chat-pane/rules.js +12 -4
  82. package/dist/react/features/chat-pane/types.d.ts +30 -1
  83. package/dist/react/hooks/assistant-chats-contracts.d.ts +164 -0
  84. package/dist/react/hooks/assistant-chats-contracts.js +2 -0
  85. package/dist/react/hooks/assistant-chats.d.ts +7 -0
  86. package/dist/react/hooks/assistant-chats.js +960 -0
  87. package/dist/react/hooks/attachment-preview-cache.d.ts +1 -1
  88. package/dist/react/hooks/attachment-preview-cache.js +1 -1
  89. package/dist/react/hooks/composer-draft-cache.d.ts +2 -2
  90. package/dist/react/hooks/composer-draft-cache.js +2 -2
  91. package/dist/react/hooks/composer-history-storage.d.ts +8 -0
  92. package/dist/react/hooks/composer-history-storage.js +40 -0
  93. package/dist/react/hooks/fab-position.d.ts +90 -0
  94. package/dist/react/hooks/fab-position.js +271 -0
  95. package/dist/react/hooks/useComposer.d.ts +10 -1
  96. package/dist/react/hooks/useComposer.js +14 -2
  97. package/dist/react/hooks/useComposerHistory.hooks.d.ts +15 -0
  98. package/dist/react/hooks/useComposerHistory.hooks.js +90 -0
  99. package/dist/react/hooks/useComposerHistoryKeys.hooks.d.ts +9 -0
  100. package/dist/react/hooks/useComposerHistoryKeys.hooks.js +90 -0
  101. package/dist/react/hooks/useConversation.d.ts +4 -1
  102. package/dist/react/hooks/useConversation.js +20 -14
  103. package/dist/react/hooks/useRunStream.js +12 -2
  104. package/dist/react/hooks/useUserTextGuard.d.ts +11 -0
  105. package/dist/react/hooks/useUserTextGuard.js +19 -0
  106. package/dist/react/index.d.ts +14 -21
  107. package/dist/react/index.js +8 -0
  108. package/dist/server/durable-runs/accept.d.ts +25 -0
  109. package/dist/server/durable-runs/accept.js +21 -0
  110. package/dist/server/durable-runs/entry.d.ts +5 -0
  111. package/dist/server/durable-runs/entry.js +4 -0
  112. package/dist/server/durable-runs/recover.d.ts +5 -0
  113. package/dist/server/durable-runs/recover.js +147 -0
  114. package/dist/server/run-finalizer/ports.d.ts +2 -16
  115. package/dist/store/legacy/sqlite/messages.d.ts +24 -18
  116. package/dist/store/legacy/sqlite/messages.js +12 -2
  117. package/dist/store/pglite/index.d.ts +2 -1
  118. package/dist/store/postgres/index.d.ts +2 -1
  119. package/dist/store/runtime/chat-expiry-sweep.d.ts +40 -0
  120. package/dist/store/runtime/chat-expiry-sweep.js +70 -0
  121. package/dist/store/runtime/chat-history-store.d.ts +20 -0
  122. package/dist/store/runtime/chat-history-store.js +50 -0
  123. package/dist/store/runtime/durable-run-store.d.ts +21 -0
  124. package/dist/store/runtime/durable-run-store.js +342 -0
  125. package/dist/store/runtime/entry.d.ts +7 -0
  126. package/dist/store/runtime/entry.js +6 -0
  127. package/dist/store/runtime/kernel.d.ts +29 -0
  128. package/dist/store/runtime/kernel.js +2 -0
  129. package/dist/store/runtime/run-guarded-chat-store.d.ts +10 -0
  130. package/dist/store/runtime/run-guarded-chat-store.js +42 -0
  131. package/dist/store/runtime/run-ledger.d.ts +15 -0
  132. package/dist/store/runtime/run-ledger.js +206 -0
  133. package/dist/store/sql/boundary.d.ts +6 -0
  134. package/dist/store/sql/boundary.js +7 -1
  135. package/dist/store/sql/store.d.ts +3 -2
  136. package/dist/store/sql/store.js +9 -2
  137. package/dist/store/sqlite/index.d.ts +3 -2
  138. package/package.json +75 -10
@@ -0,0 +1,960 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { activeRunStub, messageWriteKey, persistableMessages } from "../../core/assistant-client/messages.js";
3
+ /** Bind effects once; the returned hook captures its conversation port at mount.
4
+ * Existing race guards and retry ordering remain in the original lexical lifecycle. */
5
+ export function createAssistantChatsClient({ ports }, _options = {}) {
6
+ /**
7
+ * @file Owns the admin assistant's conversation state: which chat is open, what is in it, and what
8
+ * has already been written.
9
+ *
10
+ * `ChatPane` is deliberately left as the uncontrolled component it already is — it takes
11
+ * `initialMessages` and a `conversationId` and manages the live transcript itself. This hook only
12
+ * supplies those two props and listens to `onMessagesChange`, which is why switching conversations
13
+ * remounts the pane via its `key` rather than trying to push new messages into a running one.
14
+ */
15
+ /**
16
+ * Backoff before each re-attempt of a failed message write — three retries over ~13s.
17
+ *
18
+ * The gap this closes: `flush` used to un-mark a failed message so it could be retried, and then
19
+ * nothing ever retried it. Retrying is driven by `onMessagesChange`, which fires on deltas — and
20
+ * the message most likely to fail is the *final* assistant reply, after which no further delta
21
+ * arrives. A single transient 503 on that write plus a reload and the reply is gone from durable
22
+ * history while still sitting on screen, which is the worst shape this failure can take: the user
23
+ * has every reason to believe it was saved.
24
+ *
25
+ * Short and bounded on purpose. This is a foreground write for a transcript the user is looking
26
+ * at, not a background job; if the server is still refusing after ~13s the honest outcome is to
27
+ * stop and leave the id un-marked for a later delta, not to queue writes indefinitely.
28
+ */
29
+ const SAVE_RETRY_DELAYS_MS = [1_000, 3_000, 9_000];
30
+ /**
31
+ * Whether a failed write is worth attempting again.
32
+ *
33
+ * A rejected `fetch` carries no status at all — offline, DNS, connection reset, the server
34
+ * restarting mid-request — and is exactly the case retrying exists for. A response we actually
35
+ * received is retryable when the server said "not now" (429, any 5xx) or "not there yet" (404).
36
+ *
37
+ * **404 belongs here, and leaving it out was a real bug.** It was excluded from `isPermanent` on
38
+ * the correct reasoning that a 404 is reachable from a benign race — but it then matched neither
39
+ * predicate, so `saveWithRetry` returned `"exhausted"` on the FIRST failure with no backoff at all,
40
+ * `flush` un-marked it, and the next delta re-sent it immediately. That is the retry storm the
41
+ * classification exists to prevent, just running at delta cadence instead of in a tight loop.
42
+ * Treated as transient it consumes the bounded ladder and is only un-marked once, after ~13s.
43
+ */
44
+ function isTransient(error) {
45
+ const status = ports.statusOf({ error }, {});
46
+ if (status !== undefined) {
47
+ return status === 404 || status === 429 || status >= 500;
48
+ }
49
+ return true;
50
+ }
51
+ /**
52
+ * Whether a failure means "this write can never succeed", which is a stronger and far more
53
+ * dangerous claim than "this attempt failed" — a message classified permanent is kept marked as
54
+ * written, so it is never retried and never persisted.
55
+ *
56
+ * Deliberately narrower than "not transient", and **404 is the exclusion that matters**. A 404 here
57
+ * means the conversation was not found *for this caller*, which is genuinely reachable from a race
58
+ * rather than from a malformed request: delete a conversation while a write is in flight and the
59
+ * write 404s for a message that was perfectly valid. Treating that as permanent turns a lost race
60
+ * into permanently lost data. Left retryable, it gets one more attempt from a later delta, and if
61
+ * the conversation really is gone the pane has already switched away so no further delta names it.
62
+ *
63
+ * What remains permanent is the class that says the request itself is wrong — 400, 403, 422 — where
64
+ * re-sending identical bytes is guaranteed to fail and retrying only produces a request storm.
65
+ */
66
+ function isPermanent(error) {
67
+ const status = ports.statusOf({ error }, {});
68
+ if (status === undefined)
69
+ return false;
70
+ return status >= 400 && status < 500 && status !== 404 && status !== 429;
71
+ }
72
+ /** The give-up classification for a transient failure that has run out of retries (out of attempts,
73
+ * or torn down mid-backoff) — a 404 specifically gets `"missing"` rather than `"exhausted"`; see
74
+ * `SaveOutcome`'s own doc for why that distinction is worth reporting separately. A top-level
75
+ * function rather than a closure inside {@link attemptSave} so its own `instanceof`/`&&` check is
76
+ * scored in its own (near-zero) scope instead of adding to that function's. */
77
+ function giveUpOutcome(error) {
78
+ return ports.statusOf({ error }, {}) === 404 ? "missing" : "exhausted";
79
+ }
80
+ /**
81
+ * Classifies one write failure and decides this attempt's outcome — the body of `attemptSave`'s
82
+ * `catch` block, pulled out as its own function (2026-08-06, complexity pass, sixth pass) so its
83
+ * four decisions are scored at nesting depth 0 instead of one level deep inside a `catch`. Cognitive
84
+ * complexity charges extra for every structure nested inside another; a `catch` block is itself one
85
+ * level of nesting, and every `if` these decisions need was paying that penalty on top of its own
86
+ * cost. Moving the whole block to a sibling function resets the depth without changing which
87
+ * decision runs when — `attemptSave`'s `catch` still runs synchronously into this on every failure.
88
+ *
89
+ * Each of the five outcomes {@link SaveOutcome}'s own doc names stays individually reachable and
90
+ * individually distinguishable here, unchanged from the inline version: the one-line `"permanent"`
91
+ * return, the one-line plain `"exhausted"` return, and the two give-up sites (out of attempts vs.
92
+ * torn down mid-backoff) sharing {@link giveUpOutcome}'s classification. `"saved"` is handled in
93
+ * `attemptSave`'s own `try` block, never reaching here.
94
+ *
95
+ * @param isDisposed checked before and after the sleep — an unmounted dock must not still be
96
+ * writing minutes later.
97
+ */
98
+ async function handleSaveFailure(error, conversationId, message, attempt, isDisposed) {
99
+ if (isPermanent(error)) {
100
+ // The one outcome that discards a message the user can still see on screen, so it does not get
101
+ // to be silent. There is no error surface in the dock to route this to yet; a console error is
102
+ // the honest minimum, and is what makes the drop findable rather than mysterious.
103
+ ports.reportPermanent({
104
+ conversationId,
105
+ messageId: message.id,
106
+ error,
107
+ }, {});
108
+ return { done: true, outcome: "permanent" };
109
+ }
110
+ if (!isTransient(error))
111
+ return { done: true, outcome: "exhausted" };
112
+ const delay = SAVE_RETRY_DELAYS_MS[attempt];
113
+ // Out of attempts, or torn down mid-backoff. Both are "might work later", not "never will" — but
114
+ // a ladder spent entirely on 404s says something more specific, so report that.
115
+ if (delay === undefined || isDisposed())
116
+ return { done: true, outcome: giveUpOutcome(error) };
117
+ await ports.sleep({ delayMs: delay }, {});
118
+ if (isDisposed())
119
+ return { done: true, outcome: giveUpOutcome(error) };
120
+ return { done: false };
121
+ }
122
+ /**
123
+ * One attempt at writing `message`: the try/catch body `saveWithRetry`'s loop used to hold inline,
124
+ * pulled out (2026-08-06, complexity pass, fifth pass — an earlier pass at this same function left
125
+ * it as a documented exemption; this extraction disproves that exemption, see below) as its own
126
+ * async function so the loop itself only has to ask "is this attempt done, and with what?". The
127
+ * failure-classification decisions themselves live in {@link handleSaveFailure}.
128
+ *
129
+ * @param isDisposed checked before and after the sleep — an unmounted dock must not still be
130
+ * writing minutes later. Never rejects, so `saveWithRetry`'s loop can await it unconditionally.
131
+ */
132
+ async function attemptSave(port, conversationId, message, attempt, isDisposed) {
133
+ try {
134
+ await port.saveMessage({ conversationId, message }, {});
135
+ return { done: true, outcome: "saved" };
136
+ }
137
+ catch (error) {
138
+ return handleSaveFailure(error, conversationId, message, attempt, isDisposed);
139
+ }
140
+ }
141
+ /**
142
+ * Writes one message, re-attempting transient failures on {@link SAVE_RETRY_DELAYS_MS} via
143
+ * {@link attemptSave}.
144
+ *
145
+ * Never rejects, so a caller can treat the result as data rather than wrapping every call.
146
+ */
147
+ async function saveWithRetry(port, conversationId, message, isDisposed) {
148
+ for (let attempt = 0;; attempt += 1) {
149
+ const result = await attemptSave(port, conversationId, message, attempt, isDisposed);
150
+ if (result.done)
151
+ return result.outcome;
152
+ }
153
+ }
154
+ /**
155
+ * Decides what a settled batch of message-save outcomes means for `flush`'s bookkeeping: which
156
+ * write keys (`messageWriteKey`) should be released back to `written` for a future delta to retry, and whether a
157
+ * fresh conversation-list read is warranted. Pulled out of `flush`'s `Promise.all(...).then(...)`
158
+ * continuation (2026-08-06, complexity pass) so this decision is directly assertable with a plain
159
+ * array of outcomes — no port, no timers, no React state.
160
+ *
161
+ * `shouldRefresh` composes the original `if (some saved) … else if (isConversationStillActive &&
162
+ * some missing) …` as one OR: the "saved" branch fires unconditionally, so ORing it with the
163
+ * "missing" branch (itself gated on `isConversationStillActive`) reproduces the original
164
+ * if/else-if exactly — the else-if only ever mattered when the first condition was already false.
165
+ *
166
+ * @param isConversationStillActive - `activeIdRef.current === conversationId` at the moment the
167
+ * batch settled — a `"missing"` outcome only warrants a refresh while the pane this write
168
+ * belonged to is still the one on screen; see `flush`'s own call site for why.
169
+ */
170
+ function summarizeFlushOutcomes(results, isConversationStillActive) {
171
+ const idsToRelease = results
172
+ .filter((result) => result.outcome === "exhausted" || result.outcome === "missing")
173
+ .map((result) => messageWriteKey({ message: result.message }, {}));
174
+ const shouldRefresh = results.some((result) => result.outcome === "saved") ||
175
+ (isConversationStillActive && results.some((result) => result.outcome === "missing"));
176
+ return { idsToRelease, shouldRefresh };
177
+ }
178
+ /**
179
+ * What the mount-time list read says about the conversation this browser last had open.
180
+ *
181
+ * - `"restore"` — it is still in this caller's list: land on it.
182
+ * - `"forget"` — the list loaded and it is not there (deleted, or not this caller's): clear the
183
+ * remembered id so the next load does not keep chasing it, and stay on today's empty new chat.
184
+ * - `"keep"` — nothing was remembered, or the list itself failed. A failed read says nothing
185
+ * about whether the conversation still exists, so a network blip must not erase the memory.
186
+ *
187
+ * @complexity Time O(n) in the number of listed conversations; space O(1).
188
+ */
189
+ function resolveRememberedConversation({ remembered, conversations, listFailed }) {
190
+ if (remembered === null || listFailed)
191
+ return "keep";
192
+ return conversations.some((conversation) => conversation.id === remembered) ? "restore" : "forget";
193
+ }
194
+ /**
195
+ * Clears `store` only while it still holds `remembered`. A chat the operator opened while the list
196
+ * read was in flight has already been written over it, and must not be forgotten in its place.
197
+ * @complexity Time/space: O(1).
198
+ */
199
+ function forgetRemembered(store, remembered) {
200
+ if (store.read() === remembered)
201
+ store.clear();
202
+ }
203
+ /**
204
+ * @param port every call this hook makes off its own island. Injected rather than imported so a
205
+ * test can describe conversation state and write failures directly — see
206
+ * `createFakeAssistantChatsPort`. Referential stability is NOT required; see `portRef`.
207
+ *
208
+ * @complexityExemption (2026-08-06, complexity pass, second pass; bar raised to ≤9/≤9 same day,
209
+ * exemption reconfirmed against the new bar) **Score: this hook's own lexical scope is 1
210
+ * cyclomatic / ~0 cognitive under ESLint — every closure inside it is independently ≤9/≤9 too
211
+ * (`select`'s inner `commit` is 2/1, `remove` is 6/3, `rename` is 4/2, `flush` is 4/2,
212
+ * `ensureConversationId` is 3/1 — remeasured 2026-09-09, when adoption moved out of
213
+ * `onMessagesChange` into `ensureConversationId`; see this file's own before/after table in the
214
+ * session report for the original figures. `onMessagesChange` itself grew again on 2026-09-11 — the
215
+ * reattach investigation's active-run stub write, `persistRunStub` — and is no longer independently
216
+ * re-measured by hand here; `development/scripts/check-admin-complexity-drift.ts` is the actual gate
217
+ * and reported zero new violations in this file after that change, which is the claim worth trusting
218
+ * over a hand-counted number that will drift out of date again the next time this function changes).
219
+ * What
220
+ * is exempted here is a DIFFERENT, unmeasurable-by-me number: the dispatch brief's owner-tool score
221
+ * of 19/24, which rolls every nested closure's branches into the hook's total. Bar: ≤9/≤9 on
222
+ * whichever view is scored — ESLint's view already clears it; the owner-tool aggregate does not,
223
+ * and I have no local tool that reproduces that aggregate to re-measure against the new bar.** This pass
224
+ * already pulled every closure that could become a genuinely top-level PURE function out of hook
225
+ * bodies across this scope (`saveWithRetry`, `summarizeFlushOutcomes`, `consumeByokStream`,
226
+ * `buildLocalCliContextRef`, `loadExecutionConfig`'s ledger mappers, `upsertMessage`) — each of
227
+ * those needed 0–2 plain parameters. `select`/`create`/`remove`/`rename`/`flush`/
228
+ * `ensureConversationId`/`onMessagesChange` below do not fit that shape: each reads and writes between 3 and 9 of this
229
+ * hook's own refs (`portRef`, `activeIdRef`, `writtenRef`, `switchSeqRef`, `adoptingRef`,
230
+ * `adoptionGenRef`, `paneNonceRef`, `disposedRef`, `conversationsRef`, `listSeqRef`,
231
+ * `pendingRenamesRef`, `renameVersionRef`) AND calls several of this hook's OTHER closures
232
+ * (`resetAdoption`, `commitActiveId`, `refresh`, `select` itself), so extracting any one of them to
233
+ * a top-level function means threading the rest through as an ad-hoc context parameter — turning a
234
+ * compiler-checked lexical capture into a runtime object every call site has to assemble correctly
235
+ * by hand. That is not a smaller change than what this pass already made elsewhere; it is a
236
+ * different, far riskier one: every one of the ~12 numbered race conditions documented inline in
237
+ * this file (see `portRef`'s, `commitActiveId`'s, `select`'s, and `remove`'s own doc comments) was
238
+ * fixed by controlling EXACTLY when a specific ref is read relative to a specific React commit, and
239
+ * the fix is stated in terms of "this closure, at this point in its own body" — re-deriving each of
240
+ * those orderings through a threaded-params version, under this pass's own time budget, is how a
241
+ * metric improves and one of those bugs quietly comes back. `use-settings-slice.hooks.ts`'s own
242
+ * history is the cited precedent (brief §9): a previous attempt at exactly this kind of extraction
243
+ * left that file's total unchanged (25 → 25). Declined for this pass; flagged to the coordinator
244
+ * rather than attempted under time pressure. See this session's report for the full reasoning.
245
+ */
246
+ function useAssistantChats(port, { lastConversation } = {}) {
247
+ /**
248
+ * The port, read through a ref so its identity is not a dependency of anything.
249
+ *
250
+ * The obvious alternative — listing `port` in each `useCallback` below — makes referential
251
+ * stability a load-bearing, unenforceable contract on every caller. It is unenforceable because
252
+ * the natural way to write the injection is the broken one:
253
+ * `useChats={() => useAssistantChats(createFakeAssistantChatsPort())}` builds a new port per
254
+ * render, so `refresh` changes identity, its effect re-runs, `setConversations` re-renders, and
255
+ * it spins forever. Documenting "must be stable" only converts that into a trap with a footnote.
256
+ *
257
+ * A ref removes the contract instead of restating it: reads always see the latest port, and no
258
+ * callback identity depends on it. Nothing here wants to *react* to a port swap — a new port does
259
+ * not mean new conversation state — so there is no behaviour to lose.
260
+ *
261
+ * Captured ONCE at mount and never written again — not during render, and not in an effect.
262
+ *
263
+ * Both of the obvious spellings leak. A render-phase `portRef.current = port` publishes a value
264
+ * from a render React may still discard (a `startTransition`, a suspended tree), so an event from
265
+ * the still-committed old tree calls through a port that never committed. Moving it to an effect
266
+ * fixes that and opens a different hole: React runs a CHILD's effects before its parent's in the
267
+ * same commit, and `ChatPane` is a child of this hook's caller — so a delta forwarded from
268
+ * `ChatPane`'s own effect, in the commit where `port` changed, reaches `flush` before this hook's
269
+ * sync effect has run and reads the OUTGOING port. `useLayoutEffect` does not help: layout effects
270
+ * are also child-first. The two spellings trade one gap for the other.
271
+ *
272
+ * So do not track the swap at all. Nothing here wants to react to one — a new port does not mean
273
+ * new conversation state — and "permanently fixed at mount" is an honest contract where
274
+ * "eventually consistent, with a window" is a latent bug. A caller that genuinely needs a
275
+ * different backend should remount (key the calling component on whatever identifies it) rather
276
+ * than swap the prop under a live instance.
277
+ */
278
+ const portRef = useRef(port);
279
+ /** Captured once at mount for the same reason as {@link portRef}: a new store is a new identity,
280
+ * which means a remount, never a swap under a live instance. */
281
+ const lastConversationRef = useRef(lastConversation);
282
+ const [conversations, setConversations] = useState([]);
283
+ const [activeId, setActiveId] = useState(null);
284
+ const [paneKey, setPaneKey] = useState("new");
285
+ const [initialMessages, setInitialMessages] = useState([]);
286
+ /**
287
+ * Write keys (message id plus run id, see `messageWriteKey`) already written, keyed by conversation.
288
+ *
289
+ * Without this, every `onMessagesChange` would re-`PUT` the entire settled transcript — the
290
+ * callback fires on each delta, and the settled prefix only grows. Keyed by conversation so
291
+ * switching away and back does not resurrect stale ids.
292
+ */
293
+ const writtenRef = useRef(new Map());
294
+ /** Dedup keys include scheduled writes; dispatch must await their actual completion. */
295
+ const messagePendingRef = useRef(new Map());
296
+ /**
297
+ * Write keys (message id plus run id) whose active-run stub has already been written, keyed by conversation — see
298
+ * {@link persistRunStub}'s own doc.
299
+ *
300
+ * Deliberately a SEPARATE map from {@link writtenRef}, not a shared one. `writtenRef` means "this
301
+ * exact settled payload is durable (or a write for it is in flight)"; this one means only "the
302
+ * early, non-terminal capture of this id's runId already went out". Sharing one set would make the
303
+ * stub write (empty/partial content, `runStatus: 'queued'`/`'running'`) permanently satisfy
304
+ * `flush`'s own dedup check for that id — the real terminal write, with the actual reply, would
305
+ * then never go out at all.
306
+ */
307
+ const runStubWrittenRef = useRef(new Map());
308
+ /** Terminal writes wait for their earlier stub, so a slow stub cannot overwrite the answer. */
309
+ const runStubPendingRef = useRef(new Map());
310
+ const activeIdRef = useRef(null);
311
+ /**
312
+ * Monotonic token identifying the most recent switch intent.
313
+ *
314
+ * `activeId` cannot serve as the staleness guard any more, because `select` now resolves the
315
+ * transcript *before* publishing the id (see below) — at the moment a load returns, `activeId`
316
+ * still names the previous conversation. Every switch-initiating path bumps this and then
317
+ * verifies it is still the newest before committing.
318
+ */
319
+ const switchSeqRef = useRef(0);
320
+ /** In-flight lazy conversation creation, so concurrent deltas adopt one conversation, not many. */
321
+ const adoptingRef = useRef(null);
322
+ /**
323
+ * Bumped by {@link resetAdoption}. An adoption captures it and re-checks before publishing.
324
+ *
325
+ * `switchSeqRef` cannot do this job, and the version that tried was wrong in a way only a
326
+ * specific ordering exposes: `select(B)` bumps the switch token *at call time*, so an
327
+ * `onMessagesChange` arriving from the outgoing pane a moment later reads the ALREADY-bumped
328
+ * value. B's load then commits (its own token still current), and the adoption resolves with a
329
+ * token that still matches — so it published `activeId = A` while `paneKey` and `initialMessages`
330
+ * were B's. The pane showed B's transcript and wrote it into A. Clearing `adoptingRef` in that
331
+ * commit does not help either: it drops the reference, not the promise that is already running.
332
+ *
333
+ * So invalidation gets its own counter, bumped by the one function whose entire job is "this
334
+ * adoption no longer belongs to anything".
335
+ */
336
+ const adoptionGenRef = useRef(0);
337
+ /**
338
+ * Makes each "fresh pane" key distinct.
339
+ *
340
+ * Resetting to a constant `"new"` is not enough: a pane that lazily adopted a conversation still
341
+ * has `paneKey === "new"`, so deleting that conversation would set the key to the value it already
342
+ * has, skip the remount, and leave the deleted chat's transcript sitting on screen.
343
+ */
344
+ const paneNonceRef = useRef(0);
345
+ /** Stops a sleeping write retry from firing against a dock that is no longer mounted. */
346
+ const disposedRef = useRef(false);
347
+ useEffect(() => {
348
+ // Reset on mount rather than only setting on unmount. StrictMode runs mount → unmount → mount
349
+ // in development, so a cleanup-only version would latch `true` on the throwaway first pass and
350
+ // leave every retry disabled for the real one — a bug that exists only in dev, which is the
351
+ // worst place for it to hide.
352
+ disposedRef.current = false;
353
+ return () => {
354
+ disposedRef.current = true;
355
+ };
356
+ }, []);
357
+ /**
358
+ * Bumped by every local mutation of the conversation list, so an older server snapshot cannot
359
+ * overwrite it.
360
+ *
361
+ * The race without it: the mount `refresh()` is in flight and will resolve to `[]`. Before it
362
+ * lands, the user creates (or lazily adopts) conversation A, which is inserted optimistically.
363
+ * The stale response then arrives and replaces the list with `[]` — `activeId` still points at A,
364
+ * but the switcher no longer lists it and the header falls back to the default title, so a real
365
+ * conversation is invisible until some later write happens to refresh again.
366
+ */
367
+ const listSeqRef = useRef(0);
368
+ /**
369
+ * Titles whose PATCH is still in flight, by conversation id, each tagged with the rename that
370
+ * queued it. Applied over every list read until that rename settles — see `refresh`.
371
+ *
372
+ * The version tag is what lets a settling rename clear only its OWN entry. A plain `delete(id)`
373
+ * would drop a second, newer rename of the same conversation that queued while the first was in
374
+ * flight, reopening the exact window this exists to close.
375
+ */
376
+ const pendingRenamesRef = useRef(new Map());
377
+ const renameVersionRef = useRef(0);
378
+ /**
379
+ * Mirrors the last `conversations` state this hook actually committed.
380
+ *
381
+ * The one consumer is `remove()`'s fallback, for exactly the case its own `refresh()` comes back
382
+ * stale (a faster, concurrent mutation resolved first and is already reflected in state). Reading
383
+ * `conversations` itself is not an option: the closure `remove` was built from can be arbitrarily
384
+ * many renders behind by the time its `await`s resolve, and adding it as a dependency would just
385
+ * reintroduce the identity churn `portRef`/`activeIdRef` exist to avoid.
386
+ *
387
+ * A plain `useEffect` is the right tool here, unlike for `portRef`/`activeIdRef` above: those had
388
+ * to be current for a delta `ChatPane` — a CHILD — forwards inside the very commit that changed
389
+ * them, and child effects run before parent effects, so an effect always lagged by exactly the
390
+ * commit that mattered. `remove()`'s fallback is read from inside an already-`await`ed
391
+ * continuation, at least one microtask after any mutation that would have updated it — well after
392
+ * this hook's own effects for that commit have flushed. There is no same-commit reader to race.
393
+ */
394
+ const conversationsRef = useRef([]);
395
+ useEffect(() => {
396
+ conversationsRef.current = conversations;
397
+ }, [conversations]);
398
+ /**
399
+ * Re-reads the list, and reports whether the snapshot was still current when it arrived.
400
+ *
401
+ * The `fresh` flag is not decoration. An earlier version returned the list unconditionally with a
402
+ * comment claiming `remove` needed the server's view regardless — which handed `remove` exactly
403
+ * the snapshot this function had just decided was too stale to render: conversation A is active,
404
+ * `remove(A)` starts a list read whose response still contains C, C is deleted meanwhile and a
405
+ * newer refresh completes, then A's older response arrives, is correctly refused by
406
+ * `setConversations`, and is still handed back. `remove` routes to C, so `activeId` lands on a
407
+ * conversation that no longer exists and every subsequent message is written at a dead row.
408
+ */
409
+ const refresh = useCallback(async () => {
410
+ const seq = ++listSeqRef.current;
411
+ // `null` marks a failed read, so the mount-time restore can tell "not listed" from "no list".
412
+ const read = await portRef.current.listConversations({}, {}).catch(() => null);
413
+ const listed = read ?? [];
414
+ /*
415
+ * A rename whose PATCH has not settled wins over whatever the server just said.
416
+ *
417
+ * `markListMutated` cannot cover this, and assuming it did was the bug: that counter answers
418
+ * "did a response from BEFORE my write land after it?", and says nothing about a read that
419
+ * STARTS after the optimistic update but reaches a server which has not applied the PATCH yet.
420
+ * Such a read is legitimately `fresh`, so it wrote the old title straight over the new one —
421
+ * rename a conversation, let any unrelated activity trigger a refresh (a message save completing
422
+ * elsewhere is enough), and the title visibly reverted until the rename's own trailing refresh
423
+ * corrected it.
424
+ */
425
+ const pending = pendingRenamesRef.current;
426
+ const conversations = pending.size === 0
427
+ ? listed
428
+ : listed.map((conversation) => {
429
+ const rename = pending.get(conversation.id);
430
+ return rename
431
+ ? { ...conversation, title: rename.title, titleSource: "manual" }
432
+ : conversation;
433
+ });
434
+ const fresh = listSeqRef.current === seq;
435
+ // Not written to state when superseded: a local insert that landed while this was in flight is
436
+ // strictly newer than this snapshot.
437
+ if (fresh)
438
+ setConversations(conversations);
439
+ return { conversations, fresh, failed: read === null };
440
+ }, []);
441
+ /** Records a local list mutation, invalidating any `refresh` already in flight. */
442
+ const markListMutated = useCallback(() => {
443
+ listSeqRef.current += 1;
444
+ }, []);
445
+ /**
446
+ * Discards any lazy adoption belonging to a pane that is about to be replaced.
447
+ *
448
+ * `adoptingRef` is scoped to exactly one pane — it names the conversation *that* pane adopted —
449
+ * and every path that re-keys the pane has to clear it. The path that did not was a real fault:
450
+ * delete your only conversation and `activeId` returns to `null`, which routes the next message
451
+ * down the lazy-adoption branch, where `??=` found the previous pane's already-resolved promise
452
+ * still naming the conversation that had just been deleted. The messages were `PUT` at a row that
453
+ * no longer existed (404, silently), `activeId` was never set again, and no new conversation was
454
+ * ever created — so the dock persisted nothing at all for the rest of the session. That is the
455
+ * same user-visible symptom lazy adoption was introduced to fix, reachable by a different route.
456
+ */
457
+ /**
458
+ * Sets `activeId` and its mirror ref together. Nothing may call `setActiveId` directly.
459
+ *
460
+ * The ref exists because `onMessagesChange` and `remove` have to know where writes go *now*, not
461
+ * as of the render they closed over. It used to be kept current by a render-phase
462
+ * `activeIdRef.current = activeId`, which is the same hazard the `portRef` comment above rejects:
463
+ * a render React later discards has already published its id, and a delta from the still-
464
+ * committed tree then routes a message into a conversation that was never actually selected.
465
+ *
466
+ * An effect is the usual remedy and is wrong here too — child effects run before parent effects,
467
+ * so `ChatPane` can forward a delta before the sync effect runs, and the ref lags by a commit at
468
+ * the exact moment it is being read. Writing both together at the point of decision avoids both:
469
+ * every call site below is an event handler or a promise continuation, never a render, so the ref
470
+ * is published only when the change is real and is never behind the state.
471
+ */
472
+ const commitActiveId = useCallback((id) => {
473
+ activeIdRef.current = id;
474
+ setActiveId(id);
475
+ // The single point every active-conversation change passes through — select, create, lazy
476
+ // adoption, and delete's landing — so the remembered id can never lag what is on screen.
477
+ if (id === null)
478
+ lastConversationRef.current?.clear();
479
+ else
480
+ lastConversationRef.current?.write(id);
481
+ }, []);
482
+ const resetAdoption = useCallback(() => {
483
+ adoptingRef.current = null;
484
+ // The half that actually stops an already-running adoption from publishing — see
485
+ // `adoptionGenRef`. Dropping the reference alone only prevents *reuse*.
486
+ adoptionGenRef.current += 1;
487
+ }, []);
488
+ /**
489
+ * Publishes `activeId` and `initialMessages` in one commit, and only once the transcript is in
490
+ * hand.
491
+ *
492
+ * Setting `activeId` first is what the obvious version does, and it is wrong in a way that is
493
+ * invisible in tests: `activeId` is `ChatPane`'s `key`, so assigning it remounts the pane
494
+ * *immediately*, while `initialMessages` still holds the conversation being navigated away from.
495
+ * `ChatPane` reads `initialMessages` at mount only, so the fresh transcript — arriving a tick
496
+ * later — was silently discarded and the pane displayed the previous chat's messages under the
497
+ * new chat's title.
498
+ *
499
+ * The display fault was the mild half. The pane then reported that stale transcript through
500
+ * `onMessagesChange`, and because `writtenRef` for the newly selected conversation was still
501
+ * empty, every one of those messages was queued as an unsaved message *belonging to the new
502
+ * conversation*. Observed live: selecting an empty chat fired four `PUT`s carrying the previous
503
+ * chat's messages. They 404'd only because those message ids already existed under their real
504
+ * conversation — an accident of the primary key, not a safeguard. Seeding `writtenRef` in the
505
+ * same commit as the id is what actually closes that hole.
506
+ *
507
+ * React batches both setters (and the ref write precedes them), so the pane remounts exactly
508
+ * once, already knowing what it holds and what has been persisted.
509
+ */
510
+ const select = useCallback((id) => {
511
+ const seq = ++switchSeqRef.current;
512
+ const commit = (messages) => {
513
+ // A slower load for a conversation the user has since navigated away from must not land.
514
+ if (switchSeqRef.current !== seq)
515
+ return;
516
+ writtenRef.current.set(id, new Set(messages.map(message => messageWriteKey({ message }, {}))));
517
+ setInitialMessages(messages);
518
+ commitActiveId(id);
519
+ // A user-initiated switch is exactly when remounting the pane IS the intent.
520
+ setPaneKey(id);
521
+ resetAdoption();
522
+ };
523
+ void portRef.current
524
+ .loadMessages({ conversationId: id }, {})
525
+ .then(commit)
526
+ // A failed load still switches, to an empty pane: leaving the user on the previous
527
+ // conversation while its title says otherwise is the worse of the two failures.
528
+ .catch(() => commit([]));
529
+ },
530
+ // `commitActiveId` added: it's a `useCallback([], ...)`-wrapped setter (see its own
531
+ // declaration above), stable for the component's lifetime — no-op addition, not a suppression.
532
+ [resetAdoption, commitActiveId]);
533
+ /**
534
+ * The mount-time list read, which also reopens the conversation this browser was last on.
535
+ *
536
+ * Declared after `select` because it calls it — moved here from beside `refresh` for that
537
+ * reason only; the read itself is unchanged.
538
+ *
539
+ * The restore is skipped if the operator has already acted (switched, created, or started typing
540
+ * into the empty pane, which adopts a conversation) before the list arrived: their own action is
541
+ * newer intent than a memory from the previous page load. `switchSeqRef` covers select/create,
542
+ * `activeIdRef`/`adoptingRef` cover lazy adoption. Under StrictMode's double mount the first
543
+ * restore's `select` bumps `switchSeqRef`, so the second one stands down rather than loading twice.
544
+ */
545
+ useEffect(() => {
546
+ const store = lastConversationRef.current;
547
+ if (!store) {
548
+ void refresh();
549
+ return;
550
+ }
551
+ const remembered = store.read();
552
+ const intentAtMount = switchSeqRef.current;
553
+ void refresh().then(({ conversations, failed }) => {
554
+ const decision = resolveRememberedConversation({ remembered, conversations, listFailed: failed });
555
+ if (decision === "forget")
556
+ forgetRemembered(store, remembered);
557
+ const untouched = switchSeqRef.current === intentAtMount && activeIdRef.current === null && adoptingRef.current === null;
558
+ if (decision === "restore" && untouched)
559
+ select(remembered);
560
+ });
561
+ }, [refresh, select]);
562
+ const create = useCallback(async () => {
563
+ /*
564
+ * The token is claimed BEFORE the request, and re-checked after.
565
+ *
566
+ * Claiming it after `createConversation()` resolved made "New" the winner of every race it
567
+ * happened to finish last: click New, change your mind, pick chat B from the list, watch B load —
568
+ * and then the earlier POST returns and yanks you into an empty new chat. Bumping up front
569
+ * registers the intent at the moment of the click; re-checking means a selection made *after*
570
+ * the click still wins, which is the ordering a user expects.
571
+ *
572
+ * The conversation row is still created either way. That is deliberate — it exists server-side,
573
+ * and abandoning it silently would be worse than leaving an empty chat in the list.
574
+ */
575
+ const seq = ++switchSeqRef.current;
576
+ const conversation = await portRef.current.createConversation({}, {});
577
+ // Both branches insert locally, so both have to invalidate an in-flight `refresh` — otherwise a
578
+ // list snapshot taken before this POST lands overwrites the row that was just added.
579
+ markListMutated();
580
+ if (switchSeqRef.current !== seq) {
581
+ // Superseded: surface the new row in the list, but do not steal the user's current view.
582
+ setConversations((current) => [conversation, ...current]);
583
+ return;
584
+ }
585
+ setConversations((current) => [conversation, ...current]);
586
+ commitActiveId(conversation.id);
587
+ setInitialMessages([]);
588
+ setPaneKey(conversation.id);
589
+ writtenRef.current.set(conversation.id, new Set());
590
+ resetAdoption();
591
+ // `commitActiveId` added: stable `useCallback([], ...)` wrapper, see `select`'s identical note.
592
+ }, [markListMutated, resetAdoption, commitActiveId]);
593
+ const remove = useCallback(async (id) => {
594
+ await portRef.current.deleteConversation({ id }, {});
595
+ writtenRef.current.delete(id);
596
+ const snapshot = await refresh();
597
+ if (activeIdRef.current !== id)
598
+ return;
599
+ /*
600
+ * A stale snapshot must not be trusted for routing: if this list read landed superseded (the
601
+ * user deleted something else while it was in flight), it can still name a conversation that
602
+ * has since been deleted, and routing there would strand `activeId` on a row no fresh list
603
+ * will ever contain again — `select`'s `.catch(() => commit([]))` even dresses that up as a
604
+ * successful switch to an empty pane.
605
+ *
606
+ * `fresh` is exactly the signal for that, and the earlier gap was checking it for nothing:
607
+ * this used `snapshot.conversations` regardless of `fresh`. Falling back to `conversationsRef`
608
+ * — mirroring the last state this hook actually committed, kept current by the effect above —
609
+ * instead of an unguarded stale read closes it: by the time this snapshot is judged stale,
610
+ * whatever concurrent mutation outraced it has already had its own `refresh()` commit, which
611
+ * is what "stale" means here. `id` is filtered out regardless, so the row just deleted can
612
+ * never be the landing spot even from the fallback.
613
+ */
614
+ const remaining = (snapshot.fresh ? snapshot.conversations : conversationsRef.current).filter((conversation) => conversation.id !== id);
615
+ // Land on the next most recent chat rather than an empty pane with no way back.
616
+ const next = remaining[0]?.id ?? null;
617
+ if (!next) {
618
+ // Nothing left to land on. Bump the token so a switch still in flight cannot resurrect a
619
+ // transcript into the now-empty pane.
620
+ switchSeqRef.current += 1;
621
+ commitActiveId(null);
622
+ setInitialMessages([]);
623
+ paneNonceRef.current += 1;
624
+ setPaneKey(`new-${paneNonceRef.current}`);
625
+ // The branch `resetAdoption` exists for: this is the one place `activeId` goes back to
626
+ // `null`, which is the only state that re-enters the lazy-adoption path. Without this the
627
+ // fresh pane inherits the deleted conversation's adoption and never persists again.
628
+ resetAdoption();
629
+ return;
630
+ }
631
+ /*
632
+ * `select` and ONLY `select` — deliberately no `setActiveId(next)` first.
633
+ *
634
+ * Setting the id here looks harmless and is not: `activeId` is `ChatPane`'s `key`, so it
635
+ * remounts the pane immediately with `initialMessages` still empty, and then `select`'s own
636
+ * commit sets `activeId` to the value it ALREADY has. The key never changes again, so the pane
637
+ * is never remounted, and `ChatPane` seeds `initialMessages` at mount only — nothing resets it
638
+ * on a prop change. The empty transcript is therefore permanent, not a flicker: deleting the
639
+ * active chat landed on the next one showing no history at all, and the next prompt was sent
640
+ * without it as context. `select` alone publishes id, transcript and written-ids in one commit.
641
+ */
642
+ select(next);
643
+ },
644
+ // `commitActiveId` added (used a few lines above, in the "nothing left to land on" branch):
645
+ // stable `useCallback([], ...)` wrapper, see `select`'s identical note.
646
+ [refresh, resetAdoption, select, commitActiveId]);
647
+ const rename = useCallback(async (id, title) => {
648
+ // Optimistic: a rename that only appears after a round-trip feels broken during the edit.
649
+ // `markListMutated` for the same reason `create` needs it — this is a local write to the
650
+ // list, so a refresh already in flight is now older than it. Without the mark, that older
651
+ // response lands and the title visibly reverts to its previous value until the refresh below
652
+ // corrects it a moment later.
653
+ markListMutated();
654
+ // Held until the PATCH settles, so any refresh landing in between re-applies this title
655
+ // instead of trusting a server read that has not caught up yet.
656
+ const version = ++renameVersionRef.current;
657
+ pendingRenamesRef.current.set(id, { version, title });
658
+ setConversations((current) => current.map((c) => (c.id === id ? { ...c, title, titleSource: "manual" } : c)));
659
+ try {
660
+ await portRef.current.renameConversation({ id, title }, {});
661
+ }
662
+ catch {
663
+ // Swallowed as before — the trailing refresh shows whatever the server actually has, which
664
+ // is the correct outcome for a failed rename too.
665
+ }
666
+ finally {
667
+ // Only if no NEWER rename of this conversation has queued behind us.
668
+ if (pendingRenamesRef.current.get(id)?.version === version) {
669
+ pendingRenamesRef.current.delete(id);
670
+ }
671
+ }
672
+ void refresh();
673
+ }, [markListMutated, refresh]);
674
+ const saveTrackedMessage = useCallback(({ conversationId, message }, _optional = {}) => {
675
+ const pending = messagePendingRef.current.get(conversationId) ?? new Map();
676
+ messagePendingRef.current.set(conversationId, pending);
677
+ const key = messageWriteKey({ message }, {});
678
+ const existing = pending.get(key);
679
+ if (existing)
680
+ return existing;
681
+ const write = (async () => {
682
+ await runStubPendingRef.current.get(conversationId)?.get(key);
683
+ return saveWithRetry(portRef.current, conversationId, message, () => disposedRef.current);
684
+ })();
685
+ pending.set(key, write);
686
+ void write.then(() => {
687
+ pending.delete(key);
688
+ if (pending.size === 0)
689
+ messagePendingRef.current.delete(conversationId);
690
+ });
691
+ return write;
692
+ }, []);
693
+ /** Writes whatever in `messages` has settled and is not already stored, into `conversationId`. */
694
+ const flush = useCallback((conversationId, messages) => {
695
+ const written = writtenRef.current.get(conversationId) ?? new Set();
696
+ writtenRef.current.set(conversationId, written);
697
+ // Keyed by message id AND run id (see `messageWriteKey`), so a retry's new run is saved too.
698
+ const pending = persistableMessages({ messages }, {}).filter((m) => !written.has(messageWriteKey({ message: m }, {})));
699
+ if (pending.length === 0)
700
+ return;
701
+ // Marked before the request resolves so a second `onMessagesChange` arriving mid-flight —
702
+ // which it will, since deltas keep coming — does not queue the same message twice.
703
+ for (const message of pending)
704
+ written.add(messageWriteKey({ message }, {}));
705
+ void Promise.all(pending.map(async (message) => {
706
+ return {
707
+ message,
708
+ outcome: await saveTrackedMessage({ conversationId, message }, {}),
709
+ };
710
+ })).then((results) => {
711
+ /*
712
+ * Un-marked ONLY when the write could plausibly succeed later.
713
+ *
714
+ * Un-marking on any failure — which this did — turns a permanent error into a permanent
715
+ * load: the id goes back in the queue, the next `onMessagesChange` re-sends it, it fails
716
+ * identically, and it is un-marked again. One 400 therefore issues a doomed request on
717
+ * every subsequent delta for the rest of the session, and they accumulate as more messages
718
+ * fail the same way. `saveWithRetry` has already spent its attempts by this point, so
719
+ * "exhausted" is the only outcome where another try is worth queueing at all.
720
+ *
721
+ * `some`, not "all succeeded": one message failing must not suppress the list refresh for
722
+ * the others. The first turn is what gives an untitled chat its name server-side, and
723
+ * `messageCount` changes on every turn, so the list needs re-reading rather than patching.
724
+ * A write that spent its whole ladder on 404s means this conversation may no longer exist —
725
+ * re-read so the switcher stops showing a row the server has already forgotten, rather than
726
+ * leaving the pane to grind out doomed writes with nothing visible anywhere in the UI.
727
+ * See {@link summarizeFlushOutcomes} for the decision itself.
728
+ */
729
+ const { idsToRelease, shouldRefresh } = summarizeFlushOutcomes(results, activeIdRef.current === conversationId);
730
+ for (const id of idsToRelease)
731
+ written.delete(id);
732
+ if (shouldRefresh)
733
+ void refresh();
734
+ });
735
+ }, [refresh, saveTrackedMessage]);
736
+ /**
737
+ * Writes ONE user turn durably, right now, and reports nothing back but completion.
738
+ *
739
+ * Defect 2 of the 2026-09-11 chat-lifecycle repair. Until this existed, the only writer of a user
740
+ * turn was {@link flush} — driven asynchronously off `onMessagesChange` deltas, with no ordering
741
+ * relationship at all to `assistant-transport.ts`'s `POST /api/runs`. The two are not atomic: a
742
+ * run could be (and was) dispatched while the message it was dispatched for had not been written
743
+ * anywhere, so a failure in that window left the user's words in a browser tab and nowhere else.
744
+ * `startRun` awaits this first, including any PUT already scheduled by `flush`. Acceptance also
745
+ * persists the exact user payload with its stub, covering exhausted browser writes and tab loss.
746
+ *
747
+ * Shares `writtenRef` with `flush` deliberately — that is what makes the two writers cooperate
748
+ * rather than duplicate. The id is marked before the request for the same reason `flush` marks
749
+ * before its own resolves (a delta arriving mid-flight must not queue the same message again),
750
+ * and released again on a failure `saveWithRetry` has already given up on, so `flush` still gets
751
+ * its chance rather than the message being silently recorded as stored.
752
+ *
753
+ * @param conversationId - The conversation the turn belongs to. Supplied by the caller (which has
754
+ * just resolved it through {@link ensureConversationId}) rather than read from `activeIdRef`,
755
+ * so a run and its message can never land in two different conversations.
756
+ * @param message - The exact `ChatMessage` being sent. Its `id` is the idempotency key:
757
+ * `saveMessage` PUTs to `/messages/<id>`, so writing the same message twice is one row.
758
+ * @returns Nothing, always — never rejects. A caller that cannot make the turn durable must still
759
+ * be able to send it.
760
+ * @complexity O(1) plus one PUT and its retry ladder.
761
+ */
762
+ const persistUserTurn = useCallback(async (conversationId, message) => {
763
+ const written = writtenRef.current.get(conversationId) ?? new Set();
764
+ writtenRef.current.set(conversationId, written);
765
+ const key = messageWriteKey({ message }, {});
766
+ // A new chat's first delta often reaches flush before transport reaches this method. The
767
+ // dedup bit then means "scheduled", not "committed"; wait on the same PUT before dispatch.
768
+ const pending = messagePendingRef.current.get(conversationId)?.get(key);
769
+ if (pending) {
770
+ await pending;
771
+ return;
772
+ }
773
+ if (written.has(key))
774
+ return;
775
+ written.add(key);
776
+ const outcome = await saveTrackedMessage({ conversationId, message }, {});
777
+ // Same rule `summarizeFlushOutcomes` applies to a settled batch, for the same reason: only an
778
+ // outcome that could plausibly succeed later is worth handing back to `flush`. Re-queueing a
779
+ // permanent failure would make every later delta re-send a doomed request.
780
+ if (outcome !== "saved")
781
+ written.delete(key);
782
+ }, [saveTrackedMessage]);
783
+ /**
784
+ * Writes the ONE extra durable row a future reattach needs: a non-terminal assistant message's
785
+ * `runId`, captured the moment it appears — see `assistant-chats.ts`'s {@link activeRunStub} for
786
+ * why this is not already covered by {@link flush}.
787
+ *
788
+ * Deliberately best-effort and silent on a PERMANENT failure, unlike {@link persistUserTurn}: a
789
+ * lost stub write only means a future reattach has nothing to find for this run, the same
790
+ * degraded-but-safe outcome as today (nothing reattaches yet regardless — see the 2026-09-11
791
+ * investigation this exists for). It must never surface as a chat-breaking error over what is, at
792
+ * worst, a missed optimization. A TRANSIENT failure (503, a dropped connection) is left for the
793
+ * next delta to retry for free: `id` is released so a later call with the same message id tries
794
+ * again, exactly like {@link persistUserTurn}'s own release-on-failure.
795
+ *
796
+ * No retry ladder of its own (unlike `persistUserTurn`'s `saveWithRetry`): the run is still
797
+ * streaming, so another `onMessagesChange` delta — and therefore another chance — arrives on its
798
+ * own within moments. A run that ends before any further delta fires has nothing left worth
799
+ * reattaching to anyway.
800
+ *
801
+ * Daemon acceptance now closes that former gap before dispatch. This best-effort path remains
802
+ * only for request-bound BYOK/AG-UI; browser daemon stubs cannot own durable attempt state.
803
+ * @complexity O(1) plus at most one `PUT`.
804
+ */
805
+ const persistRunStub = useCallback((conversationId, message) => {
806
+ // Daemon acceptance commits the stub before dispatch; browser snapshots cannot own it.
807
+ if (message.runId && !ports.requestRunPrefixes.some(prefix => message.runId.startsWith(prefix)))
808
+ return;
809
+ const written = runStubWrittenRef.current.get(conversationId) ?? new Set();
810
+ runStubWrittenRef.current.set(conversationId, written);
811
+ // Per run, not per message id: a retry reuses the id with a new run (see `messageWriteKey`).
812
+ const key = messageWriteKey({ message }, {});
813
+ if (written.has(key))
814
+ return;
815
+ // This run's final result is already saved. A retry resets the message to `queued` while it still
816
+ // carries the OLD run id; writing that would put a stale stub over the saved answer.
817
+ if (writtenRef.current.get(conversationId)?.has(key))
818
+ return;
819
+ written.add(key);
820
+ const pending = runStubPendingRef.current.get(conversationId) ?? new Map();
821
+ runStubPendingRef.current.set(conversationId, pending);
822
+ const write = portRef.current.saveMessage({ conversationId, message }, {}).catch(() => {
823
+ written.delete(key);
824
+ }).finally(() => {
825
+ pending.delete(key);
826
+ if (pending.size === 0)
827
+ runStubPendingRef.current.delete(conversationId);
828
+ });
829
+ pending.set(key, write);
830
+ }, []);
831
+ /**
832
+ * This pane's conversation id, adopting one if that is what it takes — the single owner of lazy
833
+ * adoption, awaited by {@link onMessagesChange} below and by `assistant-transport.ts`'s `startRun`
834
+ * (via `AssistantDock`'s `useAssistantTransport`).
835
+ *
836
+ * `startRun` is the reason this is exposed rather than staying inline in `onMessagesChange`.
837
+ * Adoption used to be reachable ONLY from a message delta, and `@jini-ai/chat`'s
838
+ * `useChatPane.sendPrompt` freezes `runContext(...)` into the run's context object BEFORE it calls
839
+ * `conversation.sendMessage(...)` — the very call that produces that first delta. So on turn 1 the
840
+ * run was always dispatched with no `conversationId`, `agent-daemon-server.ts`'s `onStarted` skipped
841
+ * its whole session-capture subscription (`if (conversationId !== undefined)`), and the agent-CLI
842
+ * session id turn 2 needed to resume was never stored. Turn 2 then started cold while the client,
843
+ * trusting that resume, had sent only the bare latest user message: the conversation's history was
844
+ * gone, silently, at exactly the turn-1→turn-2 boundary of every chat.
845
+ *
846
+ * `setActiveId` WITHOUT `setPaneKey`: the pane may be mid-run and is keyed on `paneKey`, so leaving
847
+ * that alone is what keeps this invisible to the user. Re-keying here would remount the pane and
848
+ * destroy the reply currently streaming into it.
849
+ *
850
+ * @returns The conversation id writes and runs for this pane belong to, or `null` when creation
851
+ * failed. Never rejects — a caller that cannot get an id (`startRun`) must still be able to send
852
+ * the turn: one run with no resumable session is a far smaller loss than a run that never happens.
853
+ * @complexity O(1) plus at most one `POST /conversations`, shared across concurrent callers.
854
+ */
855
+ const ensureConversationId = useCallback(() => {
856
+ const active = activeIdRef.current;
857
+ if (active)
858
+ return Promise.resolve(active);
859
+ /*
860
+ * The adoption generation is READ here, and `resetAdoption` is the only thing that bumps it.
861
+ *
862
+ * Deliberately NOT `switchSeqRef`. That token is bumped at the *call* of `select`/`create`,
863
+ * so an adoption starting after the click reads the already-bumped value, matches it when the
864
+ * adoption resolves, and publishes `activeId` into a pane that has since been re-keyed to
865
+ * someone else's transcript. `adoptionGenRef` moves with the commit rather than the intent,
866
+ * which is the thing this actually needs to be later than.
867
+ */
868
+ const generation = adoptionGenRef.current;
869
+ // `??=` so a run start and the deltas racing it await ONE creation, not two — otherwise a single
870
+ // turn splits across two conversations, the run filed under one and the messages under the other.
871
+ const adoption = (adoptingRef.current ??= portRef.current
872
+ .createConversation({}, {})
873
+ .then((conversation) => {
874
+ markListMutated();
875
+ setConversations((current) => [conversation, ...current]);
876
+ writtenRef.current.set(conversation.id, new Set());
877
+ // Superseded: the row exists and shows up in the list, but the pane that asked for it is
878
+ // gone, so pointing `activeId` at it would send the NEXT pane's writes here. The caller's
879
+ // own follow-up still runs either way — those messages came from the pane that asked for
880
+ // this conversation, and dropping them would be the data loss adoption exists to prevent.
881
+ if (adoptionGenRef.current === generation)
882
+ commitActiveId(conversation.id);
883
+ return conversation.id;
884
+ })
885
+ .catch(() => null));
886
+ void adoption.then((id) => {
887
+ /*
888
+ * Cleared so a later turn can retry; keeping a rejected promise would wedge persistence for
889
+ * the rest of the session.
890
+ *
891
+ * Guarded on identity, because a bare `adoptingRef.current = null` clears whatever is there
892
+ * NOW, which need not be this promise. A stale adoption rejecting late would wipe a newer one
893
+ * that is still in flight, and the next delta would start a third — two conversations created
894
+ * for one turn, with the same messages flushed into both and whichever resolved last taking
895
+ * `activeId`.
896
+ */
897
+ if (!id && adoptingRef.current === adoption)
898
+ adoptingRef.current = null;
899
+ });
900
+ return adoption;
901
+ }, [markListMutated, commitActiveId]);
902
+ const onMessagesChange = useCallback((messages) => {
903
+ // Computed once up front, independent of which branch below actually runs — both need it, and
904
+ // it is O(1) (last element only, see `activeRunStub`'s own doc), so there is no cost to paying
905
+ // it on every delta even on the branch that turns out not to use it.
906
+ const stub = activeRunStub({ messages }, {});
907
+ const conversationId = activeIdRef.current;
908
+ if (conversationId) {
909
+ if (stub)
910
+ persistRunStub(conversationId, stub);
911
+ flush(conversationId, messages);
912
+ return;
913
+ }
914
+ /*
915
+ * No conversation is active — and this is the DEFAULT state, not an edge case. Nothing selects
916
+ * a conversation on mount, so opening the admin and typing straight into the dock (without
917
+ * first clicking "New" or picking from history) landed here, where the old code simply
918
+ * returned. The run worked on screen and neither turn was ever stored: the entire conversation
919
+ * vanished on reload, silently, on the most common path through the feature.
920
+ *
921
+ * So adopt one lazily, at the first moment there is actually something to save. Created on
922
+ * demand rather than on mount, because a row per admin page-load would litter the switcher with
923
+ * empty conversations. `startRun` now usually gets here first (see {@link ensureConversationId}),
924
+ * in which case `??=` makes this await that same creation rather than starting a second.
925
+ */
926
+ if (persistableMessages({ messages }, {}).length === 0 && !stub)
927
+ return;
928
+ void ensureConversationId().then((id) => {
929
+ if (!id)
930
+ return;
931
+ if (stub)
932
+ persistRunStub(id, stub);
933
+ flush(id, messages);
934
+ });
935
+ },
936
+ // Adoption itself now lives in `ensureConversationId` (stable `useCallback`, see its own doc),
937
+ // so `markListMutated`/`commitActiveId` are no longer read here.
938
+ [flush, ensureConversationId, persistRunStub]);
939
+ // Boundary adapters keep the hook's stable action identities and lexical race guards intact.
940
+ const actions = useMemo(() => ({
941
+ select: ({ id }, _options = {}) => select(id),
942
+ create: (_required, _options = {}) => create(),
943
+ remove: ({ id }, _options = {}) => remove(id),
944
+ rename: ({ id, title }, _options = {}) => rename(id, title),
945
+ onMessagesChange: ({ messages }, _options = {}) => onMessagesChange(messages),
946
+ ensureConversationId: (_required, _options = {}) => ensureConversationId(),
947
+ persistUserTurn: ({ conversationId, message }, _options = {}) => persistUserTurn(conversationId, message),
948
+ }), [select, create, remove, rename, onMessagesChange, ensureConversationId, persistUserTurn]);
949
+ return { conversations, activeId, paneKey, initialMessages, ...actions };
950
+ }
951
+ return {
952
+ giveUpOutcome: ({ error }, _options = {}) => giveUpOutcome(error),
953
+ handleSaveFailure: ({ error, conversationId, message, attempt, isDisposed }, _options = {}) => handleSaveFailure(error, conversationId, message, attempt, isDisposed),
954
+ attemptSave: ({ port, conversationId, message, attempt, isDisposed }, _options = {}) => attemptSave(port, conversationId, message, attempt, isDisposed),
955
+ summarizeFlushOutcomes: ({ results, isConversationStillActive }, _options = {}) => summarizeFlushOutcomes(results, isConversationStillActive),
956
+ resolveRememberedConversation: (required, _options = {}) => resolveRememberedConversation(required),
957
+ useAssistantChats: ({ port }, options = {}) => useAssistantChats(port, options),
958
+ };
959
+ }
960
+ //# sourceMappingURL=assistant-chats.js.map