@assistant-ui/core 0.3.14 → 0.3.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/dist/adapters/voice.js +11 -0
  2. package/dist/adapters/voice.js.map +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/internal.d.ts +2 -1
  5. package/dist/internal.js +2 -1
  6. package/dist/model-context/frame/host.d.ts +1 -1
  7. package/dist/model-context/frame/host.d.ts.map +1 -1
  8. package/dist/model-context/frame/host.js +17 -3
  9. package/dist/model-context/frame/host.js.map +1 -1
  10. package/dist/model-context/frame/provider.d.ts +4 -0
  11. package/dist/model-context/frame/provider.d.ts.map +1 -1
  12. package/dist/model-context/frame/provider.js +50 -3
  13. package/dist/model-context/frame/provider.js.map +1 -1
  14. package/dist/model-context/frame/types.d.ts +4 -1
  15. package/dist/model-context/frame/types.d.ts.map +1 -1
  16. package/dist/model-context/frame/types.js.map +1 -1
  17. package/dist/react/client/RemoteThreadList.d.ts +1 -1
  18. package/dist/react/client/RemoteThreadList.d.ts.map +1 -1
  19. package/dist/react/client/RemoteThreadList.js +95 -23
  20. package/dist/react/client/RemoteThreadList.js.map +1 -1
  21. package/dist/react/primitive-hooks/useActionBarCopy.js +7 -1
  22. package/dist/react/primitive-hooks/useActionBarCopy.js.map +1 -1
  23. package/dist/react/primitive-hooks/useThreadIsEmpty.d.ts +3 -0
  24. package/dist/react/primitive-hooks/useThreadIsEmpty.d.ts.map +1 -1
  25. package/dist/react/primitive-hooks/useThreadIsEmpty.js +3 -0
  26. package/dist/react/primitive-hooks/useThreadIsEmpty.js.map +1 -1
  27. package/dist/react/primitive-hooks/useThreadIsRunning.d.ts +3 -0
  28. package/dist/react/primitive-hooks/useThreadIsRunning.d.ts.map +1 -1
  29. package/dist/react/primitive-hooks/useThreadIsRunning.js +3 -0
  30. package/dist/react/primitive-hooks/useThreadIsRunning.js.map +1 -1
  31. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +2 -0
  32. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
  33. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +6 -0
  34. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
  35. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +6 -0
  36. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  37. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +192 -50
  38. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  39. package/dist/react/runtimes/RemoteThreadResource.d.ts.map +1 -1
  40. package/dist/react/runtimes/RemoteThreadResource.js +2 -0
  41. package/dist/react/runtimes/RemoteThreadResource.js.map +1 -1
  42. package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
  43. package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.js +1 -1
  44. package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
  45. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.d.ts.map +1 -1
  46. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js +38 -5
  47. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js.map +1 -1
  48. package/dist/runtime/utils/message-repository.d.ts.map +1 -1
  49. package/dist/runtime/utils/message-repository.js +27 -18
  50. package/dist/runtime/utils/message-repository.js.map +1 -1
  51. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  52. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +2 -0
  53. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  54. package/dist/runtimes/inert/InertThreadRuntimeCore.d.ts +102 -0
  55. package/dist/runtimes/inert/InertThreadRuntimeCore.d.ts.map +1 -0
  56. package/dist/runtimes/inert/InertThreadRuntimeCore.js +156 -0
  57. package/dist/runtimes/inert/InertThreadRuntimeCore.js.map +1 -0
  58. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  59. package/dist/runtimes/local/local-thread-runtime-core.js +25 -6
  60. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  61. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts +12 -60
  62. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
  63. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js +10 -145
  64. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js.map +1 -1
  65. package/dist/runtimes/remote-thread-list/adapter-changed.d.ts +9 -0
  66. package/dist/runtimes/remote-thread-list/adapter-changed.d.ts.map +1 -0
  67. package/dist/runtimes/remote-thread-list/adapter-changed.js +13 -0
  68. package/dist/runtimes/remote-thread-list/adapter-changed.js.map +1 -0
  69. package/dist/runtimes/remote-thread-list/empty-thread-core.d.ts.map +1 -1
  70. package/dist/runtimes/remote-thread-list/empty-thread-core.js +16 -155
  71. package/dist/runtimes/remote-thread-list/empty-thread-core.js.map +1 -1
  72. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts +2 -0
  73. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts.map +1 -1
  74. package/dist/runtimes/remote-thread-list/optimistic-state.js +11 -0
  75. package/dist/runtimes/remote-thread-list/optimistic-state.js.map +1 -1
  76. package/dist/runtimes/remote-thread-list/types.d.ts +3 -0
  77. package/dist/runtimes/remote-thread-list/types.d.ts.map +1 -1
  78. package/dist/store/clients/external-thread.js +1 -1
  79. package/dist/store/clients/external-thread.js.map +1 -1
  80. package/dist/store/runtime-clients/handle-runtime-action.d.ts.map +1 -1
  81. package/dist/store/runtime-clients/handle-runtime-action.js +2 -0
  82. package/dist/store/runtime-clients/handle-runtime-action.js.map +1 -1
  83. package/dist/types/message.d.ts +1 -1
  84. package/dist/types/message.js.map +1 -1
  85. package/dist/utils/silent-runtime-action.d.ts +6 -0
  86. package/dist/utils/silent-runtime-action.d.ts.map +1 -0
  87. package/dist/utils/silent-runtime-action.js +7 -0
  88. package/dist/utils/silent-runtime-action.js.map +1 -0
  89. package/package.json +4 -4
  90. package/src/adapters/voice.test.ts +66 -1
  91. package/src/adapters/voice.ts +8 -0
  92. package/src/internal.ts +1 -0
  93. package/src/model-context/frame/host.test.ts +64 -5
  94. package/src/model-context/frame/host.ts +16 -3
  95. package/src/model-context/frame/provider.test.ts +347 -20
  96. package/src/model-context/frame/provider.ts +58 -2
  97. package/src/model-context/frame/types.ts +5 -0
  98. package/src/react/client/RemoteThreadList.test.ts +196 -0
  99. package/src/react/client/RemoteThreadList.ts +93 -24
  100. package/src/react/primitive-hooks/useActionBarCopy.test.ts +15 -0
  101. package/src/react/primitive-hooks/useActionBarCopy.ts +13 -3
  102. package/src/react/primitive-hooks/useThreadIsEmpty.ts +3 -0
  103. package/src/react/primitive-hooks/useThreadIsRunning.ts +3 -0
  104. package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +12 -0
  105. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.republish.test.ts +87 -0
  106. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +268 -63
  107. package/src/react/runtimes/RemoteThreadResource.ts +2 -0
  108. package/src/react/runtimes/assistant-transport/useAssistantTransportRuntime.test.tsx +58 -0
  109. package/src/react/runtimes/assistant-transport/useAssistantTransportRuntime.ts +3 -1
  110. package/src/react/runtimes/cloud/useCloudThreadListAdapter.test.tsx +98 -3
  111. package/src/react/runtimes/cloud/useCloudThreadListAdapter.tsx +68 -6
  112. package/src/react/runtimes/useLocalRuntime.test.tsx +5 -5
  113. package/src/runtime/utils/message-repository.ts +26 -18
  114. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +4 -0
  115. package/src/runtimes/inert/InertThreadRuntimeCore.test.ts +253 -0
  116. package/src/runtimes/inert/InertThreadRuntimeCore.ts +232 -0
  117. package/src/runtimes/local/local-thread-runtime-core.test.ts +319 -0
  118. package/src/runtimes/local/local-thread-runtime-core.ts +38 -8
  119. package/src/runtimes/readonly/ReadonlyThreadRuntimeCore.ts +17 -209
  120. package/src/runtimes/remote-thread-list/adapter-changed.ts +10 -0
  121. package/src/runtimes/remote-thread-list/empty-thread-core.ts +28 -221
  122. package/src/runtimes/remote-thread-list/optimistic-state.test.ts +18 -0
  123. package/src/runtimes/remote-thread-list/optimistic-state.ts +13 -0
  124. package/src/runtimes/remote-thread-list/types.ts +4 -0
  125. package/src/store/clients/external-thread.ts +1 -1
  126. package/src/store/runtime-clients/handle-runtime-action.test.ts +40 -0
  127. package/src/store/runtime-clients/handle-runtime-action.ts +3 -0
  128. package/src/tests/MessageRepository.test.ts +48 -0
  129. package/src/tests/RemoteThreadListThreadListRuntimeCore-errors.test.ts +2 -2
  130. package/src/tests/RemoteThreadListThreadListRuntimeCore-loadMore.test.ts +8 -6
  131. package/src/tests/empty-thread-core.test.ts +6 -0
  132. package/src/tests/external-store-thread-runtime-core-adapter.test.ts +20 -0
  133. package/src/tests/external-thread-isEmpty.test.tsx +100 -0
  134. package/src/tests/remote-thread-list-adapter-switch.test.ts +383 -0
  135. package/src/tests/useRemoteThreadListRuntime-controlled.test.tsx +67 -0
  136. package/src/types/message.ts +1 -1
  137. package/src/utils/silent-runtime-action.ts +6 -0
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { RemoteThreadListThreadListRuntimeCore } from "./RemoteThreadListThreadListRuntimeCore";
3
+ import { ThreadListRuntimeImpl } from "../../runtime/api/thread-list-runtime";
4
+ import { ReadonlyThreadRuntimeCore } from "../../runtimes/readonly/ReadonlyThreadRuntimeCore";
5
+ import {
6
+ contextProvider,
7
+ makeAdapter,
8
+ } from "../../tests/remote-thread-list-test-helpers";
9
+ import type { ThreadMessage } from "../../types/message";
10
+
11
+ const message = (id: string): ThreadMessage =>
12
+ ({
13
+ id,
14
+ role: "user",
15
+ content: [{ type: "text", text: id }],
16
+ createdAt: new Date(0),
17
+ attachments: [],
18
+ metadata: { custom: {} },
19
+ }) as unknown as ThreadMessage;
20
+
21
+ const setup = () => {
22
+ const core = new RemoteThreadListThreadListRuntimeCore(
23
+ { adapter: makeAdapter(), runtimeHook: () => ({}) as never },
24
+ contextProvider,
25
+ );
26
+ const hookManager = (
27
+ core as unknown as {
28
+ _hookManager: {
29
+ _publishThreadRuntime: (
30
+ id: string,
31
+ runtime: ReadonlyThreadRuntimeCore,
32
+ generation: number,
33
+ ) => void;
34
+ instances: Map<string, { generation: number }>;
35
+ };
36
+ }
37
+ )._hookManager;
38
+ const threadId = core.mainThreadId!;
39
+ const publish = (runtime: ReadonlyThreadRuntimeCore) =>
40
+ hookManager._publishThreadRuntime(
41
+ threadId,
42
+ runtime,
43
+ hookManager.instances.get(threadId)!.generation,
44
+ );
45
+ return { core, publish };
46
+ };
47
+
48
+ describe("RemoteThreadListThreadListRuntimeCore republish", () => {
49
+ it("keeps the subscribed main thread state on the republished core", async () => {
50
+ const { core, publish } = setup();
51
+ const first = new ReadonlyThreadRuntimeCore();
52
+ first.setMessages([message("m1")]);
53
+ publish(first);
54
+
55
+ const thread = new ThreadListRuntimeImpl(core).main;
56
+ thread.subscribe(() => {});
57
+ expect(thread.getState().messages.map((m) => m.id)).toEqual(["m1"]);
58
+
59
+ const second = new ReadonlyThreadRuntimeCore();
60
+ second.setMessages([message("m2")]);
61
+ publish(second);
62
+ await Promise.resolve();
63
+
64
+ const ids = thread.getState().messages.map((m) => m.id);
65
+ expect(ids).toEqual(["m2"]);
66
+ for (const id of ids) {
67
+ expect(() => thread.getMessageById(id).getState()).not.toThrow();
68
+ }
69
+ });
70
+
71
+ it("notifies thread subscribers when the core is republished", async () => {
72
+ const { core, publish } = setup();
73
+ publish(new ReadonlyThreadRuntimeCore());
74
+
75
+ const thread = new ThreadListRuntimeImpl(core).main;
76
+ let notified = 0;
77
+ thread.subscribe(() => notified++);
78
+
79
+ const next = new ReadonlyThreadRuntimeCore();
80
+ next.setMessages([message("m1")]);
81
+ publish(next);
82
+ await Promise.resolve();
83
+
84
+ expect(notified).toBeGreaterThan(0);
85
+ expect(thread.getState().messages.map((m) => m.id)).toEqual(["m1"]);
86
+ });
87
+ });
@@ -17,7 +17,9 @@ import {
17
17
  import type {
18
18
  RemoteThreadListAdapter,
19
19
  RemoteThreadListOptions,
20
+ RemoteThreadMetadata,
20
21
  } from "../../runtimes/remote-thread-list/types";
22
+ import { ThreadListAdapterChangedError } from "../../runtimes/remote-thread-list/adapter-changed";
21
23
  import { RemoteThreadListHookInstanceManager } from "./RemoteThreadListHookInstanceManager";
22
24
  import { isTitleSourceMessage } from "./RemoteThreadResource";
23
25
  import {
@@ -47,6 +49,48 @@ const threadStatusError = (
47
49
  `Thread "${threadIdOrRemoteId}" has status "${status}", so it cannot ${action}.`,
48
50
  );
49
51
 
52
+ const EMPTY_REMOTE_STATE: RemoteThreadState = {
53
+ isLoading: true,
54
+ isLoadingMore: false,
55
+ cursor: undefined,
56
+ newThreadId: undefined,
57
+ threadIds: [],
58
+ archivedThreadIds: [],
59
+ threadIdMap: {},
60
+ threadData: {},
61
+ };
62
+
63
+ const addNewThread = (state: RemoteThreadState) => {
64
+ let id: string;
65
+ do {
66
+ id = `__LOCALID_${generateId()}`;
67
+ } while (state.threadIdMap[id]);
68
+
69
+ const mappingId = createThreadMappingId(id);
70
+ return {
71
+ id,
72
+ state: {
73
+ ...state,
74
+ newThreadId: id,
75
+ threadIdMap: {
76
+ ...state.threadIdMap,
77
+ [id]: mappingId,
78
+ },
79
+ threadData: {
80
+ ...state.threadData,
81
+ [mappingId]: {
82
+ status: "new",
83
+ id,
84
+ remoteId: undefined,
85
+ externalId: undefined,
86
+ title: undefined,
87
+ custom: undefined,
88
+ } satisfies RemoteThreadData,
89
+ },
90
+ },
91
+ };
92
+ };
93
+
50
94
  export class RemoteThreadListThreadListRuntimeCore
51
95
  extends BaseSubscribable
52
96
  implements ThreadListRuntimeCore
@@ -58,20 +102,16 @@ export class RemoteThreadListThreadListRuntimeCore
58
102
  private _loadThreadsPromise: Promise<void> | undefined;
59
103
  private _loadMorePromise: Promise<void> | undefined;
60
104
  private _loadGeneration = 0;
105
+ private _adapterGeneration = 0;
106
+ private _replaceListOnNextLoad = false;
107
+ private _staleThreadIdsOnReplace: ReadonlySet<string> | undefined;
61
108
  private _switchGeneration = 0;
62
109
  private _switchTask: Promise<void> | undefined;
63
110
 
64
111
  private _mainThreadId!: string;
65
- private readonly _state = new OptimisticState<RemoteThreadState>({
66
- isLoading: true,
67
- isLoadingMore: false,
68
- cursor: undefined,
69
- newThreadId: undefined,
70
- threadIds: [],
71
- archivedThreadIds: [],
72
- threadIdMap: {},
73
- threadData: {},
74
- });
112
+ private readonly _state = new OptimisticState<RemoteThreadState>(
113
+ EMPTY_REMOTE_STATE,
114
+ );
75
115
 
76
116
  private readonly _useAdaptersProvider: FC<PropsWithChildren> = ({
77
117
  children,
@@ -117,6 +157,7 @@ export class RemoteThreadListThreadListRuntimeCore
117
157
  // TODO this needs to be cached in case this promise is loaded during suspense
118
158
  if (!this._loadThreadsPromise) {
119
159
  const generation = this._loadGeneration;
160
+ let replacedList = false;
120
161
  this._loadThreadsPromise = this._state
121
162
  .optimisticUpdate({
122
163
  execute: () => this._options.adapter.list(),
@@ -128,27 +169,31 @@ export class RemoteThreadListThreadListRuntimeCore
128
169
  },
129
170
  then: (state, l) => {
130
171
  if (generation !== this._loadGeneration) return state;
172
+ const replaceList = this._replaceListOnNextLoad;
173
+ if (replaceList) {
174
+ this._replaceListOnNextLoad = false;
175
+ replacedList = true;
176
+ return this._replaceWithThreads(
177
+ state,
178
+ l.threads,
179
+ normalizeCursor(l.nextCursor),
180
+ );
181
+ }
182
+
131
183
  const fresh = classifyThreads(l.threads, {
132
184
  threadIds: [],
133
185
  archivedThreadIds: [],
134
186
  threadIdMap: {},
135
187
  threadData: {},
136
188
  });
137
-
138
189
  return {
139
190
  ...state,
140
191
  isLoading: false,
141
192
  cursor: normalizeCursor(l.nextCursor),
142
193
  threadIds: fresh.threadIds,
143
194
  archivedThreadIds: fresh.archivedThreadIds,
144
- threadIdMap: {
145
- ...state.threadIdMap,
146
- ...fresh.threadIdMap,
147
- },
148
- threadData: {
149
- ...state.threadData,
150
- ...fresh.threadData,
151
- },
195
+ threadIdMap: { ...state.threadIdMap, ...fresh.threadIdMap },
196
+ threadData: { ...state.threadData, ...fresh.threadData },
152
197
  };
153
198
  },
154
199
  })
@@ -156,12 +201,26 @@ export class RemoteThreadListThreadListRuntimeCore
156
201
  if (generation !== this._loadGeneration) return;
157
202
  console.error("[assistant-ui] thread list load failed:", error);
158
203
  this._loadThreadsPromise = undefined;
159
- this._state.update({
160
- ...this._state.baseValue,
161
- isLoading: false,
162
- });
204
+ if (!this._replaceListOnNextLoad) {
205
+ this._state.update({
206
+ ...this._state.baseValue,
207
+ isLoading: false,
208
+ });
209
+ return;
210
+ }
211
+ this._replaceListOnNextLoad = false;
212
+ replacedList = true;
213
+ this._state.update(
214
+ this._replaceWithThreads(this._state.baseValue, [], undefined),
215
+ );
163
216
  })
164
- .then(() => {});
217
+ .then(() => {
218
+ if (!replacedList) return;
219
+ const threadId = this._options.threadId;
220
+ if (threadId === undefined) return;
221
+ if (this.getItemById(threadId)?.id === this._mainThreadId) return;
222
+ this._switchToThreadFromProp(threadId).catch(() => {});
223
+ });
165
224
  }
166
225
 
167
226
  return this._loadThreadsPromise;
@@ -206,6 +265,7 @@ export class RemoteThreadListThreadListRuntimeCore
206
265
  },
207
266
  })
208
267
  .catch((error: unknown) => {
268
+ if (generation !== this._loadGeneration) return;
209
269
  console.error("[assistant-ui] thread list loadMore failed:", error);
210
270
  })
211
271
  .then(() => {
@@ -237,6 +297,11 @@ export class RemoteThreadListThreadListRuntimeCore
237
297
  this._hookManager.__internal_subscribeRunningChanged(() =>
238
298
  this._notifySubscribers(),
239
299
  );
300
+ this._hookManager.__internal_subscribeRuntimeReplaced(() => {
301
+ // A republish can land during the thread resource's render, where a
302
+ // synchronous notify would re-enter store consumers mid-render.
303
+ queueMicrotask(() => this._notifySubscribers());
304
+ });
240
305
  this.useProvider = create(() => ({
241
306
  Provider: this.resolveProvider(options.adapter),
242
307
  }));
@@ -268,8 +333,17 @@ export class RemoteThreadListThreadListRuntimeCore
268
333
 
269
334
  if (adapterChanged) {
270
335
  this._loadGeneration++;
336
+ this._adapterGeneration++;
337
+ this._switchGeneration++;
338
+ this._switchTask = undefined;
271
339
  this._loadThreadsPromise = undefined;
272
340
  this._loadMorePromise = undefined;
341
+ this._replaceListOnNextLoad = true;
342
+ this._staleThreadIdsOnReplace = new Set(
343
+ Object.values(this._state.baseValue.threadData)
344
+ .filter((item) => item.status !== "new")
345
+ .map((item) => item.id),
346
+ );
273
347
  this._state.update({
274
348
  ...this._state.baseValue,
275
349
  cursor: undefined,
@@ -281,6 +355,115 @@ export class RemoteThreadListThreadListRuntimeCore
281
355
  }
282
356
  }
283
357
 
358
+ private _requireAdapterGeneration(generation: number) {
359
+ if (generation !== this._adapterGeneration) {
360
+ throw new ThreadListAdapterChangedError();
361
+ }
362
+ }
363
+
364
+ private _requireAdapterSettled() {
365
+ if (this._replaceListOnNextLoad) {
366
+ throw new ThreadListAdapterChangedError();
367
+ }
368
+ }
369
+
370
+ private _replaceWithThreads(
371
+ state: RemoteThreadState,
372
+ threads: readonly RemoteThreadMetadata[],
373
+ cursor: string | undefined,
374
+ ): RemoteThreadState {
375
+ const fresh = classifyThreads(threads, {
376
+ threadIds: [],
377
+ archivedThreadIds: [],
378
+ threadIdMap: {},
379
+ threadData: {},
380
+ });
381
+ const threadIdMap = { ...fresh.threadIdMap };
382
+ const threadData = { ...fresh.threadData };
383
+ const threadIds = [...fresh.threadIds];
384
+ const archivedThreadIds = [...fresh.archivedThreadIds];
385
+
386
+ if (state.newThreadId) {
387
+ const mappingId = state.threadIdMap[state.newThreadId];
388
+ const draft = mappingId ? state.threadData[mappingId] : undefined;
389
+ if (draft?.status === "new") {
390
+ threadIdMap[state.newThreadId] = mappingId!;
391
+ threadData[mappingId!] = draft;
392
+ }
393
+ }
394
+
395
+ const stale = this._staleThreadIdsOnReplace;
396
+ if (stale) {
397
+ for (const item of Object.values(state.threadData)) {
398
+ if (stale.has(item.id)) continue;
399
+ if (item.status !== "new" && item.remoteId === undefined) continue;
400
+ const mappingId = createThreadMappingId(item.id);
401
+ if (threadData[mappingId]) continue;
402
+ threadIdMap[item.id] = mappingId;
403
+ if (item.remoteId !== undefined) {
404
+ threadIdMap[item.remoteId] = mappingId;
405
+ }
406
+ threadData[mappingId] = item;
407
+ if (item.remoteId === undefined) continue;
408
+ if (item.status === "regular" && !threadIds.includes(item.remoteId)) {
409
+ threadIds.push(item.remoteId);
410
+ } else if (
411
+ item.status === "archived" &&
412
+ !archivedThreadIds.includes(item.remoteId)
413
+ ) {
414
+ archivedThreadIds.push(item.remoteId);
415
+ }
416
+ }
417
+ }
418
+ this._staleThreadIdsOnReplace = undefined;
419
+
420
+ let nextState: RemoteThreadState = {
421
+ ...state,
422
+ isLoading: false,
423
+ cursor,
424
+ threadIds,
425
+ archivedThreadIds,
426
+ threadIdMap,
427
+ threadData,
428
+ newThreadId:
429
+ state.newThreadId !== undefined &&
430
+ threadIdMap[state.newThreadId] === undefined
431
+ ? undefined
432
+ : state.newThreadId,
433
+ };
434
+
435
+ if (getThreadData(nextState, this._mainThreadId) === undefined) {
436
+ const preservedDraft = nextState.newThreadId;
437
+ if (preservedDraft !== undefined) {
438
+ this._mainThreadId = preservedDraft;
439
+ } else {
440
+ const seeded = addNewThread(nextState);
441
+ this._mainThreadId = seeded.id;
442
+ nextState = seeded.state;
443
+ }
444
+ if (this._options.threadId === undefined) {
445
+ this._notifyThreadIdChange();
446
+ } else {
447
+ this._lastNotifiedThreadId = undefined;
448
+ }
449
+ }
450
+
451
+ const nextIds = new Set(
452
+ Object.values(nextState.threadData).map((item) => item.id),
453
+ );
454
+ for (const item of Object.values(state.threadData)) {
455
+ if (!nextIds.has(item.id)) {
456
+ this._hookManager.stopThreadRuntime(item.id);
457
+ }
458
+ }
459
+ void this._hookManager.startThreadRuntime(this._mainThreadId).then(
460
+ () => this._notifySubscribers(),
461
+ () => undefined,
462
+ );
463
+
464
+ return nextState;
465
+ }
466
+
284
467
  public __internal_load() {
285
468
  this.getLoadThreadsPromise(); // begin loading on initial bind
286
469
  if (this._initialThreadLoaded) return;
@@ -440,6 +623,12 @@ export class RemoteThreadListThreadListRuntimeCore
440
623
  generation: number,
441
624
  emitThreadIdChange: boolean,
442
625
  ): Promise<void> {
626
+ if (
627
+ this._replaceListOnNextLoad &&
628
+ threadIdOrRemoteId !== this._state.value.newThreadId
629
+ ) {
630
+ throw new ThreadListAdapterChangedError();
631
+ }
443
632
  let data = this.getItemById(threadIdOrRemoteId);
444
633
 
445
634
  if (!data) {
@@ -573,48 +762,32 @@ export class RemoteThreadListThreadListRuntimeCore
573
762
  const state = this._state.baseValue;
574
763
  let id: string | undefined = this._state.value.newThreadId;
575
764
  if (id === undefined) {
576
- do {
577
- id = `__LOCALID_${generateId()}`;
578
- } while (state.threadIdMap[id]);
579
-
580
- const mappingId = createThreadMappingId(id);
581
- this._state.update({
582
- ...state,
583
- newThreadId: id,
584
- threadIdMap: {
585
- ...state.threadIdMap,
586
- [id]: mappingId,
587
- },
588
- threadData: {
589
- ...state.threadData,
590
- [mappingId]: {
591
- status: "new",
592
- id,
593
- remoteId: undefined,
594
- externalId: undefined,
595
- title: undefined,
596
- custom: undefined,
597
- } satisfies RemoteThreadData,
598
- },
599
- });
765
+ const next = addNewThread(state);
766
+ id = next.id;
767
+ this._state.update(next.state);
600
768
  }
601
769
 
602
770
  return this._switchToThread(id, undefined, generation, emitThreadIdChange);
603
771
  }
604
772
 
605
773
  public initialize = async (threadId: string) => {
774
+ const adapter = this._options.adapter;
775
+ const adapterGeneration = this._adapterGeneration;
606
776
  if (this._state.value.newThreadId !== threadId) {
777
+ this._requireAdapterSettled();
607
778
  const data = this.getItemById(threadId);
608
779
  if (!data) throw threadNotFoundError(threadId, "initializing it");
609
780
  if (data.status === "new")
610
781
  throw threadStatusError(threadId, data.status, "be initialized here");
611
782
  const { remoteId, externalId } = await data.initializeTask;
783
+ this._requireAdapterGeneration(adapterGeneration);
612
784
  return { remoteId, externalId };
613
785
  }
614
786
 
615
787
  const { remoteId, externalId } = await this._state.optimisticUpdate({
616
788
  execute: () => {
617
- return this._options.adapter.initialize(threadId);
789
+ this._requireAdapterGeneration(adapterGeneration);
790
+ return adapter.initialize(threadId);
618
791
  },
619
792
  optimistic: (state) => {
620
793
  return updateStatusReducer(state, threadId, "regular");
@@ -633,6 +806,7 @@ export class RemoteThreadListThreadListRuntimeCore
633
806
  };
634
807
  },
635
808
  then: (state, { remoteId, externalId }) => {
809
+ if (adapterGeneration !== this._adapterGeneration) return state;
636
810
  const data = getThreadData(state, threadId);
637
811
  if (!data) return state;
638
812
 
@@ -655,16 +829,21 @@ export class RemoteThreadListThreadListRuntimeCore
655
829
  };
656
830
  },
657
831
  });
832
+ this._requireAdapterGeneration(adapterGeneration);
658
833
  return { remoteId, externalId };
659
834
  };
660
835
 
661
836
  public generateTitle = async (threadId: string) => {
837
+ this._requireAdapterSettled();
838
+ const adapter = this._options.adapter;
839
+ const adapterGeneration = this._adapterGeneration;
662
840
  const data = this.getItemById(threadId);
663
841
  if (!data) throw threadNotFoundError(threadId, "generating its title");
664
842
  if (data.status === "new")
665
843
  throw threadStatusError(threadId, data.status, "generate a title");
666
844
 
667
845
  const { remoteId } = await data.initializeTask;
846
+ this._requireAdapterGeneration(adapterGeneration);
668
847
 
669
848
  const runtimeCore = this._hookManager.getThreadRuntimeCore(data.id);
670
849
  if (!runtimeCore) return; // thread is no longer running
@@ -673,12 +852,10 @@ export class RemoteThreadListThreadListRuntimeCore
673
852
  // would make the payload race-dependent; the title reads settled
674
853
  // messages only, matching the trigger's readiness gate.
675
854
  const messages = runtimeCore.messages.filter(isTitleSourceMessage);
676
- const stream = await this._options.adapter.generateTitle(
677
- remoteId,
678
- messages,
679
- );
855
+ const stream = await adapter.generateTitle(remoteId, messages);
680
856
  const messageStream = AssistantMessageStream.fromAssistantStream(stream);
681
857
  for await (const result of messageStream) {
858
+ if (adapterGeneration !== this._adapterGeneration) return;
682
859
  const newTitle = result.parts.filter((c) => c.type === "text")[0]?.text;
683
860
  const state = this._state.baseValue;
684
861
  const currentData = getThreadData(state, data.id);
@@ -696,7 +873,13 @@ export class RemoteThreadListThreadListRuntimeCore
696
873
  }
697
874
  };
698
875
 
699
- public rename(threadIdOrRemoteId: string, newTitle: string): Promise<void> {
876
+ public async rename(
877
+ threadIdOrRemoteId: string,
878
+ newTitle: string,
879
+ ): Promise<void> {
880
+ this._requireAdapterSettled();
881
+ const adapter = this._options.adapter;
882
+ const adapterGeneration = this._adapterGeneration;
700
883
  const data = this.getItemById(threadIdOrRemoteId);
701
884
  if (!data) throw threadNotFoundError(threadIdOrRemoteId, "renaming it");
702
885
  if (data.status === "new")
@@ -705,7 +888,8 @@ export class RemoteThreadListThreadListRuntimeCore
705
888
  return this._state.optimisticUpdate({
706
889
  execute: async () => {
707
890
  const { remoteId } = await data.initializeTask;
708
- return this._options.adapter.rename(remoteId, newTitle);
891
+ this._requireAdapterGeneration(adapterGeneration);
892
+ return adapter.rename(remoteId, newTitle);
709
893
  },
710
894
  optimistic: (state) => {
711
895
  const data = getThreadData(state, threadIdOrRemoteId);
@@ -725,10 +909,13 @@ export class RemoteThreadListThreadListRuntimeCore
725
909
  });
726
910
  }
727
911
 
728
- public updateCustom(
912
+ public async updateCustom(
729
913
  threadIdOrRemoteId: string,
730
914
  custom: Record<string, unknown> | undefined,
731
915
  ): Promise<void> {
916
+ this._requireAdapterSettled();
917
+ const adapter = this._options.adapter;
918
+ const adapterGeneration = this._adapterGeneration;
732
919
  const data = this.getItemById(threadIdOrRemoteId);
733
920
  if (!data)
734
921
  throw threadNotFoundError(
@@ -742,7 +929,7 @@ export class RemoteThreadListThreadListRuntimeCore
742
929
  "update custom metadata",
743
930
  );
744
931
 
745
- if (!this._options.adapter.updateCustom) {
932
+ if (!adapter.updateCustom) {
746
933
  throw new Error(
747
934
  "Remote thread list adapter does not support updating custom metadata",
748
935
  );
@@ -751,7 +938,7 @@ export class RemoteThreadListThreadListRuntimeCore
751
938
  return this._state.optimisticUpdate({
752
939
  execute: async () => {
753
940
  const { remoteId } = await data.initializeTask;
754
- const adapter = this._options.adapter;
941
+ this._requireAdapterGeneration(adapterGeneration);
755
942
  if (!adapter.updateCustom) {
756
943
  throw new Error(
757
944
  "Remote thread list adapter does not support updating custom metadata",
@@ -800,17 +987,22 @@ export class RemoteThreadListThreadListRuntimeCore
800
987
  }
801
988
 
802
989
  public async archive(threadIdOrRemoteId: string) {
990
+ this._requireAdapterSettled();
991
+ const adapter = this._options.adapter;
992
+ const adapterGeneration = this._adapterGeneration;
803
993
  const data = this.getItemById(threadIdOrRemoteId);
804
994
  if (!data) throw threadNotFoundError(threadIdOrRemoteId, "archiving it");
805
995
  if (data.status !== "regular")
806
996
  throw threadStatusError(threadIdOrRemoteId, data.status, "be archived");
807
997
 
808
998
  await this._ensureThreadIsNotMain(data.id);
999
+ this._requireAdapterGeneration(adapterGeneration);
809
1000
 
810
1001
  return this._state.optimisticUpdate({
811
1002
  execute: async () => {
812
1003
  const { remoteId } = await data.initializeTask;
813
- return this._options.adapter.archive(remoteId);
1004
+ this._requireAdapterGeneration(adapterGeneration);
1005
+ return adapter.archive(remoteId);
814
1006
  },
815
1007
  optimistic: (state) => {
816
1008
  return updateStatusReducer(state, data.id, "archived");
@@ -818,7 +1010,10 @@ export class RemoteThreadListThreadListRuntimeCore
818
1010
  });
819
1011
  }
820
1012
 
821
- public unarchive(threadIdOrRemoteId: string): Promise<void> {
1013
+ public async unarchive(threadIdOrRemoteId: string): Promise<void> {
1014
+ this._requireAdapterSettled();
1015
+ const adapter = this._options.adapter;
1016
+ const adapterGeneration = this._adapterGeneration;
822
1017
  const data = this.getItemById(threadIdOrRemoteId);
823
1018
  if (!data) throw threadNotFoundError(threadIdOrRemoteId, "unarchiving it");
824
1019
  if (data.status !== "archived")
@@ -828,9 +1023,12 @@ export class RemoteThreadListThreadListRuntimeCore
828
1023
  execute: async () => {
829
1024
  try {
830
1025
  const { remoteId } = await data.initializeTask;
831
- return await this._options.adapter.unarchive(remoteId);
1026
+ this._requireAdapterGeneration(adapterGeneration);
1027
+ return await adapter.unarchive(remoteId);
832
1028
  } catch (error) {
833
- await this._ensureThreadIsNotMain(data.id);
1029
+ if (adapterGeneration === this._adapterGeneration) {
1030
+ await this._ensureThreadIsNotMain(data.id);
1031
+ }
834
1032
  throw error;
835
1033
  }
836
1034
  },
@@ -841,18 +1039,23 @@ export class RemoteThreadListThreadListRuntimeCore
841
1039
  }
842
1040
 
843
1041
  public async delete(threadIdOrRemoteId: string) {
1042
+ this._requireAdapterSettled();
1043
+ const adapter = this._options.adapter;
1044
+ const adapterGeneration = this._adapterGeneration;
844
1045
  const data = this.getItemById(threadIdOrRemoteId);
845
1046
  if (!data) throw threadNotFoundError(threadIdOrRemoteId, "deleting it");
846
1047
  if (data.status !== "regular" && data.status !== "archived")
847
1048
  throw threadStatusError(threadIdOrRemoteId, data.status, "be deleted");
848
1049
 
849
1050
  await this._ensureThreadIsNotMain(data.id);
1051
+ this._requireAdapterGeneration(adapterGeneration);
850
1052
  this._hookManager.stopThreadRuntime(data.id);
851
1053
 
852
1054
  return this._state.optimisticUpdate({
853
1055
  execute: async () => {
854
1056
  const { remoteId } = await data.initializeTask;
855
- return await this._options.adapter.delete(remoteId);
1057
+ this._requireAdapterGeneration(adapterGeneration);
1058
+ return await adapter.delete(remoteId);
856
1059
  },
857
1060
  optimistic: (state) => {
858
1061
  return updateStatusReducer(state, data.id, "deleted");
@@ -865,12 +1068,14 @@ export class RemoteThreadListThreadListRuntimeCore
865
1068
  }
866
1069
 
867
1070
  public async detach(threadIdOrRemoteId: string): Promise<void> {
1071
+ const adapterGeneration = this._adapterGeneration;
868
1072
  const data = this.getItemById(threadIdOrRemoteId);
869
1073
  if (!data) throw threadNotFoundError(threadIdOrRemoteId, "detaching it");
870
1074
  if (data.status !== "regular" && data.status !== "archived")
871
1075
  throw threadStatusError(threadIdOrRemoteId, data.status, "be detached");
872
1076
 
873
1077
  await this._ensureThreadIsNotMain(data.id);
1078
+ this._requireAdapterGeneration(adapterGeneration);
874
1079
  this._hookManager.stopThreadRuntime(data.id);
875
1080
  }
876
1081
 
@@ -21,6 +21,7 @@ import {
21
21
  useRuntimeAdaptersProvider,
22
22
  type RuntimeAdapters,
23
23
  } from "./RuntimeAdapterProvider";
24
+ import { isSilentRuntimeAction } from "../../utils/silent-runtime-action";
24
25
 
25
26
  export type RemoteThreadListHook = () => AssistantRuntime;
26
27
 
@@ -48,6 +49,7 @@ export const subscribeToTitleGeneration = (
48
49
  ) => {
49
50
  const generate = () =>
50
51
  void itemRuntime.generateTitle().catch((error: unknown) => {
52
+ if (isSilentRuntimeAction(error)) return;
51
53
  console.error("[assistant-ui] Thread title generation failed", error);
52
54
  });
53
55