@jini-ai/chat 0.5.4 → 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.
- package/CHANGELOG.md +4 -0
- package/dist/core/ag-ui/entry.d.ts +33 -0
- package/dist/core/ag-ui/entry.js +80 -0
- package/dist/core/agentic/chat-capabilities.d.ts +2 -4
- package/dist/core/agentic/index.d.ts +4 -17
- package/dist/core/agentic/index.js +4 -17
- package/dist/core/assistant-client/a2ui-action-poster.d.ts +46 -0
- package/dist/core/assistant-client/a2ui-action-poster.js +113 -0
- package/dist/core/assistant-client/ag-ui-transport.d.ts +68 -0
- package/dist/core/assistant-client/ag-ui-transport.js +147 -0
- package/dist/core/assistant-client/attachment-liveness.d.ts +37 -0
- package/dist/core/assistant-client/attachment-liveness.js +93 -0
- package/dist/core/assistant-client/durable-run-subscription.d.ts +39 -0
- package/dist/core/assistant-client/durable-run-subscription.js +96 -0
- package/dist/core/assistant-client/entry.d.ts +13 -0
- package/dist/core/assistant-client/entry.js +7 -0
- package/dist/core/assistant-client/messages.d.ts +73 -0
- package/dist/core/assistant-client/messages.js +68 -0
- package/dist/core/assistant-client/transport.d.ts +164 -0
- package/dist/core/assistant-client/transport.js +734 -0
- package/dist/core/browser/page-actions.d.ts +121 -0
- package/dist/core/browser/page-actions.js +113 -0
- package/dist/core/durable-runs/continuation.d.ts +18 -0
- package/dist/core/durable-runs/continuation.js +61 -0
- package/dist/core/durable-runs/ledger-contracts.d.ts +53 -0
- package/dist/core/durable-runs/ledger-contracts.js +2 -0
- package/dist/core/durable-runs/policies.d.ts +6 -0
- package/dist/core/durable-runs/policies.js +6 -0
- package/dist/core/durable-runs/ports.d.ts +123 -0
- package/dist/core/durable-runs/ports.js +2 -0
- package/dist/core/durable-runs/run-contracts.d.ts +17 -0
- package/dist/core/durable-runs/run-contracts.js +2 -0
- package/dist/core/durable-runs/run-lock.d.ts +4 -0
- package/dist/core/durable-runs/run-lock.js +4 -0
- package/dist/core/index.d.ts +6 -19
- package/dist/core/index.js +6 -19
- package/dist/core/messages.d.ts +2 -0
- package/dist/core/question-form/format.d.ts +0 -9
- package/dist/core/question-form/format.js +16 -2
- package/dist/core/question-form/normalize.js +1 -0
- package/dist/core/question-form/secret-answers.d.ts +38 -0
- package/dist/core/question-form/secret-answers.js +45 -0
- package/dist/core/question-form/types.d.ts +2 -0
- package/dist/core/question-form.d.ts +1 -0
- package/dist/core/question-form.js +1 -0
- package/dist/core/run-events/entry.d.ts +208 -16
- package/dist/core/run-events/entry.js +273 -39
- package/dist/core/transcript.d.ts +3 -2
- package/dist/core/transcript.js +19 -3
- package/dist/core/user-text-redaction.d.ts +56 -0
- package/dist/core/user-text-redaction.js +89 -0
- package/dist/embed-widget/browser/page-actions.d.ts +1 -120
- package/dist/embed-widget/browser/page-actions.js +2 -112
- package/dist/embed-widget/react/embed.d.ts +1 -1
- package/dist/embed-widget/react/embed.js +1 -1
- package/dist/react/artifact-types.d.ts +9 -15
- package/dist/react/components/QuestionForm.d.ts +2 -1
- package/dist/react/components/QuestionForm.js +18 -12
- package/dist/react/components/QuestionsPanel.d.ts +3 -2
- package/dist/react/components/QuestionsPanel.js +2 -2
- package/dist/react/features/chat-pane/agent-tools.d.ts +1 -1
- package/dist/react/features/chat-pane/agent-tools.js +0 -1
- package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +5 -2
- package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
- package/dist/react/features/chat-pane/components/ChatPane.js +5 -2
- package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +2 -1
- package/dist/react/features/chat-pane/create-typed-answer-poster.js +33 -1
- package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +3 -2
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +4 -1
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +29 -17
- package/dist/react/features/chat-pane/model-options.d.ts +10 -0
- package/dist/react/features/chat-pane/model-options.js +51 -0
- package/dist/react/features/chat-pane/rules.d.ts +3 -2
- package/dist/react/features/chat-pane/rules.js +12 -4
- package/dist/react/features/chat-pane/types.d.ts +25 -1
- package/dist/react/hooks/assistant-chats-contracts.d.ts +164 -0
- package/dist/react/hooks/assistant-chats-contracts.js +2 -0
- package/dist/react/hooks/assistant-chats.d.ts +7 -0
- package/dist/react/hooks/assistant-chats.js +960 -0
- package/dist/react/hooks/attachment-preview-cache.d.ts +1 -1
- package/dist/react/hooks/attachment-preview-cache.js +1 -1
- package/dist/react/hooks/composer-draft-cache.d.ts +2 -2
- package/dist/react/hooks/composer-draft-cache.js +2 -2
- package/dist/react/hooks/fab-position.d.ts +90 -0
- package/dist/react/hooks/fab-position.js +271 -0
- package/dist/react/hooks/useConversation.d.ts +4 -1
- package/dist/react/hooks/useConversation.js +14 -12
- package/dist/react/hooks/useUserTextGuard.d.ts +11 -0
- package/dist/react/hooks/useUserTextGuard.js +19 -0
- package/dist/react/index.d.ts +12 -21
- package/dist/react/index.js +7 -0
- package/dist/server/durable-runs/accept.d.ts +25 -0
- package/dist/server/durable-runs/accept.js +21 -0
- package/dist/server/durable-runs/entry.d.ts +5 -0
- package/dist/server/durable-runs/entry.js +4 -0
- package/dist/server/durable-runs/recover.d.ts +5 -0
- package/dist/server/durable-runs/recover.js +147 -0
- package/dist/server/run-finalizer/ports.d.ts +2 -16
- package/dist/store/legacy/sqlite/messages.d.ts +24 -18
- package/dist/store/legacy/sqlite/messages.js +12 -2
- package/dist/store/pglite/index.d.ts +2 -1
- package/dist/store/postgres/index.d.ts +2 -1
- package/dist/store/runtime/chat-expiry-sweep.d.ts +40 -0
- package/dist/store/runtime/chat-expiry-sweep.js +70 -0
- package/dist/store/runtime/chat-history-store.d.ts +20 -0
- package/dist/store/runtime/chat-history-store.js +50 -0
- package/dist/store/runtime/durable-run-store.d.ts +21 -0
- package/dist/store/runtime/durable-run-store.js +342 -0
- package/dist/store/runtime/entry.d.ts +7 -0
- package/dist/store/runtime/entry.js +6 -0
- package/dist/store/runtime/kernel.d.ts +29 -0
- package/dist/store/runtime/kernel.js +2 -0
- package/dist/store/runtime/run-guarded-chat-store.d.ts +10 -0
- package/dist/store/runtime/run-guarded-chat-store.js +42 -0
- package/dist/store/runtime/run-ledger.d.ts +15 -0
- package/dist/store/runtime/run-ledger.js +206 -0
- package/dist/store/sql/boundary.d.ts +6 -0
- package/dist/store/sql/boundary.js +7 -1
- package/dist/store/sql/store.d.ts +3 -2
- package/dist/store/sql/store.js +9 -2
- package/dist/store/sqlite/index.d.ts +3 -2
- 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
|