@assistant-ui/core 0.3.17 → 0.3.18
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/dist/adapters/attachment.d.ts.map +1 -1
- package/dist/adapters/attachment.js +5 -4
- package/dist/adapters/attachment.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +3 -2
- package/dist/internal.js +3 -2
- package/dist/model-context/frame/host.d.ts.map +1 -1
- package/dist/model-context/frame/host.js +10 -4
- package/dist/model-context/frame/host.js.map +1 -1
- package/dist/model-context/frame/provider.d.ts +2 -0
- package/dist/model-context/frame/provider.d.ts.map +1 -1
- package/dist/model-context/frame/provider.js +30 -7
- package/dist/model-context/frame/provider.js.map +1 -1
- package/dist/model-context/interactable-composer-metadata.d.ts.map +1 -1
- package/dist/model-context/interactable-composer-metadata.js +16 -9
- package/dist/model-context/interactable-composer-metadata.js.map +1 -1
- package/dist/model-context/registry.d.ts.map +1 -1
- package/dist/model-context/registry.js +3 -5
- package/dist/model-context/registry.js.map +1 -1
- package/dist/model-context/types.d.ts.map +1 -1
- package/dist/model-context/types.js +5 -4
- package/dist/model-context/types.js.map +1 -1
- package/dist/react/client/InMemoryThreadList.d.ts.map +1 -1
- package/dist/react/client/InMemoryThreadList.js +62 -56
- package/dist/react/client/InMemoryThreadList.js.map +1 -1
- package/dist/react/client/Interactables.d.ts.map +1 -1
- package/dist/react/client/Interactables.js +24 -27
- package/dist/react/client/Interactables.js.map +1 -1
- package/dist/react/client/RemoteThreadList.d.ts +1 -0
- package/dist/react/client/RemoteThreadList.d.ts.map +1 -1
- package/dist/react/client/RemoteThreadList.js +151 -141
- package/dist/react/client/RemoteThreadList.js.map +1 -1
- package/dist/react/client/Tools.d.ts.map +1 -1
- package/dist/react/client/Tools.js +18 -16
- package/dist/react/client/Tools.js.map +1 -1
- package/dist/react/interactables-legacy/Interactables.js +242 -121
- package/dist/react/interactables-legacy/Interactables.js.map +1 -1
- package/dist/react/interactables-shared/useInteractablePersistenceQueue.d.ts.map +1 -1
- package/dist/react/interactables-shared/useInteractablePersistenceQueue.js +11 -9
- package/dist/react/interactables-shared/useInteractablePersistenceQueue.js.map +1 -1
- package/dist/react/model-context/makeAssistantTool.d.ts +2 -2
- package/dist/react/model-context/makeAssistantTool.js +1 -1
- package/dist/react/model-context/makeAssistantTool.js.map +1 -1
- package/dist/react/model-context/useAssistantTool.d.ts +2 -2
- package/dist/react/model-context/useAssistantTool.js +1 -1
- package/dist/react/model-context/useAssistantTool.js.map +1 -1
- package/dist/react/model-context/useToolArgsStatus.d.ts.map +1 -1
- package/dist/react/model-context/useToolArgsStatus.js +2 -1
- package/dist/react/model-context/useToolArgsStatus.js.map +1 -1
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +16 -8
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +5 -1
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +103 -103
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
- package/dist/react/runtimes/RemoteThreadResource.d.ts +1 -0
- package/dist/react/runtimes/RemoteThreadResource.d.ts.map +1 -1
- package/dist/react/runtimes/RemoteThreadResource.js +13 -11
- package/dist/react/runtimes/RemoteThreadResource.js.map +1 -1
- package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.d.ts +4 -1
- package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.d.ts.map +1 -1
- package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js +401 -34
- package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js.map +1 -1
- package/dist/react/runtimes/cloud/createCloudThreadListAdapter.d.ts.map +1 -1
- package/dist/react/runtimes/cloud/createCloudThreadListAdapter.js +2 -1
- package/dist/react/runtimes/cloud/createCloudThreadListAdapter.js.map +1 -1
- package/dist/react/runtimes/useExternalStoreRuntime.js +75 -46
- package/dist/react/runtimes/useExternalStoreRuntime.js.map +1 -1
- package/dist/react/runtimes/useRemoteThreadListRuntime.d.ts.map +1 -1
- package/dist/react/runtimes/useRemoteThreadListRuntime.js +63 -23
- package/dist/react/runtimes/useRemoteThreadListRuntime.js.map +1 -1
- package/dist/runtime/api/thread-list-item-runtime.d.ts +7 -3
- package/dist/runtime/api/thread-list-item-runtime.d.ts.map +1 -1
- package/dist/runtime/api/thread-list-item-runtime.js +2 -2
- package/dist/runtime/api/thread-list-item-runtime.js.map +1 -1
- package/dist/runtime/api/thread-list-runtime.d.ts +3 -0
- package/dist/runtime/api/thread-list-runtime.d.ts.map +1 -1
- package/dist/runtime/api/thread-list-runtime.js +18 -14
- package/dist/runtime/api/thread-list-runtime.js.map +1 -1
- package/dist/runtime/api/thread-runtime.d.ts +4 -0
- package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
- package/dist/runtime/api/thread-runtime.js +1 -1
- package/dist/runtime/api/thread-runtime.js.map +1 -1
- package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
- package/dist/runtime/base/base-composer-runtime-core.js +16 -12
- package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
- package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtime/base/base-thread-runtime-core.js +6 -5
- package/dist/runtime/base/base-thread-runtime-core.js.map +1 -1
- package/dist/runtime/interfaces/composer-runtime-core.d.ts +12 -5
- package/dist/runtime/interfaces/composer-runtime-core.d.ts.map +1 -1
- package/dist/runtime/interfaces/thread-list-runtime-core.d.ts +4 -1
- package/dist/runtime/interfaces/thread-list-runtime-core.d.ts.map +1 -1
- package/dist/runtime/utils/external-message-conversion.js +1 -1
- package/dist/runtime/utils/external-message-conversion.js.map +1 -1
- package/dist/runtime/utils/message-repository.d.ts +1 -0
- package/dist/runtime/utils/message-repository.d.ts.map +1 -1
- package/dist/runtime/utils/message-repository.js +15 -6
- package/dist/runtime/utils/message-repository.js.map +1 -1
- package/dist/runtime/utils/thread-message-like.js +1 -1
- package/dist/runtime/utils/thread-message-like.js.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-list-runtime-core.js +21 -27
- package/dist/runtimes/external-store/external-store-thread-list-runtime-core.js.map +1 -1
- package/dist/runtimes/internal.d.ts +2 -2
- package/dist/runtimes/internal.js +2 -2
- package/dist/runtimes/remote-thread-list/remote-thread-state.d.ts +18 -1
- package/dist/runtimes/remote-thread-list/remote-thread-state.d.ts.map +1 -1
- package/dist/runtimes/remote-thread-list/remote-thread-state.js +130 -42
- package/dist/runtimes/remote-thread-list/remote-thread-state.js.map +1 -1
- package/dist/runtimes/remote-thread-list/title-generation.d.ts +49 -0
- package/dist/runtimes/remote-thread-list/title-generation.d.ts.map +1 -0
- package/dist/runtimes/remote-thread-list/title-generation.js +197 -0
- package/dist/runtimes/remote-thread-list/title-generation.js.map +1 -0
- package/dist/runtimes/remote-thread-list/title.d.ts +3 -1
- package/dist/runtimes/remote-thread-list/title.d.ts.map +1 -1
- package/dist/runtimes/remote-thread-list/title.js +9 -1
- package/dist/runtimes/remote-thread-list/title.js.map +1 -1
- package/dist/runtimes/remote-thread-list/types.d.ts +10 -0
- package/dist/runtimes/remote-thread-list/types.d.ts.map +1 -1
- package/dist/store/clients/external-thread.d.ts.map +1 -1
- package/dist/store/clients/external-thread.js +19 -3
- package/dist/store/clients/external-thread.js.map +1 -1
- package/dist/store/clients/model-context-client.d.ts.map +1 -1
- package/dist/store/clients/model-context-client.js +2 -1
- package/dist/store/clients/model-context-client.js.map +1 -1
- package/dist/store/clients/single-thread-list.d.ts.map +1 -1
- package/dist/store/clients/single-thread-list.js +1 -0
- package/dist/store/clients/single-thread-list.js.map +1 -1
- package/dist/store/clients/suggestions.d.ts.map +1 -1
- package/dist/store/clients/suggestions.js +5 -4
- package/dist/store/clients/suggestions.js.map +1 -1
- package/dist/store/runtime-clients/composer-runtime-client.d.ts +1 -0
- package/dist/store/runtime-clients/composer-runtime-client.d.ts.map +1 -1
- package/dist/store/runtime-clients/composer-runtime-client.js +97 -61
- package/dist/store/runtime-clients/composer-runtime-client.js.map +1 -1
- package/dist/store/runtime-clients/message-runtime-client.d.ts +1 -0
- package/dist/store/runtime-clients/message-runtime-client.d.ts.map +1 -1
- package/dist/store/runtime-clients/message-runtime-client.js +221 -144
- package/dist/store/runtime-clients/message-runtime-client.js.map +1 -1
- package/dist/store/runtime-clients/thread-list-item-runtime-client.d.ts.map +1 -1
- package/dist/store/runtime-clients/thread-list-item-runtime-client.js +1 -1
- package/dist/store/runtime-clients/thread-list-item-runtime-client.js.map +1 -1
- package/dist/store/runtime-clients/thread-list-runtime-client.d.ts.map +1 -1
- package/dist/store/runtime-clients/thread-list-runtime-client.js +54 -52
- package/dist/store/runtime-clients/thread-list-runtime-client.js.map +1 -1
- package/dist/store/runtime-clients/thread-runtime-client.d.ts.map +1 -1
- package/dist/store/runtime-clients/thread-runtime-client.js +193 -121
- package/dist/store/runtime-clients/thread-runtime-client.js.map +1 -1
- package/dist/store/scopes/composer.d.ts +10 -2
- package/dist/store/scopes/composer.d.ts.map +1 -1
- package/dist/store/scopes/message.d.ts +30 -1
- package/dist/store/scopes/message.d.ts.map +1 -1
- package/dist/store/scopes/thread-list-item.d.ts +2 -2
- package/dist/store/scopes/thread-list-item.d.ts.map +1 -1
- package/dist/store/scopes/thread.d.ts +8 -0
- package/dist/store/scopes/thread.d.ts.map +1 -1
- package/dist/store/scopes/threads.d.ts +1 -0
- package/dist/store/scopes/threads.d.ts.map +1 -1
- package/dist/types/attachment.d.ts +2 -1
- package/dist/types/attachment.d.ts.map +1 -1
- package/dist/types/attachment.js +2 -1
- package/dist/types/attachment.js.map +1 -1
- package/dist/utils/abortable-iterable.d.ts +19 -0
- package/dist/utils/abortable-iterable.d.ts.map +1 -0
- package/dist/utils/abortable-iterable.js +72 -0
- package/dist/utils/abortable-iterable.js.map +1 -0
- package/dist/utils/composite-context-provider.d.ts.map +1 -1
- package/dist/utils/composite-context-provider.js +20 -2
- package/dist/utils/composite-context-provider.js.map +1 -1
- package/dist/utils/record.d.ts +11 -0
- package/dist/utils/record.d.ts.map +1 -0
- package/dist/utils/record.js +12 -0
- package/dist/utils/record.js.map +1 -0
- package/package.json +10 -10
- package/src/adapters/attachment.test.ts +50 -1
- package/src/adapters/attachment.ts +7 -4
- package/src/index.ts +1 -0
- package/src/internal.ts +4 -0
- package/src/model-context/frame/host.test.ts +24 -0
- package/src/model-context/frame/host.ts +10 -4
- package/src/model-context/frame/provider.test.ts +71 -0
- package/src/model-context/frame/provider.ts +37 -7
- package/src/model-context/interactable-composer-metadata.test.ts +28 -0
- package/src/model-context/interactable-composer-metadata.ts +9 -3
- package/src/model-context/registry.test.ts +12 -0
- package/src/model-context/registry.ts +3 -2
- package/src/model-context/types.test.ts +25 -0
- package/src/model-context/types.ts +9 -4
- package/src/react/RuntimeAdapter.test.ts +34 -0
- package/src/react/client/InMemoryThreadList.ts +1 -0
- package/src/react/client/Interactables.test.ts +28 -0
- package/src/react/client/Interactables.ts +15 -13
- package/src/react/client/RemoteThreadList.background.test.ts +148 -6
- package/src/react/client/RemoteThreadList.concurrent.test.tsx +42 -1
- package/src/react/client/RemoteThreadList.test.ts +345 -3
- package/src/react/client/RemoteThreadList.ts +166 -110
- package/src/react/client/Tools.ts +22 -11
- package/src/react/interactables-legacy/Interactables.ts +16 -16
- package/src/react/interactables-shared/useInteractablePersistenceQueue.ts +9 -10
- package/src/react/model-context/makeAssistantTool.ts +2 -2
- package/src/react/model-context/useAssistantTool.ts +2 -2
- package/src/react/model-context/useToolArgsStatus.test.tsx +30 -0
- package/src/react/model-context/useToolArgsStatus.ts +2 -1
- package/src/react/runtimes/RemoteThreadListHookInstanceManager.destroy.test.tsx +172 -0
- package/src/react/runtimes/RemoteThreadListHookInstanceManager.running.test.tsx +33 -0
- package/src/react/runtimes/RemoteThreadListHookInstanceManager.test.tsx +7 -1
- package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +28 -5
- package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.load-error.test.ts +132 -0
- package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.switch-delete.test.ts +105 -0
- package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +136 -111
- package/src/react/runtimes/RemoteThreadResource.test.ts +9 -0
- package/src/react/runtimes/RemoteThreadResource.ts +19 -12
- package/src/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.test.tsx +483 -6
- package/src/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.ts +504 -68
- package/src/react/runtimes/cloud/createCloudThreadListAdapter.test.ts +15 -0
- package/src/react/runtimes/cloud/createCloudThreadListAdapter.ts +1 -0
- package/src/react/runtimes/useExternalStoreRuntime.lifecycle.test.tsx +41 -0
- package/src/react/runtimes/useExternalStoreRuntime.ts +10 -4
- package/src/react/runtimes/useLocalRuntime.test.tsx +20 -0
- package/src/react/runtimes/useRemoteThreadListRuntime.load-error.test.tsx +108 -0
- package/src/react/runtimes/useRemoteThreadListRuntime.ts +33 -0
- package/src/runtime/api/message-runtime.test.ts +75 -0
- package/src/runtime/api/thread-list-item-runtime.ts +10 -3
- package/src/runtime/api/thread-list-runtime.ts +21 -11
- package/src/runtime/api/thread-runtime.test.ts +78 -0
- package/src/runtime/api/thread-runtime.ts +8 -1
- package/src/runtime/base/base-composer-runtime-core.ts +36 -8
- package/src/runtime/base/base-thread-runtime-core.test.ts +55 -1
- package/src/runtime/base/base-thread-runtime-core.ts +6 -4
- package/src/runtime/interfaces/composer-runtime-core.ts +10 -5
- package/src/runtime/interfaces/thread-list-runtime-core.ts +5 -1
- package/src/runtime/utils/external-message-conversion.test.ts +68 -0
- package/src/runtime/utils/external-message-conversion.ts +1 -1
- package/src/runtime/utils/message-repository.ts +29 -17
- package/src/runtime/utils/thread-message-like.ts +1 -1
- package/src/runtimes/external-store/external-store-thread-list-runtime-core.ts +18 -13
- package/src/runtimes/internal.ts +1 -0
- package/src/runtimes/remote-thread-list/remote-thread-state.test.ts +128 -2
- package/src/runtimes/remote-thread-list/remote-thread-state.ts +182 -42
- package/src/runtimes/remote-thread-list/title-generation.test.ts +602 -0
- package/src/runtimes/remote-thread-list/title-generation.ts +329 -0
- package/src/runtimes/remote-thread-list/title.test.ts +62 -0
- package/src/runtimes/remote-thread-list/title.ts +13 -0
- package/src/runtimes/remote-thread-list/types.ts +10 -0
- package/src/store/clients/external-thread.ts +48 -3
- package/src/store/clients/model-context-client.ts +5 -1
- package/src/store/clients/single-thread-list.ts +1 -0
- package/src/store/clients/suggestions.test.ts +40 -0
- package/src/store/clients/suggestions.ts +13 -10
- package/src/store/runtime-clients/composer-runtime-client.ts +42 -9
- package/src/store/runtime-clients/message-runtime-client.ts +54 -6
- package/src/store/runtime-clients/thread-list-item-runtime-client.ts +4 -2
- package/src/store/runtime-clients/thread-list-runtime-client.ts +1 -0
- package/src/store/runtime-clients/thread-runtime-client.ts +58 -10
- package/src/store/scopes/composer.ts +16 -4
- package/src/store/scopes/message.ts +14 -0
- package/src/store/scopes/thread-list-item.ts +5 -2
- package/src/store/scopes/thread.ts +4 -0
- package/src/store/scopes/threads.ts +1 -0
- package/src/tests/MessageRepository.test.ts +116 -0
- package/src/tests/OptimisticState-list-race.test.ts +1 -0
- package/src/tests/RemoteThreadListThreadListRuntimeCore-initialize-adapter-change.test.ts +38 -0
- package/src/tests/RemoteThreadListThreadListRuntimeCore-load-race.test.ts +47 -0
- package/src/tests/RemoteThreadListThreadListRuntimeCore-title-race.test.ts +89 -0
- package/src/tests/attachment-adapters.test.ts +14 -0
- package/src/tests/base-composer-runtime-core.test.ts +39 -0
- package/src/tests/external-store-thread-list-runtime-core.test.ts +83 -0
- package/src/tests/external-store-thread-runtime-core-adapter.test.ts +27 -18
- package/src/tests/external-thread-attachments.test.tsx +139 -0
- package/src/tests/external-thread-parity.test.tsx +23 -0
- package/src/tests/remote-thread-list-isLoading.test.ts +1 -0
- package/src/tests/thread-list-runtime-getLoadThreadsPromise.test.ts +1 -0
- package/src/tests/thread-switch-events.test.tsx +78 -0
- package/src/tests/tools-scope-migration.test.tsx +28 -0
- package/src/types/attachment.ts +4 -0
- package/src/utils/abortable-iterable.test.ts +213 -0
- package/src/utils/abortable-iterable.ts +93 -0
- package/src/utils/composite-context-provider.test.ts +25 -0
- package/src/utils/composite-context-provider.ts +23 -2
- package/src/utils/record.test.ts +48 -0
- package/src/utils/record.ts +11 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
export type ThreadTitleClaim = {
|
|
2
|
+
readonly title: string;
|
|
3
|
+
readonly order: number;
|
|
4
|
+
readonly settled: Promise<boolean>;
|
|
5
|
+
readonly settle: (renamed: boolean) => void;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
type ThreadTitleGeneration = {
|
|
9
|
+
readonly automatic: boolean;
|
|
10
|
+
readonly order: number;
|
|
11
|
+
claim: ThreadTitleClaim | null;
|
|
12
|
+
beforeGenerationClaims: readonly ThreadTitleClaim[];
|
|
13
|
+
superseded: boolean;
|
|
14
|
+
readonly persisted: Promise<string | undefined>;
|
|
15
|
+
readonly settlePersisted: (title: string | undefined) => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ThreadTitleState = {
|
|
19
|
+
generations: Set<ThreadTitleGeneration>;
|
|
20
|
+
pendingClaim: ThreadTitleClaim | null;
|
|
21
|
+
inFlightClaims: Set<ThreadTitleClaim>;
|
|
22
|
+
manualTitle: string | undefined;
|
|
23
|
+
latestExplicit: ThreadTitleGeneration | null;
|
|
24
|
+
nextOrder: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ThreadTitleGenerationRun = {
|
|
28
|
+
states: Map<string, ThreadTitleState>;
|
|
29
|
+
threadId: string;
|
|
30
|
+
automatic: boolean;
|
|
31
|
+
generate: (
|
|
32
|
+
onTitle: (title: string | undefined) => Promise<void>,
|
|
33
|
+
) => Promise<void>;
|
|
34
|
+
rename: (title: string) => Promise<void>;
|
|
35
|
+
applyTitle: (title: string | undefined) => Promise<void>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function getThreadTitleState(
|
|
39
|
+
states: Map<string, ThreadTitleState>,
|
|
40
|
+
threadId: string,
|
|
41
|
+
): ThreadTitleState {
|
|
42
|
+
let state = states.get(threadId);
|
|
43
|
+
if (state === undefined) {
|
|
44
|
+
state = {
|
|
45
|
+
generations: new Set(),
|
|
46
|
+
pendingClaim: null,
|
|
47
|
+
inFlightClaims: new Set(),
|
|
48
|
+
manualTitle: undefined,
|
|
49
|
+
latestExplicit: null,
|
|
50
|
+
nextOrder: 0,
|
|
51
|
+
};
|
|
52
|
+
states.set(threadId, state);
|
|
53
|
+
}
|
|
54
|
+
return state;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function pruneThreadTitleState(
|
|
58
|
+
states: Map<string, ThreadTitleState>,
|
|
59
|
+
threadId: string,
|
|
60
|
+
state: ThreadTitleState,
|
|
61
|
+
): void {
|
|
62
|
+
if (
|
|
63
|
+
state.generations.size === 0 &&
|
|
64
|
+
state.pendingClaim === null &&
|
|
65
|
+
state.inFlightClaims.size === 0 &&
|
|
66
|
+
state.manualTitle === undefined &&
|
|
67
|
+
states.get(threadId) === state
|
|
68
|
+
) {
|
|
69
|
+
states.delete(threadId);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isCurrentGeneration(
|
|
74
|
+
state: ThreadTitleState,
|
|
75
|
+
generation: ThreadTitleGeneration,
|
|
76
|
+
): boolean {
|
|
77
|
+
return (
|
|
78
|
+
!generation.superseded &&
|
|
79
|
+
(state.latestExplicit?.order ?? 0) <= generation.order
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isCurrentClaim(
|
|
84
|
+
state: ThreadTitleState,
|
|
85
|
+
claim: ThreadTitleClaim,
|
|
86
|
+
): boolean {
|
|
87
|
+
return (state.latestExplicit?.order ?? 0) < claim.order;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function startThreadTitleRename(
|
|
91
|
+
states: Map<string, ThreadTitleState>,
|
|
92
|
+
threadId: string,
|
|
93
|
+
title: string,
|
|
94
|
+
): ThreadTitleClaim {
|
|
95
|
+
const state = getThreadTitleState(states, threadId);
|
|
96
|
+
let settle!: (renamed: boolean) => void;
|
|
97
|
+
const settled = new Promise<boolean>((resolve) => {
|
|
98
|
+
settle = resolve;
|
|
99
|
+
});
|
|
100
|
+
const claim: ThreadTitleClaim = {
|
|
101
|
+
title,
|
|
102
|
+
order: ++state.nextOrder,
|
|
103
|
+
settled,
|
|
104
|
+
settle,
|
|
105
|
+
};
|
|
106
|
+
state.pendingClaim = claim;
|
|
107
|
+
state.inFlightClaims.add(claim);
|
|
108
|
+
for (const generation of state.generations) {
|
|
109
|
+
if (generation.order < claim.order) generation.claim = claim;
|
|
110
|
+
}
|
|
111
|
+
return claim;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function finishThreadTitleRename(
|
|
115
|
+
states: Map<string, ThreadTitleState>,
|
|
116
|
+
threadId: string,
|
|
117
|
+
claim: ThreadTitleClaim,
|
|
118
|
+
renamed: boolean,
|
|
119
|
+
): void {
|
|
120
|
+
claim.settle(renamed);
|
|
121
|
+
const state = states.get(threadId);
|
|
122
|
+
if (state === undefined) return;
|
|
123
|
+
state.inFlightClaims.delete(claim);
|
|
124
|
+
if (state.pendingClaim === claim) {
|
|
125
|
+
state.pendingClaim = null;
|
|
126
|
+
if (renamed) {
|
|
127
|
+
state.manualTitle = claim.title;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
pruneThreadTitleState(states, threadId, state);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function takeManualTitle(
|
|
134
|
+
states: Map<string, ThreadTitleState>,
|
|
135
|
+
threadId: string,
|
|
136
|
+
state: ThreadTitleState,
|
|
137
|
+
): string | undefined {
|
|
138
|
+
const retained = state.manualTitle;
|
|
139
|
+
if (retained === undefined) return undefined;
|
|
140
|
+
state.manualTitle = undefined;
|
|
141
|
+
pruneThreadTitleState(states, threadId, state);
|
|
142
|
+
return retained;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function clearThreadTitleState(
|
|
146
|
+
states: Map<string, ThreadTitleState>,
|
|
147
|
+
threadId: string,
|
|
148
|
+
): void {
|
|
149
|
+
states.delete(threadId);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function startThreadTitleGeneration(
|
|
153
|
+
states: Map<string, ThreadTitleState>,
|
|
154
|
+
threadId: string,
|
|
155
|
+
automatic: boolean,
|
|
156
|
+
): ThreadTitleGeneration | null {
|
|
157
|
+
const state = getThreadTitleState(states, threadId);
|
|
158
|
+
if (
|
|
159
|
+
automatic &&
|
|
160
|
+
state.pendingClaim === null &&
|
|
161
|
+
takeManualTitle(states, threadId, state) !== undefined
|
|
162
|
+
) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let settlePersisted!: (title: string | undefined) => void;
|
|
167
|
+
const persisted = new Promise<string | undefined>((resolve) => {
|
|
168
|
+
settlePersisted = resolve;
|
|
169
|
+
});
|
|
170
|
+
const generation: ThreadTitleGeneration = {
|
|
171
|
+
automatic,
|
|
172
|
+
order: ++state.nextOrder,
|
|
173
|
+
claim: automatic ? state.pendingClaim : null,
|
|
174
|
+
beforeGenerationClaims: automatic ? [] : [...state.inFlightClaims],
|
|
175
|
+
superseded: false,
|
|
176
|
+
persisted,
|
|
177
|
+
settlePersisted,
|
|
178
|
+
};
|
|
179
|
+
if (!automatic) {
|
|
180
|
+
state.pendingClaim = null;
|
|
181
|
+
state.manualTitle = undefined;
|
|
182
|
+
state.latestExplicit = generation;
|
|
183
|
+
for (const active of state.generations) {
|
|
184
|
+
if (active.automatic) active.superseded = true;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
state.generations.add(generation);
|
|
188
|
+
return generation;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Runs one title generation under the per-thread rename claims and generation
|
|
193
|
+
* ordering, so the title that wins locally is also the title left on the
|
|
194
|
+
* server.
|
|
195
|
+
*
|
|
196
|
+
* A generated run persists the title itself, and the adapter exposes no
|
|
197
|
+
* compare-and-set, so the run that loses reasserts the winner through `rename`
|
|
198
|
+
* only after `generate` has resolved. Reasserting mid-stream would race the
|
|
199
|
+
* run's own write and lose the winning title on the server.
|
|
200
|
+
*/
|
|
201
|
+
export async function runThreadTitleGeneration({
|
|
202
|
+
states,
|
|
203
|
+
threadId,
|
|
204
|
+
automatic,
|
|
205
|
+
generate,
|
|
206
|
+
rename,
|
|
207
|
+
applyTitle,
|
|
208
|
+
}: ThreadTitleGenerationRun): Promise<void> {
|
|
209
|
+
const state = getThreadTitleState(states, threadId);
|
|
210
|
+
const generation = startThreadTitleGeneration(states, threadId, automatic);
|
|
211
|
+
if (generation === null) return;
|
|
212
|
+
|
|
213
|
+
let persistedTitle: string | undefined;
|
|
214
|
+
let persistedOrder = generation.order;
|
|
215
|
+
|
|
216
|
+
const settleClaim = async (claim: ThreadTitleClaim) => {
|
|
217
|
+
const renamed = await claim.settled;
|
|
218
|
+
if (generation.claim !== claim) return undefined;
|
|
219
|
+
if (!renamed) {
|
|
220
|
+
generation.claim = null;
|
|
221
|
+
if (state.pendingClaim === claim) state.pendingClaim = null;
|
|
222
|
+
}
|
|
223
|
+
return renamed;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const retainManualTitle = () =>
|
|
227
|
+
automatic && takeManualTitle(states, threadId, state) !== undefined;
|
|
228
|
+
|
|
229
|
+
// `generate` resolves only once the run has persisted its title, so a run
|
|
230
|
+
// that lost the ordering race while it streamed has already overwritten the
|
|
231
|
+
// winner. It waits for the winning generation to persist its own title and
|
|
232
|
+
// writes that title back.
|
|
233
|
+
const repairLostRace = async () => {
|
|
234
|
+
if (persistedTitle === undefined) return;
|
|
235
|
+
while (true) {
|
|
236
|
+
const claim = generation.claim;
|
|
237
|
+
if (
|
|
238
|
+
claim !== null &&
|
|
239
|
+
claim.order > persistedOrder &&
|
|
240
|
+
isCurrentClaim(state, claim)
|
|
241
|
+
) {
|
|
242
|
+
const renamed = await settleClaim(claim);
|
|
243
|
+
if (renamed === true) {
|
|
244
|
+
persistedTitle = claim.title;
|
|
245
|
+
persistedOrder = claim.order;
|
|
246
|
+
await rename(claim.title);
|
|
247
|
+
}
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const winner = state.latestExplicit;
|
|
251
|
+
if (winner === null || winner.order <= persistedOrder) return;
|
|
252
|
+
const title = await winner.persisted;
|
|
253
|
+
if (state.latestExplicit !== winner) continue;
|
|
254
|
+
persistedOrder = winner.order;
|
|
255
|
+
if (title === undefined || title === persistedTitle) return;
|
|
256
|
+
persistedTitle = title;
|
|
257
|
+
await rename(title);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const runGeneration = async () => {
|
|
262
|
+
// An explicit generation waits for every earlier rename because any of
|
|
263
|
+
// those server writes may settle last.
|
|
264
|
+
if (generation.beforeGenerationClaims.length > 0) {
|
|
265
|
+
await Promise.all(
|
|
266
|
+
generation.beforeGenerationClaims.map((claim) => claim.settled),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
if (generation.claim !== null) {
|
|
270
|
+
const renamed = await settleClaim(generation.claim);
|
|
271
|
+
if (renamed === true) {
|
|
272
|
+
generation.superseded = true;
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (renamed === false && retainManualTitle()) return;
|
|
276
|
+
}
|
|
277
|
+
if (!isCurrentGeneration(state, generation)) return;
|
|
278
|
+
|
|
279
|
+
let sawTitle = false;
|
|
280
|
+
let lastTitle: string | undefined;
|
|
281
|
+
await generate(async (title) => {
|
|
282
|
+
sawTitle = true;
|
|
283
|
+
lastTitle = title;
|
|
284
|
+
if (title !== undefined) persistedTitle = title;
|
|
285
|
+
const claim = generation.claim;
|
|
286
|
+
if (claim !== null) {
|
|
287
|
+
const renamed = await settleClaim(claim);
|
|
288
|
+
if (renamed !== false) return;
|
|
289
|
+
if (retainManualTitle()) {
|
|
290
|
+
generation.superseded = true;
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (isCurrentGeneration(state, generation)) await applyTitle(lastTitle);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
while (true) {
|
|
298
|
+
const claim = generation.claim;
|
|
299
|
+
if (claim === null) return;
|
|
300
|
+
const renamed = await settleClaim(claim);
|
|
301
|
+
if (renamed === undefined) continue;
|
|
302
|
+
if (renamed === false) {
|
|
303
|
+
if (retainManualTitle()) return;
|
|
304
|
+
if (sawTitle && isCurrentGeneration(state, generation)) {
|
|
305
|
+
await applyTitle(lastTitle);
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (!isCurrentClaim(state, claim)) return;
|
|
310
|
+
await rename(claim.title);
|
|
311
|
+
persistedTitle = claim.title;
|
|
312
|
+
persistedOrder = claim.order;
|
|
313
|
+
if (generation.claim !== claim) continue;
|
|
314
|
+
if (!isCurrentGeneration(state, generation)) return;
|
|
315
|
+
await applyTitle(claim.title);
|
|
316
|
+
generation.superseded = true;
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
await runGeneration();
|
|
323
|
+
await repairLostRace();
|
|
324
|
+
} finally {
|
|
325
|
+
generation.settlePersisted(persistedTitle);
|
|
326
|
+
state.generations.delete(generation);
|
|
327
|
+
pruneThreadTitleState(states, threadId, state);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { applyTitleStream } from "./title";
|
|
3
|
+
|
|
4
|
+
const createTextStream = (text: string) =>
|
|
5
|
+
new ReadableStream({
|
|
6
|
+
start(controller) {
|
|
7
|
+
controller.enqueue({
|
|
8
|
+
type: "part-start",
|
|
9
|
+
path: [0],
|
|
10
|
+
part: { type: "text" },
|
|
11
|
+
});
|
|
12
|
+
controller.enqueue({
|
|
13
|
+
type: "text-delta",
|
|
14
|
+
path: [0],
|
|
15
|
+
textDelta: text,
|
|
16
|
+
});
|
|
17
|
+
controller.enqueue({ type: "part-finish", path: [0] });
|
|
18
|
+
controller.close();
|
|
19
|
+
},
|
|
20
|
+
}) as never;
|
|
21
|
+
|
|
22
|
+
describe("applyTitleStream", () => {
|
|
23
|
+
it("ignores an empty stream", async () => {
|
|
24
|
+
const titles: (string | undefined)[] = [];
|
|
25
|
+
|
|
26
|
+
await applyTitleStream(
|
|
27
|
+
new ReadableStream({
|
|
28
|
+
start(controller) {
|
|
29
|
+
controller.close();
|
|
30
|
+
},
|
|
31
|
+
}) as never,
|
|
32
|
+
async (title) => {
|
|
33
|
+
titles.push(title);
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
expect(titles).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("ignores whitespace-only titles", async () => {
|
|
41
|
+
const titles: (string | undefined)[] = [];
|
|
42
|
+
|
|
43
|
+
await applyTitleStream(createTextStream(" "), async (title) => {
|
|
44
|
+
titles.push(title);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(titles).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("forwards titles containing text unchanged", async () => {
|
|
51
|
+
const titles: (string | undefined)[] = [];
|
|
52
|
+
|
|
53
|
+
await applyTitleStream(
|
|
54
|
+
createTextStream(" Generated title "),
|
|
55
|
+
async (title) => {
|
|
56
|
+
titles.push(title);
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
expect(titles).toContain(" Generated title ");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
import { AssistantMessageStream } from "assistant-stream";
|
|
2
|
+
|
|
1
3
|
export const isTitleSourceMessage = (message: {
|
|
2
4
|
status?: { type: string } | undefined;
|
|
3
5
|
}) => message.status?.type !== "running";
|
|
6
|
+
|
|
7
|
+
export const applyTitleStream = async (
|
|
8
|
+
stream: Parameters<typeof AssistantMessageStream.fromAssistantStream>[0],
|
|
9
|
+
onTitle: (title: string | undefined) => Promise<void>,
|
|
10
|
+
) => {
|
|
11
|
+
const messageStream = AssistantMessageStream.fromAssistantStream(stream);
|
|
12
|
+
for await (const result of messageStream) {
|
|
13
|
+
const title = result.parts.filter((part) => part.type === "text")[0]?.text;
|
|
14
|
+
if (title?.trim()) await onTitle(title);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
@@ -3,6 +3,7 @@ import type { AssistantRuntime } from "../../runtime/api/assistant-runtime";
|
|
|
3
3
|
import type { AssistantStream } from "assistant-stream";
|
|
4
4
|
import type { ThreadHistoryAdapter } from "../../adapters/thread-history";
|
|
5
5
|
import type { AttachmentAdapter } from "../../adapters/attachment";
|
|
6
|
+
import type { FeedbackAdapter } from "../../adapters/feedback";
|
|
6
7
|
import type { ModelContextProvider } from "../../model-context/types";
|
|
7
8
|
|
|
8
9
|
/* oxlint-disable typescript/no-explicit-any -- structural stand-in for ComponentType without depending on react types */
|
|
@@ -40,6 +41,7 @@ export type RuntimeAdapters = {
|
|
|
40
41
|
modelContext?: ModelContextProvider | undefined;
|
|
41
42
|
history?: ThreadHistoryAdapter | undefined;
|
|
42
43
|
attachments?: AttachmentAdapter | undefined;
|
|
44
|
+
feedback?: FeedbackAdapter | undefined;
|
|
43
45
|
};
|
|
44
46
|
|
|
45
47
|
export type RemoteThreadListAdapter = {
|
|
@@ -54,6 +56,14 @@ export type RemoteThreadListAdapter = {
|
|
|
54
56
|
unarchive(remoteId: string): Promise<void>;
|
|
55
57
|
delete(remoteId: string): Promise<void>;
|
|
56
58
|
initialize(threadId: string): Promise<RemoteThreadInitializeResponse>;
|
|
59
|
+
/**
|
|
60
|
+
* Generates a title for the thread and streams it back.
|
|
61
|
+
*
|
|
62
|
+
* When the generation persists the title itself, the returned stream must
|
|
63
|
+
* not complete before that write has landed. Concurrent generations are
|
|
64
|
+
* ordered by stream completion, and a run whose write outlives its stream
|
|
65
|
+
* can overwrite a newer title.
|
|
66
|
+
*/
|
|
57
67
|
generateTitle(
|
|
58
68
|
remoteId: string,
|
|
59
69
|
unstable_messages: readonly ThreadMessage[],
|
|
@@ -21,7 +21,10 @@ import type {
|
|
|
21
21
|
CreateAttachment,
|
|
22
22
|
PendingAttachment,
|
|
23
23
|
} from "../../types/attachment";
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
isAttachmentComplete,
|
|
26
|
+
isCreateAttachment,
|
|
27
|
+
} from "../../types/attachment";
|
|
25
28
|
import type {
|
|
26
29
|
AddToolResultOptions,
|
|
27
30
|
RespondToToolApprovalOptions,
|
|
@@ -463,6 +466,35 @@ const useLiveState = <T>(initial: T) => {
|
|
|
463
466
|
return [state, set, ref] as const;
|
|
464
467
|
};
|
|
465
468
|
|
|
469
|
+
// A try/catch inside the resource makes the React Compiler bail out of the
|
|
470
|
+
// whole hook, so the adapter call and its failure status live at module scope.
|
|
471
|
+
const removeAttachmentThroughAdapter = async (
|
|
472
|
+
attachment: Attachment,
|
|
473
|
+
attachmentAdapter: AttachmentAdapter | undefined,
|
|
474
|
+
setAttachments: (
|
|
475
|
+
next:
|
|
476
|
+
| readonly Attachment[]
|
|
477
|
+
| ((prev: readonly Attachment[]) => readonly Attachment[]),
|
|
478
|
+
) => void,
|
|
479
|
+
) => {
|
|
480
|
+
try {
|
|
481
|
+
await attachmentAdapter?.remove(attachment);
|
|
482
|
+
} catch (error) {
|
|
483
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
484
|
+
setAttachments((prev) =>
|
|
485
|
+
prev.map((candidate) =>
|
|
486
|
+
candidate.id === attachment.id && !isAttachmentComplete(candidate)
|
|
487
|
+
? {
|
|
488
|
+
...candidate,
|
|
489
|
+
status: { type: "incomplete", reason: "error", message },
|
|
490
|
+
}
|
|
491
|
+
: candidate,
|
|
492
|
+
),
|
|
493
|
+
);
|
|
494
|
+
throw error;
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
|
|
466
498
|
// Composer Client - minimal implementation
|
|
467
499
|
const useComposerClientResource = ({
|
|
468
500
|
type,
|
|
@@ -516,7 +548,13 @@ const useComposerClientResource = ({
|
|
|
516
548
|
attachment,
|
|
517
549
|
onRemove: async () => {
|
|
518
550
|
attachmentAddOperations.cancel(attachment.id);
|
|
519
|
-
|
|
551
|
+
if (!isAttachmentComplete(attachment)) {
|
|
552
|
+
await removeAttachmentThroughAdapter(
|
|
553
|
+
attachment,
|
|
554
|
+
attachmentAdapter,
|
|
555
|
+
setAttachments,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
520
558
|
setAttachments((prev) =>
|
|
521
559
|
prev.filter((a) => a.id !== attachment.id),
|
|
522
560
|
);
|
|
@@ -905,7 +943,11 @@ const useExternalThread = ({
|
|
|
905
943
|
: undefined;
|
|
906
944
|
};
|
|
907
945
|
|
|
946
|
+
// `messages` changes identity on every streamed token, and this body fans out
|
|
947
|
+
// to one client per message, so pruning during render would re-run the whole
|
|
948
|
+
// fan-out per token. The effect bails out on an unchanged map instead.
|
|
908
949
|
useEffect(() => {
|
|
950
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
909
951
|
setSubmittedFeedback((prev) => {
|
|
910
952
|
const live = Object.entries(prev).filter(([id, entry]) => {
|
|
911
953
|
const msg = messages.find((m) => m.id === id);
|
|
@@ -1113,7 +1155,10 @@ const useExternalThread = ({
|
|
|
1113
1155
|
}
|
|
1114
1156
|
: {
|
|
1115
1157
|
createdAt: message.createdAt ?? new Date(),
|
|
1116
|
-
parentId:
|
|
1158
|
+
parentId:
|
|
1159
|
+
message.parentId === undefined
|
|
1160
|
+
? (messages.at(-1)?.id ?? null)
|
|
1161
|
+
: message.parentId,
|
|
1117
1162
|
sourceId: message.sourceId ?? null,
|
|
1118
1163
|
role: message.role ?? "user",
|
|
1119
1164
|
content: message.content,
|
|
@@ -3,6 +3,7 @@ import { resource } from "@assistant-ui/tap";
|
|
|
3
3
|
import type { ClientOutput } from "@assistant-ui/store";
|
|
4
4
|
import { CompositeContextProvider } from "../../utils/composite-context-provider";
|
|
5
5
|
import type { ModelContextState } from "../scopes/model-context";
|
|
6
|
+
import { shallowEqual } from "@assistant-ui/store/internal";
|
|
6
7
|
|
|
7
8
|
const EMPTY_TOOL_NAMES: readonly string[] = [];
|
|
8
9
|
|
|
@@ -12,7 +13,7 @@ const INITIAL_STATE: ModelContextState = {
|
|
|
12
13
|
};
|
|
13
14
|
|
|
14
15
|
const toolNamesEqual = (a: readonly string[], b: readonly string[]): boolean =>
|
|
15
|
-
a === b || (a
|
|
16
|
+
a === b || shallowEqual(a, b);
|
|
16
17
|
|
|
17
18
|
const deriveState = (
|
|
18
19
|
composite: CompositeContextProvider,
|
|
@@ -36,6 +37,9 @@ const useModelContext = (): ClientOutput<"modelContext"> => {
|
|
|
36
37
|
);
|
|
37
38
|
|
|
38
39
|
useEffect(() => {
|
|
40
|
+
// The composite is an external store; this catches the snapshot up for
|
|
41
|
+
// providers registered between render and subscribe.
|
|
42
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
39
43
|
setState((prev) => deriveState(composite, prev));
|
|
40
44
|
return composite.subscribe(() => {
|
|
41
45
|
setState((prev) => deriveState(composite, prev));
|
|
@@ -151,6 +151,46 @@ describe("ThreadSuggestions", () => {
|
|
|
151
151
|
}
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
+
it("drops sparse holes before the per-suggestion lookup", () => {
|
|
155
|
+
let setSuggestions!: (suggestions: ThreadSuggestion[]) => void;
|
|
156
|
+
const root = createTapRoot(function ThreadSuggestionsRoot() {
|
|
157
|
+
const [suggestions, setValue] = useState<ThreadSuggestion[]>([
|
|
158
|
+
{ prompt: "Prompt A" },
|
|
159
|
+
{ prompt: "Prompt B" },
|
|
160
|
+
]);
|
|
161
|
+
setSuggestions = setValue;
|
|
162
|
+
return useResource(ThreadSuggestions(suggestions));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const sparse: ThreadSuggestion[] = new Array(2);
|
|
167
|
+
sparse[1] = { prompt: "Prompt B" };
|
|
168
|
+
flushTapSync(() => setSuggestions(sparse));
|
|
169
|
+
|
|
170
|
+
const state = root.getValue().getState();
|
|
171
|
+
const expected = { title: "Prompt B", label: "", prompt: "Prompt B" };
|
|
172
|
+
expect(state.suggestions).toEqual([expected]);
|
|
173
|
+
expect(Object.keys(state.suggestions)).toEqual(["0"]);
|
|
174
|
+
const firstSuggestion = root
|
|
175
|
+
.getValue()
|
|
176
|
+
.suggestion({ index: 0 })
|
|
177
|
+
.getState();
|
|
178
|
+
expect(firstSuggestion).toEqual(expected);
|
|
179
|
+
|
|
180
|
+
// An equivalent sparse update keeps both identities stable.
|
|
181
|
+
const sparseAgain: ThreadSuggestion[] = new Array(2);
|
|
182
|
+
sparseAgain[1] = { prompt: "Prompt B" };
|
|
183
|
+
flushTapSync(() => setSuggestions(sparseAgain));
|
|
184
|
+
|
|
185
|
+
expect(root.getValue().getState()).toBe(state);
|
|
186
|
+
expect(root.getValue().suggestion({ index: 0 }).getState()).toBe(
|
|
187
|
+
firstSuggestion,
|
|
188
|
+
);
|
|
189
|
+
} finally {
|
|
190
|
+
root.unmount();
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
154
194
|
it("reuses unchanged suggestion state when one entry changes", () => {
|
|
155
195
|
let setSuggestions!: (suggestions: ThreadSuggestion[]) => void;
|
|
156
196
|
const root = createTapRoot(function ThreadSuggestionsRoot() {
|
|
@@ -17,18 +17,21 @@ const useStableSuggestionsState = (
|
|
|
17
17
|
const cell = useMemo(() => ({}) as { state?: SuggestionsState }, []);
|
|
18
18
|
const previous = cell.state;
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
// forEach skips array holes, so a sparse caller array normalizes to a dense
|
|
21
|
+
// list here, before the per-suggestion resource lookup indexes every slot.
|
|
22
|
+
const suggestions: SuggestionState[] = [];
|
|
23
|
+
next.suggestions.forEach((suggestion) => {
|
|
24
|
+
// Probed at the compacted destination index, not the sparse source index,
|
|
25
|
+
// so an equivalent hole-containing update keeps reusing prior identities.
|
|
26
|
+
const previousSuggestion = previous?.suggestions[suggestions.length];
|
|
27
|
+
suggestions.push(
|
|
28
|
+
previousSuggestion && shallowEqual(previousSuggestion, suggestion)
|
|
29
|
+
? previousSuggestion
|
|
30
|
+
: suggestion,
|
|
31
|
+
);
|
|
25
32
|
});
|
|
26
33
|
const state =
|
|
27
|
-
previous &&
|
|
28
|
-
previous.suggestions.length === suggestions.length &&
|
|
29
|
-
suggestions.every(
|
|
30
|
-
(suggestion, index) => suggestion === previous.suggestions[index],
|
|
31
|
-
)
|
|
34
|
+
previous && shallowEqual(suggestions, previous.suggestions)
|
|
32
35
|
? previous
|
|
33
36
|
: { suggestions };
|
|
34
37
|
|