@kortix/sdk 0.12.3 → 0.12.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/README.md +58 -15
- package/dist/browser/session-sync/session-transcript-cache.d.ts +17 -0
- package/dist/browser/session-sync/session-transcript-cache.d.ts.map +1 -1
- package/dist/browser/session-sync/session-transcript-cache.js +14 -2
- package/dist/browser/session-sync/session-transcript-cache.js.map +1 -1
- package/dist/browser/stores/sync-store.d.ts +86 -2
- package/dist/browser/stores/sync-store.d.ts.map +1 -1
- package/dist/browser/stores/sync-store.js +386 -33
- package/dist/browser/stores/sync-store.js.map +1 -1
- package/dist/core/client/kortix.d.ts +57 -54
- package/dist/core/client/kortix.d.ts.map +1 -1
- package/dist/core/client/kortix.js +40 -21
- package/dist/core/client/kortix.js.map +1 -1
- package/dist/core/http/api-client.d.ts +19 -1
- package/dist/core/http/api-client.d.ts.map +1 -1
- package/dist/core/http/api-client.js +39 -6
- package/dist/core/http/api-client.js.map +1 -1
- package/dist/core/http/auth.d.ts.map +1 -1
- package/dist/core/http/auth.js +6 -4
- package/dist/core/http/auth.js.map +1 -1
- package/dist/core/http/config.d.ts +4 -0
- package/dist/core/http/config.d.ts.map +1 -1
- package/dist/core/http/config.js.map +1 -1
- package/dist/core/rest/platform-client/host-boundary.js +2 -2
- package/dist/core/rest/platform-client/host-boundary.js.map +1 -1
- package/dist/core/rest/projects-client/access.d.ts +3 -3
- package/dist/core/rest/projects-client/access.d.ts.map +1 -1
- package/dist/core/rest/projects-client/agent-config.d.ts +1 -1
- package/dist/core/rest/projects-client/agent-config.d.ts.map +1 -1
- package/dist/core/rest/projects-client/audit.js +1 -1
- package/dist/core/rest/projects-client/audit.js.map +1 -1
- package/dist/core/rest/projects-client/billing.d.ts +7 -0
- package/dist/core/rest/projects-client/billing.d.ts.map +1 -1
- package/dist/core/rest/projects-client/billing.js.map +1 -1
- package/dist/core/rest/projects-client/channels.d.ts +7 -6
- package/dist/core/rest/projects-client/channels.d.ts.map +1 -1
- package/dist/core/rest/projects-client/channels.js +12 -2
- package/dist/core/rest/projects-client/channels.js.map +1 -1
- package/dist/core/rest/projects-client/connectors.d.ts +128 -98
- package/dist/core/rest/projects-client/connectors.d.ts.map +1 -1
- package/dist/core/rest/projects-client/connectors.js +199 -119
- package/dist/core/rest/projects-client/connectors.js.map +1 -1
- package/dist/core/rest/projects-client/files.d.ts.map +1 -1
- package/dist/core/rest/projects-client/files.js +7 -1
- package/dist/core/rest/projects-client/files.js.map +1 -1
- package/dist/core/rest/projects-client/policies.js +3 -3
- package/dist/core/rest/projects-client/policies.js.map +1 -1
- package/dist/core/rest/projects-client/projects.d.ts +46 -0
- package/dist/core/rest/projects-client/projects.d.ts.map +1 -1
- package/dist/core/rest/projects-client/projects.js +12 -0
- package/dist/core/rest/projects-client/projects.js.map +1 -1
- package/dist/core/rest/projects-client/secrets.d.ts +55 -3
- package/dist/core/rest/projects-client/secrets.d.ts.map +1 -1
- package/dist/core/rest/projects-client/secrets.js +14 -4
- package/dist/core/rest/projects-client/secrets.js.map +1 -1
- package/dist/core/rest/projects-client/sessions.d.ts +8 -16
- package/dist/core/rest/projects-client/sessions.d.ts.map +1 -1
- package/dist/core/rest/projects-client/sessions.js +1 -1
- package/dist/core/rest/projects-client/sessions.js.map +1 -1
- package/dist/core/session/index.d.ts +1 -0
- package/dist/core/session/index.d.ts.map +1 -1
- package/dist/core/session/index.js +1 -0
- package/dist/core/session/index.js.map +1 -1
- package/dist/core/session/preview-probe.d.ts +76 -0
- package/dist/core/session/preview-probe.d.ts.map +1 -0
- package/dist/core/session/preview-probe.js +97 -0
- package/dist/core/session/preview-probe.js.map +1 -0
- package/dist/core/turns/classify.js +1 -1
- package/dist/core/turns/errors.js +1 -1
- package/dist/core/turns/errors.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/kortix.esm.min.js +19 -19
- package/dist/kortix.global.js +19 -19
- package/dist/platform/auth-core.d.ts +2 -1
- package/dist/platform/auth-core.d.ts.map +1 -1
- package/dist/platform/auth-core.js +10 -1
- package/dist/platform/auth-core.js.map +1 -1
- package/dist/react/use-opencode-sessions/messages.d.ts +19 -3
- package/dist/react/use-opencode-sessions/messages.d.ts.map +1 -1
- package/dist/react/use-opencode-sessions/messages.js +28 -55
- package/dist/react/use-opencode-sessions/messages.js.map +1 -1
- package/dist/react/use-project-secrets.d.ts +24 -0
- package/dist/react/use-project-secrets.d.ts.map +1 -1
- package/dist/react/use-session-sync.d.ts +1 -2
- package/dist/react/use-session-sync.d.ts.map +1 -1
- package/dist/react/use-session-sync.js +17 -40
- package/dist/react/use-session-sync.js.map +1 -1
- package/dist/react/use-session.d.ts.map +1 -1
- package/dist/react/use-session.js +18 -16
- package/dist/react/use-session.js.map +1 -1
- package/package.json +2 -2
|
@@ -26,41 +26,288 @@ const optimisticIds = new Map();
|
|
|
26
26
|
// match: the server has not been told it exists, so nothing the server returns
|
|
27
27
|
// can be a copy of it.
|
|
28
28
|
const dispatchedOptimisticIds = new Map();
|
|
29
|
-
function trackId(store, sessionID,
|
|
29
|
+
function trackId(store, sessionID, id) {
|
|
30
30
|
const bucket = store.get(sessionID);
|
|
31
31
|
if (bucket)
|
|
32
|
-
bucket.add(
|
|
32
|
+
bucket.add(id);
|
|
33
33
|
else
|
|
34
|
-
store.set(sessionID, new Set([
|
|
34
|
+
store.set(sessionID, new Set([id]));
|
|
35
35
|
}
|
|
36
|
-
function untrackId(store, sessionID,
|
|
36
|
+
function untrackId(store, sessionID, id) {
|
|
37
37
|
const bucket = store.get(sessionID);
|
|
38
38
|
if (!bucket)
|
|
39
39
|
return;
|
|
40
|
-
bucket.delete(
|
|
40
|
+
bucket.delete(id);
|
|
41
41
|
if (bucket.size === 0)
|
|
42
42
|
store.delete(sessionID);
|
|
43
43
|
}
|
|
44
|
-
function hasTrackedId(store, sessionID,
|
|
45
|
-
return store.get(sessionID)?.has(
|
|
44
|
+
function hasTrackedId(store, sessionID, id) {
|
|
45
|
+
return store.get(sessionID)?.has(id) ?? false;
|
|
46
46
|
}
|
|
47
|
-
/** Release every id this session was tracking — called from `clearSession
|
|
47
|
+
/** Release every id this session was tracking — called from `clearSession`
|
|
48
|
+
* and from eviction. */
|
|
48
49
|
function forgetSessionIds(sessionID) {
|
|
49
50
|
optimisticIds.delete(sessionID);
|
|
50
51
|
dispatchedOptimisticIds.delete(sessionID);
|
|
52
|
+
// The joined rows hold the very message and part arrays this session's
|
|
53
|
+
// data is being dropped from. Left behind, they keep the transcript
|
|
54
|
+
// reachable and the drop achieves nothing.
|
|
55
|
+
sessionMessageRows.delete(sessionID);
|
|
56
|
+
// A session cleared without ever going idle/erroring (the only other
|
|
57
|
+
// release points, below) would otherwise leave a dead bucket in
|
|
58
|
+
// deltaActiveParts for the lifetime of the tab — the exact leak class
|
|
59
|
+
// this function exists to prevent for optimisticIds above.
|
|
60
|
+
deltaActiveParts.delete(sessionID);
|
|
61
|
+
// Same leak class, on bridgedPartIds: it used to be released only by
|
|
62
|
+
// reset() (which application code never calls), not by clearSession, so
|
|
63
|
+
// a message id stayed "bridged" forever once bridged once. If a LATER
|
|
64
|
+
// message in a different session ever reused that id, upsertPart's
|
|
65
|
+
// bridge-clearing branch fired for it too, wiping every part already
|
|
66
|
+
// stored under it. See bridgedPartIds below.
|
|
67
|
+
bridgedPartIds.delete(sessionID);
|
|
51
68
|
}
|
|
52
69
|
const isOptimistic = (sessionID, messageID) => hasTrackedId(optimisticIds, sessionID, messageID);
|
|
53
70
|
const isDispatched = (sessionID, messageID) => hasTrackedId(dispatchedOptimisticIds, sessionID, messageID);
|
|
54
|
-
// Track message IDs where optimistic parts were bridged to the real message
|
|
55
|
-
//
|
|
56
|
-
//
|
|
71
|
+
// Track message IDs where optimistic parts were bridged to the real message,
|
|
72
|
+
// keyed by session — same shape and same reason as optimisticIds above. When
|
|
73
|
+
// the first real part arrives for a bridged message, the bridged parts are
|
|
74
|
+
// cleared so optimistic and real parts don't co-exist (which would
|
|
57
75
|
// double-render the user's text).
|
|
58
|
-
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
76
|
+
//
|
|
77
|
+
// It used to be one process-wide Set<string> shared by every session in the
|
|
78
|
+
// tab, cleared only by reset() — clearSession never released its entries, so
|
|
79
|
+
// ids accumulated for the lifetime of the tab. A leaked id made upsertPart
|
|
80
|
+
// treat any LATER message that reused it (in any session) as still-bridged:
|
|
81
|
+
// the first real text part for that id wiped every part already stored under
|
|
82
|
+
// it, even though nothing was ever bridged in that session. Keying by session
|
|
83
|
+
// and releasing only the ending session's bucket (via forgetSessionIds)
|
|
84
|
+
// fixes it.
|
|
85
|
+
const bridgedPartIds = new Map();
|
|
86
|
+
// Track part IDs that have received at least one delta, keyed by session —
|
|
87
|
+
// same shape and same reason as optimisticIds above. Used by upsertPart to
|
|
88
|
+
// avoid overwriting delta-accumulated text with a stale message.part.updated
|
|
89
|
+
// snapshot that arrives in the same event batch. A session's entries are
|
|
90
|
+
// cleared when THAT session's stream goes idle or errors.
|
|
91
|
+
//
|
|
92
|
+
// It used to be one process-wide Set<string> shared by every session in the
|
|
93
|
+
// tab, cleared wholesale on session.idle/session.error. Session B going idle
|
|
94
|
+
// wiped session A's tracking while A was still streaming, so the guard below
|
|
95
|
+
// stopped protecting A — a stale snapshot could then overwrite A's
|
|
96
|
+
// delta-accumulated text. Keying by session and releasing only the ending
|
|
97
|
+
// session's bucket fixes it.
|
|
98
|
+
const deltaActiveParts = new Map();
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Session retention — how a transcript ever LEAVES memory again.
|
|
101
|
+
//
|
|
102
|
+
// `messages` and `parts` are keyed by session and nothing removed a key.
|
|
103
|
+
// `clearSession` has one caller (a cache-ownership conflict in
|
|
104
|
+
// use-session-sync.ts) and `reset()` has none in application code, so every
|
|
105
|
+
// session a user opened kept its full transcript and every part for the
|
|
106
|
+
// lifetime of the tab. Open ten long sessions and memory climbs monotonically
|
|
107
|
+
// and never comes back.
|
|
108
|
+
//
|
|
109
|
+
// So: reference-count the mounted consumers of each session and free it once
|
|
110
|
+
// the last one is gone. Same idea as React Query's observer count, but bounded
|
|
111
|
+
// by COUNT rather than by a `gcTime` timer — memory pressure is "how many
|
|
112
|
+
// transcripts are resident", not "how old is this one", and a count needs no
|
|
113
|
+
// timer in a store that has none today and stays deterministic under test.
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
/**
|
|
116
|
+
* How many sessions keep their transcript resident after their LAST consumer
|
|
117
|
+
* unmounts, most-recently-detached first.
|
|
118
|
+
*
|
|
119
|
+
* Not zero, for two reasons:
|
|
120
|
+
* 1. React StrictMode double-invokes effects (mount → cleanup → mount) in the
|
|
121
|
+
* same commit. Freeing the instant the count hits zero would blank the
|
|
122
|
+
* transcript on every dev mount, and again on every fast refresh.
|
|
123
|
+
* 2. Back-and-forth between two or three sessions is the common navigation.
|
|
124
|
+
* Holding them costs nothing to correctness and skips a disk round trip —
|
|
125
|
+
* and a round trip is the GOOD case: `getCurrentCacheScope()` returns null
|
|
126
|
+
* when unauthenticated, and then nothing was ever written to disk to
|
|
127
|
+
* return to.
|
|
128
|
+
*
|
|
129
|
+
* Small, because memory must be TIGHTER than disk: `idb-sync-cache.ts` bounds
|
|
130
|
+
* the on-disk cache at 50 sessions / 7 days.
|
|
131
|
+
*/
|
|
132
|
+
const DETACHED_SESSION_LIMIT = 3;
|
|
133
|
+
/**
|
|
134
|
+
* The joined `MessageWithParts[]` rows, per session — see
|
|
135
|
+
* {@link SyncState.buildSessionMessages}. Bounded, and dropped for real by
|
|
136
|
+
* `forgetSessionIds`.
|
|
137
|
+
*/
|
|
138
|
+
const MESSAGE_ROWS_LIMIT = 20;
|
|
139
|
+
const sessionMessageRows = new Map();
|
|
140
|
+
const EMPTY_MESSAGE_ROWS = [];
|
|
141
|
+
/** Insert-or-refresh `sessionID` as the most recently used entry, dropping the
|
|
142
|
+
* least recently used one once the map is over {@link MESSAGE_ROWS_LIMIT}.
|
|
143
|
+
* Re-inserting is what moves a key to the end of a Map's iteration order. */
|
|
144
|
+
function touchSessionMessageRows(sessionID, entry) {
|
|
145
|
+
sessionMessageRows.delete(sessionID);
|
|
146
|
+
sessionMessageRows.set(sessionID, entry);
|
|
147
|
+
if (sessionMessageRows.size > MESSAGE_ROWS_LIMIT) {
|
|
148
|
+
const leastRecentlyUsed = sessionMessageRows.keys().next().value;
|
|
149
|
+
if (leastRecentlyUsed)
|
|
150
|
+
sessionMessageRows.delete(leastRecentlyUsed);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Mounted consumers per session. Absent means "nobody ever retained this" —
|
|
154
|
+
* see the release returned by `retainSession`, which then leaves the session
|
|
155
|
+
* alone entirely. */
|
|
156
|
+
const sessionConsumers = new Map();
|
|
157
|
+
/** Sessions at zero consumers, oldest first (Set preserves insertion order). */
|
|
158
|
+
const detachedSessions = new Set();
|
|
159
|
+
/**
|
|
160
|
+
* Sessions whose transcript eviction freed, and which nothing authoritative has
|
|
161
|
+
* reloaded since. See {@link SyncState.wasTranscriptEvicted} for what reads it
|
|
162
|
+
* and {@link pruneDetachedSessions} for the second thing it is for.
|
|
163
|
+
*
|
|
164
|
+
* Holds ids, not data. It is cleared wholesale by `reset()`, and per-session by
|
|
165
|
+
* every path that re-establishes the session — `hydrate`, `clearSession`,
|
|
166
|
+
* `optimisticAdd`.
|
|
167
|
+
*/
|
|
168
|
+
const evictedSessions = new Set();
|
|
169
|
+
/**
|
|
170
|
+
* Free the part buckets belonging to `sessionIDs` that no message points at.
|
|
171
|
+
*
|
|
172
|
+
* `parts` is keyed by messageID, so every sweep that walks a session's
|
|
173
|
+
* `messages` misses the buckets with no message entry to walk from — and the
|
|
174
|
+
* store creates those on purpose. `message.part.delta` stores the part but
|
|
175
|
+
* SKIPS creating the assistant message when the session holds no user message
|
|
176
|
+
* yet (a delta that beats `hydrate()` after a page refresh), so `hydrate` can
|
|
177
|
+
* attach the real one later. Nothing else ever revisits the bucket, so before
|
|
178
|
+
* this it outlived both eviction and `clearSession`: the leak this file's
|
|
179
|
+
* retention work exists to close, left open on the one path that produces it.
|
|
180
|
+
*
|
|
181
|
+
* Attribution is by `Part.sessionID`, which is what those stub parts carry.
|
|
182
|
+
* `every` rather than `some` so a bucket is only freed when the whole thing is
|
|
183
|
+
* this session's; a bucket whose parts arrived without a `sessionID` is
|
|
184
|
+
* unattributable and deliberately left alone rather than guessed at.
|
|
185
|
+
*
|
|
186
|
+
* Mutates `parts` — a caller-owned copy in both call sites.
|
|
187
|
+
*/
|
|
188
|
+
function deleteOrphanPartBuckets(parts, sessionIDs) {
|
|
189
|
+
const dropped = new Set(sessionIDs);
|
|
190
|
+
for (const messageID of Object.keys(parts)) {
|
|
191
|
+
const bucket = parts[messageID];
|
|
192
|
+
if (!bucket || bucket.length === 0)
|
|
193
|
+
continue;
|
|
194
|
+
const owner = bucket[0].sessionID;
|
|
195
|
+
if (!owner || !dropped.has(owner))
|
|
196
|
+
continue;
|
|
197
|
+
if (bucket.every((part) => part.sessionID === owner))
|
|
198
|
+
delete parts[messageID];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Drop these sessions' data outright.
|
|
203
|
+
*
|
|
204
|
+
* DELETES the `messages` key rather than emptying it, unlike `clearSession`.
|
|
205
|
+
* The difference is load-bearing: `use-session-sync.ts` reads `sessionId in
|
|
206
|
+
* store.messages` both for `isLoading` and (via `shouldHydrateFromCache`) to
|
|
207
|
+
* decide whether the IndexedDB transcript may repaint. An empty array reads as
|
|
208
|
+
* "loaded, and empty", which would leave a returning user staring at a blank
|
|
209
|
+
* transcript that never repaints.
|
|
210
|
+
*
|
|
211
|
+
* `sessionStatus` is deliberately NOT dropped. It is the one slice read for
|
|
212
|
+
* sessions that are on purpose not resident — a parent's spawn-tool banner
|
|
213
|
+
* reads `sessionStatus[child]` for a child whose transcript the parent never
|
|
214
|
+
* holds (`tool/shared/sub-agent.tsx`, `tool/tools/removed-connector-tool.tsx`).
|
|
215
|
+
* Dropping it bought no memory either: every `session.status` frame, and the
|
|
216
|
+
* connect-time `client.session.status()` poll, re-add an entry for every
|
|
217
|
+
* session on the runtime whether or not it is resident, so the delete was
|
|
218
|
+
* undone on the next frame — while the gap was visible to the user. One small
|
|
219
|
+
* status object per session is not the memory this eviction reclaims; the
|
|
220
|
+
* transcript arrays are.
|
|
221
|
+
*/
|
|
222
|
+
function dropSessionData(state, sessionIDs) {
|
|
223
|
+
const messages = { ...state.messages };
|
|
224
|
+
const parts = { ...state.parts };
|
|
225
|
+
const diffs = { ...state.diffs };
|
|
226
|
+
const todos = { ...state.todos };
|
|
227
|
+
for (const sessionID of sessionIDs) {
|
|
228
|
+
for (const message of messages[sessionID] ?? [])
|
|
229
|
+
delete parts[message.id];
|
|
230
|
+
delete messages[sessionID];
|
|
231
|
+
delete diffs[sessionID];
|
|
232
|
+
delete todos[sessionID];
|
|
233
|
+
}
|
|
234
|
+
deleteOrphanPartBuckets(parts, sessionIDs);
|
|
235
|
+
return { messages, parts, sessionStatus: state.sessionStatus, diffs, todos };
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Prune the detached window down to {@link DETACHED_SESSION_LIMIT}, freeing the
|
|
239
|
+
* oldest sessions past it, plus any already-evicted session the live stream has
|
|
240
|
+
* since refilled. Returns the ids it freed.
|
|
241
|
+
*
|
|
242
|
+
* The second half is what keeps eviction from being a one-shot. Eviction takes
|
|
243
|
+
* a session OUT of the detach window, so an agent still running in it puts
|
|
244
|
+
* `messages[id]` back — through SSE, with nobody watching — and that data then
|
|
245
|
+
* has no path out of memory again. Re-checking {@link evictedSessions} on every
|
|
246
|
+
* prune gives it one: the next mount of any session sweeps it, exactly like the
|
|
247
|
+
* first time. It stays marked, because the user can still come back to it.
|
|
248
|
+
*
|
|
249
|
+
* Runs when a session is RETAINED, never when one is released. React runs every
|
|
250
|
+
* passive destroy before any passive create in a commit, so pruning on release
|
|
251
|
+
* would let the unmount of B evict A in the very commit that mounts A: visit
|
|
252
|
+
* A → X → Y → B and go back to A, and A is the oldest of four detached
|
|
253
|
+
* sessions at exactly the wrong moment. Pruning after the incoming session has
|
|
254
|
+
* been removed from the window makes that unreachable, and keeps `set()` out of
|
|
255
|
+
* the unmount path entirely.
|
|
256
|
+
*
|
|
257
|
+
* The window therefore holds at most LIMIT + (releases since the last retain),
|
|
258
|
+
* and is back to LIMIT the moment any session mounts.
|
|
259
|
+
*
|
|
260
|
+
* Synchronous on purpose. Deferring it (a microtask, a post-commit hook) so
|
|
261
|
+
* that every retain in a commit lands first would buy nothing, because the
|
|
262
|
+
* ordering it would buy is already guaranteed. The case that motivates
|
|
263
|
+
* deferral is a parent's spawn-tool preview of a child session, and the
|
|
264
|
+
* preview's `useOpenCodeMessages(childId)` is always a React DESCENDANT of the
|
|
265
|
+
* component that retains the parent (`SessionLayout` → the transcript → the
|
|
266
|
+
* tool part). React runs passive effects bottom-up, so in any commit that
|
|
267
|
+
* mounts both, the child's retain lands before the parent's — and therefore
|
|
268
|
+
* before the prune the parent's retain triggers. Deferral would only cost the
|
|
269
|
+
* determinism that keeps unmount out of the eviction path.
|
|
270
|
+
*
|
|
271
|
+
* (The argument this replaces claimed the child's data "arrives a commit or
|
|
272
|
+
* more after the parent mounts". That is not universally true — a parent whose
|
|
273
|
+
* transcript is already resident renders it in the mount commit — and the
|
|
274
|
+
* conclusion never depended on it.)
|
|
275
|
+
*
|
|
276
|
+
* The residual hazard is narrower than it looks, and worth naming exactly.
|
|
277
|
+
* Only a child session the user opened DIRECTLY and then left is ever an
|
|
278
|
+
* eviction candidate; one that was only ever previewed was never retained, and
|
|
279
|
+
* `retainSession` leaves sessions it has never seen alone. What such a child
|
|
280
|
+
* loses is its `messages`/`parts` — `sessionStatus` survives eviction on
|
|
281
|
+
* purpose (see `dropSessionData`), so a preview's retry banner keeps reading.
|
|
282
|
+
* A preview showing a transcript has no repaint path of its own, so closing
|
|
283
|
+
* the rest needs the reference declared before the data is read — either the
|
|
284
|
+
* host retaining child ids when it parses the parent's transcript, or
|
|
285
|
+
* `useOpenCodeMessages` reading the disk cache the way `useSessionSync` does.
|
|
286
|
+
* The second needs the child's `kortixSessionScope` plumbed through from the
|
|
287
|
+
* host: entries written for an opened session are keyed
|
|
288
|
+
* `…:kortix-session:<scope>`, so a scopeless read looks up a different key and
|
|
289
|
+
* misses (idb-sync-cache-key.ts:6-9).
|
|
290
|
+
*/
|
|
291
|
+
function pruneDetachedSessions(messages) {
|
|
292
|
+
const evicted = [];
|
|
293
|
+
while (detachedSessions.size > DETACHED_SESSION_LIMIT) {
|
|
294
|
+
const oldest = detachedSessions.values().next().value;
|
|
295
|
+
if (oldest === undefined)
|
|
296
|
+
break;
|
|
297
|
+
detachedSessions.delete(oldest);
|
|
298
|
+
evicted.push(oldest);
|
|
299
|
+
}
|
|
300
|
+
// Refilled since it was last freed, and still nobody's. Gated on the key
|
|
301
|
+
// actually being back so a quiet evicted session never provokes a `set()`
|
|
302
|
+
// that changes nothing and re-renders every consumer of this store.
|
|
303
|
+
for (const sessionID of evictedSessions) {
|
|
304
|
+
if (sessionConsumers.has(sessionID) || detachedSessions.has(sessionID))
|
|
305
|
+
continue;
|
|
306
|
+
if (sessionID in messages)
|
|
307
|
+
evicted.push(sessionID);
|
|
308
|
+
}
|
|
309
|
+
return evicted;
|
|
310
|
+
}
|
|
64
311
|
// ============================================================================
|
|
65
312
|
export const useSyncStore = create()((set, get) => ({
|
|
66
313
|
messages: {},
|
|
@@ -116,12 +363,15 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
116
363
|
parts: restParts,
|
|
117
364
|
};
|
|
118
365
|
}),
|
|
119
|
-
upsertPart: (messageID, part) => set((s) => {
|
|
366
|
+
upsertPart: (messageID, part, sessionID) => set((s) => {
|
|
367
|
+
// See the `sessionID` param doc above: prefer the caller-resolved id,
|
|
368
|
+
// fall back to the part's own field only when no caller passed one.
|
|
369
|
+
const partSessionID = sessionID ?? part.sessionID;
|
|
120
370
|
// If this message had bridged (optimistic) parts, clear them now
|
|
121
371
|
// that a real part has arrived — prevents double-rendering.
|
|
122
372
|
let list;
|
|
123
373
|
let bridgeCleared = false;
|
|
124
|
-
if (bridgedPartIds
|
|
374
|
+
if (hasTrackedId(bridgedPartIds, partSessionID, messageID)) {
|
|
125
375
|
// Only retire the optimistic/bridged user text once a REAL text
|
|
126
376
|
// part with actual content arrives. A stray non-text part — or an
|
|
127
377
|
// empty text snapshot — must NOT wipe the bridge, otherwise the
|
|
@@ -131,7 +381,7 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
131
381
|
typeof incoming.text === "string" &&
|
|
132
382
|
incoming.text.length > 0;
|
|
133
383
|
if (incomingIsRealText) {
|
|
134
|
-
bridgedPartIds
|
|
384
|
+
untrackId(bridgedPartIds, partSessionID, messageID);
|
|
135
385
|
list = [];
|
|
136
386
|
bridgeCleared = true;
|
|
137
387
|
}
|
|
@@ -187,7 +437,7 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
187
437
|
// text (missing the beginning). Skip the insert — the delta-
|
|
188
438
|
// created version already exists in the parts list under the
|
|
189
439
|
// message entry created by the delta handler.
|
|
190
|
-
if (deltaActiveParts
|
|
440
|
+
if (hasTrackedId(deltaActiveParts, partSessionID, part.id) && isTextLikePart(part)) {
|
|
191
441
|
// Delta-created part already exists — check all messageID
|
|
192
442
|
// buckets since the delta handler may have stored it under
|
|
193
443
|
// a different (stub) message entry.
|
|
@@ -217,8 +467,8 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
217
467
|
}
|
|
218
468
|
return { parts: { ...s.parts, [messageID]: next } };
|
|
219
469
|
}),
|
|
220
|
-
applyPartDelta: (messageID, partID, field, delta) => {
|
|
221
|
-
deltaActiveParts
|
|
470
|
+
applyPartDelta: (sessionID, messageID, partID, field, delta) => {
|
|
471
|
+
trackId(deltaActiveParts, sessionID, partID);
|
|
222
472
|
set((s) => {
|
|
223
473
|
const list = s.parts[messageID];
|
|
224
474
|
if (!list)
|
|
@@ -245,6 +495,13 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
245
495
|
})),
|
|
246
496
|
optimisticAdd: (sessionID, message, messageParts) => {
|
|
247
497
|
trackId(optimisticIds, sessionID, message.id);
|
|
498
|
+
// The user has typed into this session, so the disk repaint stands down.
|
|
499
|
+
// `hydrate`'s ordinal fallback pairs an in-flight optimistic message with
|
|
500
|
+
// the oldest unclaimed real user message, and EVERY message in a disk
|
|
501
|
+
// copy is unclaimed — a repaint landing mid-send would retire the message
|
|
502
|
+
// that was just typed. The key-presence check used to make that
|
|
503
|
+
// unreachable (this action creates the key); the mark has to.
|
|
504
|
+
evictedSessions.delete(sessionID);
|
|
248
505
|
set((s) => {
|
|
249
506
|
const list = s.messages[sessionID] ?? [];
|
|
250
507
|
// Always append optimistic messages at the end of the list.
|
|
@@ -315,10 +572,14 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
315
572
|
// Release this session's optimistic tracking along with its messages.
|
|
316
573
|
// Skipping it is what let ids accumulate for the lifetime of the tab.
|
|
317
574
|
forgetSessionIds(sessionID);
|
|
575
|
+
// Emptied on purpose (a cache-ownership conflict). The disk copy is
|
|
576
|
+
// exactly what must NOT come back, so drop any eviction mark with it.
|
|
577
|
+
evictedSessions.delete(sessionID);
|
|
318
578
|
const existingMessages = s.messages[sessionID] ?? [];
|
|
319
579
|
const nextParts = { ...s.parts };
|
|
320
580
|
for (const message of existingMessages)
|
|
321
581
|
delete nextParts[message.id];
|
|
582
|
+
deleteOrphanPartBuckets(nextParts, [sessionID]);
|
|
322
583
|
return {
|
|
323
584
|
messages: { ...s.messages, [sessionID]: [] },
|
|
324
585
|
parts: nextParts,
|
|
@@ -327,7 +588,82 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
327
588
|
todos: { ...s.todos, [sessionID]: [] },
|
|
328
589
|
};
|
|
329
590
|
}),
|
|
591
|
+
retainSession: (sessionID) => {
|
|
592
|
+
if (!sessionID)
|
|
593
|
+
return () => { };
|
|
594
|
+
// Out of the detach window BEFORE pruning it, so mounting a session can
|
|
595
|
+
// never be what evicts it.
|
|
596
|
+
detachedSessions.delete(sessionID);
|
|
597
|
+
sessionConsumers.set(sessionID, (sessionConsumers.get(sessionID) ?? 0) + 1);
|
|
598
|
+
const evicted = pruneDetachedSessions(get().messages);
|
|
599
|
+
if (evicted.length > 0) {
|
|
600
|
+
for (const id of evicted) {
|
|
601
|
+
forgetSessionIds(id);
|
|
602
|
+
evictedSessions.add(id);
|
|
603
|
+
}
|
|
604
|
+
set((s) => dropSessionData(s, evicted));
|
|
605
|
+
}
|
|
606
|
+
let released = false;
|
|
607
|
+
return () => {
|
|
608
|
+
if (released)
|
|
609
|
+
return;
|
|
610
|
+
released = true;
|
|
611
|
+
const held = sessionConsumers.get(sessionID);
|
|
612
|
+
if (held === undefined)
|
|
613
|
+
return;
|
|
614
|
+
// Another consumer still has this session on screen. THE case a raw
|
|
615
|
+
// unmount gets wrong: a transcript and a context modal, a split
|
|
616
|
+
// view, or a parent's spawn-tool preview of a child session.
|
|
617
|
+
if (held > 1) {
|
|
618
|
+
sessionConsumers.set(sessionID, held - 1);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
sessionConsumers.delete(sessionID);
|
|
622
|
+
// Re-insert so this session is the NEWEST detached one, not wherever
|
|
623
|
+
// a previous detach left it. Nothing is freed here — see
|
|
624
|
+
// `pruneDetachedSessions`.
|
|
625
|
+
detachedSessions.delete(sessionID);
|
|
626
|
+
detachedSessions.add(sessionID);
|
|
627
|
+
};
|
|
628
|
+
},
|
|
629
|
+
wasTranscriptEvicted: (sessionID) => evictedSessions.has(sessionID),
|
|
630
|
+
buildSessionMessages: (sessionID, msgs, parts) => {
|
|
631
|
+
if (!msgs || msgs.length === 0)
|
|
632
|
+
return EMPTY_MESSAGE_ROWS;
|
|
633
|
+
const cached = sessionMessageRows.get(sessionID);
|
|
634
|
+
if (cached && cached.msgs === msgs) {
|
|
635
|
+
let same = cached.partRefs.length === msgs.length;
|
|
636
|
+
if (same) {
|
|
637
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
638
|
+
if (parts[msgs[i].id] !== cached.partRefs[i]) {
|
|
639
|
+
same = false;
|
|
640
|
+
break;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
if (same) {
|
|
645
|
+
// A hit is USE. Without this the order is insertion order, not
|
|
646
|
+
// recency, and a stable session that renders unchanged for
|
|
647
|
+
// minutes is pushed out by other sessions' rebuilds — costing it
|
|
648
|
+
// a needless rebuild and one extra render.
|
|
649
|
+
touchSessionMessageRows(sessionID, cached);
|
|
650
|
+
return cached.result;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
const partRefs = [];
|
|
654
|
+
const result = [];
|
|
655
|
+
for (const info of msgs) {
|
|
656
|
+
const messageParts = parts[info.id];
|
|
657
|
+
partRefs.push(messageParts);
|
|
658
|
+
result.push({ info, parts: messageParts ?? [] });
|
|
659
|
+
}
|
|
660
|
+
touchSessionMessageRows(sessionID, { msgs, partRefs, result });
|
|
661
|
+
return result;
|
|
662
|
+
},
|
|
330
663
|
hydrate: (sessionID, msgs) => set((s) => {
|
|
664
|
+
// An authoritative load — the disk repaint itself, or a reconcile —
|
|
665
|
+
// re-establishes the session, so its entry is no longer a fragment.
|
|
666
|
+
evictedSessions.delete(sessionID);
|
|
331
667
|
const cmp = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
332
668
|
const incoming = msgs
|
|
333
669
|
.filter((m) => !!m?.info?.id)
|
|
@@ -470,7 +806,7 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
470
806
|
if (serverHasParts || newParts[echoId]?.length)
|
|
471
807
|
continue;
|
|
472
808
|
newParts[echoId] = bridge;
|
|
473
|
-
bridgedPartIds
|
|
809
|
+
trackId(bridgedPartIds, sessionID, echoId);
|
|
474
810
|
}
|
|
475
811
|
for (const m of msgs) {
|
|
476
812
|
if (!m?.info?.id)
|
|
@@ -484,8 +820,8 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
484
820
|
// If this message still carries bridged optimistic parts, a hydrate
|
|
485
821
|
// snapshot with real parts should replace them immediately. Otherwise
|
|
486
822
|
// reconcile-by-extras can keep both copies and duplicate user text.
|
|
487
|
-
if (bridgedPartIds
|
|
488
|
-
bridgedPartIds
|
|
823
|
+
if (hasTrackedId(bridgedPartIds, sessionID, mid) && inParts.length > 0) {
|
|
824
|
+
untrackId(bridgedPartIds, sessionID, mid);
|
|
489
825
|
newParts[mid] = inParts;
|
|
490
826
|
continue;
|
|
491
827
|
}
|
|
@@ -532,6 +868,13 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
532
868
|
optimisticIds.clear();
|
|
533
869
|
dispatchedOptimisticIds.clear();
|
|
534
870
|
bridgedPartIds.clear();
|
|
871
|
+
deltaActiveParts.clear();
|
|
872
|
+
// The data these holds protected is gone, so the holds are too —
|
|
873
|
+
// otherwise a late unmount could free a session the next page opened.
|
|
874
|
+
sessionConsumers.clear();
|
|
875
|
+
detachedSessions.clear();
|
|
876
|
+
evictedSessions.clear();
|
|
877
|
+
sessionMessageRows.clear();
|
|
535
878
|
set({
|
|
536
879
|
messages: {},
|
|
537
880
|
parts: {},
|
|
@@ -649,7 +992,7 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
649
992
|
}
|
|
650
993
|
if (bridge && !newParts[info.id]?.length) {
|
|
651
994
|
newParts[info.id] = bridge;
|
|
652
|
-
bridgedPartIds.
|
|
995
|
+
trackId(bridgedPartIds, info.sessionID, info.id);
|
|
653
996
|
}
|
|
654
997
|
return {
|
|
655
998
|
messages: { ...s.messages, [info.sessionID]: next },
|
|
@@ -698,7 +1041,11 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
698
1041
|
role: "assistant",
|
|
699
1042
|
});
|
|
700
1043
|
}
|
|
701
|
-
|
|
1044
|
+
// Pass resolvedSessionID explicitly — part.sessionID can be absent
|
|
1045
|
+
// on the wire (the fallback chain above exists for exactly that),
|
|
1046
|
+
// and the deltaActiveParts guard in upsertPart must not silently
|
|
1047
|
+
// no-op just because that field is missing.
|
|
1048
|
+
store.upsertPart(part.messageID, part, resolvedSessionID);
|
|
702
1049
|
if (isTextLikePart(part)) {
|
|
703
1050
|
if (!resolvedSessionID)
|
|
704
1051
|
return;
|
|
@@ -749,12 +1096,13 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
749
1096
|
}
|
|
750
1097
|
store.upsertPart(props.messageID, {
|
|
751
1098
|
id: props.partID,
|
|
1099
|
+
sessionID: props.sessionID,
|
|
752
1100
|
messageID: props.messageID,
|
|
753
1101
|
type: "text",
|
|
754
1102
|
[props.field]: "",
|
|
755
1103
|
});
|
|
756
1104
|
}
|
|
757
|
-
store.applyPartDelta(props.messageID, props.partID, props.field, props.delta);
|
|
1105
|
+
store.applyPartDelta(props.sessionID, props.messageID, props.partID, props.field, props.delta);
|
|
758
1106
|
if (props.field === "text") {
|
|
759
1107
|
const updated = get().parts[props.messageID]?.find((p) => p.id === props.partID);
|
|
760
1108
|
if (updated && isTextLikePart(updated) && updated.text.length > 0) {
|
|
@@ -774,9 +1122,12 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
774
1122
|
const sessionID = event.properties.sessionID;
|
|
775
1123
|
if (sessionID)
|
|
776
1124
|
store.setStatus(sessionID, { type: "idle" });
|
|
777
|
-
// Streaming finished — clear
|
|
778
|
-
// message.part.updated snapshots
|
|
779
|
-
|
|
1125
|
+
// Streaming finished for THIS session — clear only its own delta
|
|
1126
|
+
// tracking so future message.part.updated snapshots for it are
|
|
1127
|
+
// accepted normally. Never the whole map: another session may
|
|
1128
|
+
// still be streaming (see comment above deltaActiveParts).
|
|
1129
|
+
if (sessionID)
|
|
1130
|
+
deltaActiveParts.delete(sessionID);
|
|
780
1131
|
return;
|
|
781
1132
|
}
|
|
782
1133
|
case "session.error": {
|
|
@@ -787,7 +1138,9 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
787
1138
|
const error = props.error;
|
|
788
1139
|
// Mark session idle — errors terminate the response.
|
|
789
1140
|
store.setStatus(sid, { type: "idle" });
|
|
790
|
-
|
|
1141
|
+
// Clear only this session's delta tracking — see the idle handler
|
|
1142
|
+
// above and the comment above deltaActiveParts.
|
|
1143
|
+
deltaActiveParts.delete(sid);
|
|
791
1144
|
// Patch the error onto the last assistant message in the sync store.
|
|
792
1145
|
// If no assistant message exists yet, create a temporary one so the
|
|
793
1146
|
// error is visible immediately. The event handler in
|