| author | |
| committer | |
| log | 9ea892e3b365faff31eed294ac38f70d122656b2 |
| tree | 3bc1abf0ad98a029177952727634f942b614a965 |
| parent | a37b85279d71f958482cb8b6fef1886cdb43f364 |
| signature | Signed by PGP key B5690EEEBB952194 |
8 files changed, 646 insertions(+), 9 deletions(-)
apps/mobile/src/state/threads.ts+5-2| ... | @@ -15,14 +15,17 @@ import { environmentCatalog } from "../connection/catalog"; | ... | @@ -15,14 +15,17 @@ import { environmentCatalog } from "../connection/catalog"; |
| 15 | import { connectionAtomRuntime } from "../connection/runtime"; | 15 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 16 | import { environmentSnapshotAtom } from "./shell"; | 16 | import { environmentSnapshotAtom } from "./shell"; |
| 17 | 17 | ||
| 18 | export const threadEnvironment = createThreadEnvironmentAtoms(connectionAtomRuntime); | 18 | export const threadEnvironment = createThreadEnvironmentAtoms( |
| 19 | connectionAtomRuntime, | ||
| 20 | environmentSnapshotAtom, | ||
| 21 | ); | ||
| 19 | export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); | 22 | export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); |
| 20 | export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( | 23 | export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( |
| 21 | environmentThreads.stateAtom, | 24 | environmentThreads.stateAtom, |
| 22 | ); | 25 | ); |
| 23 | export const environmentThreadShells = createEnvironmentThreadShellAtoms({ | 26 | export const environmentThreadShells = createEnvironmentThreadShellAtoms({ |
| 24 | catalogValueAtom: environmentCatalog.catalogValueAtom, | 27 | catalogValueAtom: environmentCatalog.catalogValueAtom, |
| 25 | snapshotAtom: environmentSnapshotAtom, | 28 | snapshotAtom: threadEnvironment.snapshotAtom, |
| 26 | }); | 29 | }); |
| 27 | 30 | ||
| 28 | const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe( | 31 | const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe( |
apps/web/src/components/Sidebar.logic.test.ts+42| ... | @@ -44,6 +44,7 @@ import { | ... | @@ -44,6 +44,7 @@ import { |
| 44 | sortProjectsForSidebar, | 44 | sortProjectsForSidebar, |
| 45 | sortScopedProjectsForSidebar, | 45 | sortScopedProjectsForSidebar, |
| 46 | shouldCreateNewThreadInCurrentProject, | 46 | shouldCreateNewThreadInCurrentProject, |
| 47 | shouldNavigateAfterThreadPark, | ||
| 47 | THREAD_JUMP_HINT_SHOW_DELAY_MS, | 48 | THREAD_JUMP_HINT_SHOW_DELAY_MS, |
| 48 | type SidebarListItem, | 49 | type SidebarListItem, |
| 49 | type SidebarListMarker, | 50 | type SidebarListMarker, |
| ... | @@ -2500,3 +2501,44 @@ describe("resolveSidebarDropVerb", () => { | ... | @@ -2500,3 +2501,44 @@ describe("resolveSidebarDropVerb", () => { |
| 2500 | expect(resolveSidebarDropVerb("active", "snoozed")).toBeNull(); | 2501 | expect(resolveSidebarDropVerb("active", "snoozed")).toBeNull(); |
| 2501 | }); | 2502 | }); |
| 2502 | }); | 2503 | }); |
| 2504 | |||
| 2505 | describe("navigation after parking a thread", () => { | ||
| 2506 | it.each([ | ||
| 2507 | ["settle", "settled", null, "thread", true], | ||
| 2508 | ["settle", "active", null, "thread", false], | ||
| 2509 | ["settle", "settled", null, "other-thread", false], | ||
| 2510 | ["snooze", null, "2099-01-01T00:00:00.000Z", "thread", true], | ||
| 2511 | ["snooze", null, null, "thread", false], | ||
| 2512 | ["snooze", null, "2026-09-12T09:00:00.000Z", "thread", false], | ||
| 2513 | ["snooze", null, "2099-01-01T00:00:00.000Z", "thread", false, true], | ||
| 2514 | ["snooze", null, "2099-01-01T00:00:00.000Z", "other-thread", false], | ||
| 2515 | ] as const)( | ||
| 2516 | "%s with state %s / %s on %s navigates: %s", | ||
| 2517 | ( | ||
| 2518 | action, | ||
| 2519 | settledOverride, | ||
| 2520 | snoozedUntil, | ||
| 2521 | currentThreadKey, | ||
| 2522 | expected, | ||
| 2523 | hasPendingApprovals: boolean = false, | ||
| 2524 | ) => { | ||
| 2525 | expect( | ||
| 2526 | shouldNavigateAfterThreadPark({ | ||
| 2527 | threadKey: "thread", | ||
| 2528 | currentThreadKey, | ||
| 2529 | action, | ||
| 2530 | now: "2026-09-12T10:00:00.000Z", | ||
| 2531 | thread: { | ||
| 2532 | settledOverride, | ||
| 2533 | snoozedUntil, | ||
| 2534 | snoozedAt: null, | ||
| 2535 | session: null, | ||
| 2536 | latestTurn: null, | ||
| 2537 | hasPendingApprovals, | ||
| 2538 | hasPendingUserInput: false, | ||
| 2539 | }, | ||
| 2540 | }), | ||
| 2541 | ).toBe(expected); | ||
| 2542 | }, | ||
| 2543 | ); | ||
| 2544 | }); |
apps/web/src/components/Sidebar.logic.ts+20| ... | @@ -9,6 +9,10 @@ import type { ContextMenuItem } from "@t3tools/contracts"; | ... | @@ -9,6 +9,10 @@ import type { ContextMenuItem } from "@t3tools/contracts"; |
| 9 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; | 9 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; |
| 10 | import type { AsyncResult } from "effect/unstable/reactivity"; | 10 | import type { AsyncResult } from "effect/unstable/reactivity"; |
| 11 | import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; | 11 | import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; |
| 12 | import { | ||
| 13 | effectiveSnoozed, | ||
| 14 | type ThreadSnoozeShell, | ||
| 15 | } from "@t3tools/client-runtime/state/thread-settled"; | ||
| 12 | import { | 16 | import { |
| 13 | getThreadSortTimestamp, | 17 | getThreadSortTimestamp, |
| 14 | resolveSettledThreadTimestamp, | 18 | resolveSettledThreadTimestamp, |
| ... | @@ -21,6 +25,22 @@ import type { SidebarThreadSummary, Thread } from "../types"; | ... | @@ -21,6 +25,22 @@ import type { SidebarThreadSummary, Thread } from "../types"; |
| 21 | import { cn } from "../lib/utils"; | 25 | import { cn } from "../lib/utils"; |
| 22 | import { isLatestTurnSettled } from "../session-logic"; | 26 | import { isLatestTurnSettled } from "../session-logic"; |
| 23 | 27 | ||
| 28 | export function shouldNavigateAfterThreadPark(input: { | ||
| 29 | readonly threadKey: string; | ||
| 30 | readonly currentThreadKey: string | null; | ||
| 31 | readonly action: "settle" | "snooze"; | ||
| 32 | readonly now: string; | ||
| 33 | readonly thread: (ThreadSnoozeShell & Pick<SidebarThreadSummary, "settledOverride">) | null; | ||
| 34 | }): boolean { | ||
| 35 | return ( | ||
| 36 | input.threadKey === input.currentThreadKey && | ||
| 37 | input.thread !== null && | ||
| 38 | (input.action === "settle" | ||
| 39 | ? input.thread.settledOverride === "settled" | ||
| 40 | : effectiveSnoozed(input.thread, { now: input.now })) | ||
| 41 | ); | ||
| 42 | } | ||
| 43 | |||
| 24 | const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; | 44 | const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; |
| 25 | export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; | 45 | export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; |
| 26 | // Visible sidebar rows are prewarmed into the thread-detail cache so opening a | 46 | // Visible sidebar rows are prewarmed into the thread-detail cache so opening a |
apps/web/src/components/Sidebar.tsx+30-3| ... | @@ -169,6 +169,7 @@ import { | ... | @@ -169,6 +169,7 @@ import { |
| 169 | resolveSidebarThreadStatus, | 169 | resolveSidebarThreadStatus, |
| 170 | searchSidebarThreads, | 170 | searchSidebarThreads, |
| 171 | shouldCreateNewThreadInCurrentProject, | 171 | shouldCreateNewThreadInCurrentProject, |
| 172 | shouldNavigateAfterThreadPark, | ||
| 172 | shouldRecedeSidebarThread, | 173 | shouldRecedeSidebarThread, |
| 173 | resolveWorkingStartedAt, | 174 | resolveWorkingStartedAt, |
| 174 | sidebarListItemId, | 175 | sidebarListItemId, |
| ... | @@ -3053,7 +3054,15 @@ export default function Sidebar() { | ... | @@ -3053,7 +3054,15 @@ export default function Sidebar() { |
| 3053 | } | 3054 | } |
| 3054 | // Only move forward if the user is still on the settled thread — | 3055 | // Only move forward if the user is still on the settled thread — |
| 3055 | // a navigation made during the await wins over ours. | 3056 | // a navigation made during the await wins over ours. |
| 3056 | if (routeThreadKeyRef.current === threadKey) { | 3057 | if ( |
| 3058 | shouldNavigateAfterThreadPark({ | ||
| 3059 | threadKey, | ||
| 3060 | currentThreadKey: routeThreadKeyRef.current, | ||
| 3061 | action: "settle", | ||
| 3062 | now: new Date().toISOString(), | ||
| 3063 | thread: readThreadShell(threadRef), | ||
| 3064 | }) | ||
| 3065 | ) { | ||
| 3057 | navigateAfterSettle?.(); | 3066 | navigateAfterSettle?.(); |
| 3058 | } | 3067 | } |
| 3059 | } finally { | 3068 | } finally { |
| ... | @@ -3582,7 +3591,17 @@ export default function Sidebar() { | ... | @@ -3582,7 +3591,17 @@ export default function Sidebar() { |
| 3582 | const settled = await run(settleThread(threadRef), "Failed to settle thread").finally( | 3591 | const settled = await run(settleThread(threadRef), "Failed to settle thread").finally( |
| 3583 | () => settlingThreadKeysRef.current.delete(activeKey), | 3592 | () => settlingThreadKeysRef.current.delete(activeKey), |
| 3584 | ); | 3593 | ); |
| 3585 | if (settled && routeThreadKeyRef.current === activeKey) navigateAfterSettle?.(); | 3594 | if ( |
| 3595 | settled && | ||
| 3596 | shouldNavigateAfterThreadPark({ | ||
| 3597 | threadKey: activeKey, | ||
| 3598 | currentThreadKey: routeThreadKeyRef.current, | ||
| 3599 | action: "settle", | ||
| 3600 | now: new Date().toISOString(), | ||
| 3601 | thread: readThreadShell(threadRef), | ||
| 3602 | }) | ||
| 3603 | ) | ||
| 3604 | navigateAfterSettle?.(); | ||
| 3586 | return; | 3605 | return; |
| 3587 | } | 3606 | } |
| 3588 | case "move-active": | 3607 | case "move-active": |
| ... | @@ -3679,7 +3698,15 @@ export default function Sidebar() { | ... | @@ -3679,7 +3698,15 @@ export default function Sidebar() { |
| 3679 | } | 3698 | } |
| 3680 | // Only move forward if the user is still on the snoozed thread — | 3699 | // Only move forward if the user is still on the snoozed thread — |
| 3681 | // a navigation made during the await wins over ours. | 3700 | // a navigation made during the await wins over ours. |
| 3682 | if (routeThreadKeyRef.current === threadKey) { | 3701 | if ( |
| 3702 | shouldNavigateAfterThreadPark({ | ||
| 3703 | threadKey, | ||
| 3704 | currentThreadKey: routeThreadKeyRef.current, | ||
| 3705 | action: "snooze", | ||
| 3706 | now: new Date().toISOString(), | ||
| 3707 | thread: readThreadShell(threadRef), | ||
| 3708 | }) | ||
| 3709 | ) { | ||
| 3683 | navigateAfterSnooze?.(); | 3710 | navigateAfterSnooze?.(); |
| 3684 | } | 3711 | } |
| 3685 | return { status: "success" } as const; | 3712 | return { status: "success" } as const; |
apps/web/src/state/threads.ts+5-2| ... | @@ -15,14 +15,17 @@ import { environmentCatalog } from "../connection/catalog"; | ... | @@ -15,14 +15,17 @@ import { environmentCatalog } from "../connection/catalog"; |
| 15 | import { connectionAtomRuntime } from "../connection/runtime"; | 15 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 16 | import { environmentSnapshotAtom } from "./shell"; | 16 | import { environmentSnapshotAtom } from "./shell"; |
| 17 | 17 | ||
| 18 | export const threadEnvironment = createThreadEnvironmentAtoms(connectionAtomRuntime); | 18 | export const threadEnvironment = createThreadEnvironmentAtoms( |
| 19 | connectionAtomRuntime, | ||
| 20 | environmentSnapshotAtom, | ||
| 21 | ); | ||
| 19 | const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); | 22 | const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); |
| 20 | export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( | 23 | export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( |
| 21 | environmentThreads.stateAtom, | 24 | environmentThreads.stateAtom, |
| 22 | ); | 25 | ); |
| 23 | export const environmentThreadShells = createEnvironmentThreadShellAtoms({ | 26 | export const environmentThreadShells = createEnvironmentThreadShellAtoms({ |
| 24 | catalogValueAtom: environmentCatalog.catalogValueAtom, | 27 | catalogValueAtom: environmentCatalog.catalogValueAtom, |
| 25 | snapshotAtom: environmentSnapshotAtom, | 28 | snapshotAtom: threadEnvironment.snapshotAtom, |
| 26 | }); | 29 | }); |
| 27 | 30 | ||
| 28 | const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe( | 31 | const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe( |
packages/client-runtime/src/state/threadCommands.test.ts created+357| ... | @@ -0,0 +1,357 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { | ||
| 3 | CommandId, | ||
| 4 | EnvironmentId, | ||
| 5 | ORCHESTRATION_WS_METHODS, | ||
| 6 | ProjectId, | ||
| 7 | ProviderInstanceId, | ||
| 8 | ThreadId, | ||
| 9 | type ClientOrchestrationCommand, | ||
| 10 | type OrchestrationShellSnapshot, | ||
| 11 | } from "@t3tools/contracts"; | ||
| 12 | import * as Crypto from "effect/Crypto"; | ||
| 13 | import * as Deferred from "effect/Deferred"; | ||
| 14 | import * as Effect from "effect/Effect"; | ||
| 15 | import * as Layer from "effect/Layer"; | ||
| 16 | import * as Option from "effect/Option"; | ||
| 17 | import * as Queue from "effect/Queue"; | ||
| 18 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 19 | import { Atom, AtomRegistry } from "effect/unstable/reactivity"; | ||
| 20 | |||
| 21 | import { EnvironmentRegistry } from "../connection/registry.ts"; | ||
| 22 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; | ||
| 23 | import type { RpcSession } from "../rpc/session.ts"; | ||
| 24 | import { createThreadEnvironmentAtoms } from "./threadCommands.ts"; | ||
| 25 | |||
| 26 | const ENVIRONMENT_ID = EnvironmentId.make("remote"); | ||
| 27 | const THREAD_ID = ThreadId.make("thread"); | ||
| 28 | const NOW = "2026-09-12T10:00:00.000Z"; | ||
| 29 | const SNAPSHOT: OrchestrationShellSnapshot = { | ||
| 30 | snapshotSequence: 1, | ||
| 31 | updatedAt: NOW, | ||
| 32 | projects: [], | ||
| 33 | threads: [ | ||
| 34 | { | ||
| 35 | id: THREAD_ID, | ||
| 36 | projectId: ProjectId.make("project"), | ||
| 37 | title: "Remote thread", | ||
| 38 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 39 | runtimeMode: "full-access", | ||
| 40 | interactionMode: "default", | ||
| 41 | branch: null, | ||
| 42 | worktreePath: null, | ||
| 43 | latestTurn: null, | ||
| 44 | createdAt: NOW, | ||
| 45 | updatedAt: NOW, | ||
| 46 | archivedAt: null, | ||
| 47 | settledOverride: null, | ||
| 48 | settledAt: null, | ||
| 49 | pullRequests: [], | ||
| 50 | session: null, | ||
| 51 | latestUserMessageAt: null, | ||
| 52 | hasPendingApprovals: false, | ||
| 53 | hasPendingUserInput: false, | ||
| 54 | hasActionableProposedPlan: false, | ||
| 55 | }, | ||
| 56 | ], | ||
| 57 | }; | ||
| 58 | |||
| 59 | const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* () { | ||
| 60 | const requests = yield* Queue.unbounded<{ | ||
| 61 | command: ClientOrchestrationCommand; | ||
| 62 | reply: Deferred.Deferred<{ sequence: number }, Error>; | ||
| 63 | }>(); | ||
| 64 | const supervisor = EnvironmentSupervisor.of({ | ||
| 65 | target: { environmentId: ENVIRONMENT_ID }, | ||
| 66 | session: yield* SubscriptionRef.make( | ||
| 67 | Option.some({ | ||
| 68 | client: { | ||
| 69 | [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command: ClientOrchestrationCommand) => | ||
| 70 | Effect.gen(function* () { | ||
| 71 | const reply = yield* Deferred.make<{ sequence: number }, Error>(); | ||
| 72 | yield* Queue.offer(requests, { command, reply }); | ||
| 73 | return yield* Deferred.await(reply); | ||
| 74 | }), | ||
| 75 | }, | ||
| 76 | } as unknown as RpcSession), | ||
| 77 | ), | ||
| 78 | } as EnvironmentSupervisor["Service"]); | ||
| 79 | const runtime = Atom.runtime( | ||
| 80 | Layer.mergeAll( | ||
| 81 | Layer.succeed(EnvironmentRegistry, { | ||
| 82 | run: (_environmentId, effect) => | ||
| 83 | Effect.provideService(effect, EnvironmentSupervisor, supervisor), | ||
| 84 | } as EnvironmentRegistry["Service"]), | ||
| 85 | Layer.succeed( | ||
| 86 | Crypto.Crypto, | ||
| 87 | Crypto.make({ | ||
| 88 | randomBytes: (size) => new Uint8Array(size), | ||
| 89 | digest: (_algorithm, data) => Effect.succeed(data), | ||
| 90 | }), | ||
| 91 | ), | ||
| 92 | ), | ||
| 93 | ); | ||
| 94 | const snapshotAtom = Atom.family((_environmentId: EnvironmentId) => Atom.make(SNAPSHOT)); | ||
| 95 | const commands = createThreadEnvironmentAtoms(runtime, snapshotAtom); | ||
| 96 | const registry = AtomRegistry.make(); | ||
| 97 | yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); | ||
| 98 | const visibleAtom = commands.snapshotAtom(ENVIRONMENT_ID); | ||
| 99 | registry.mount(visibleAtom); | ||
| 100 | return { registry, commands, snapshotAtom, visibleAtom, requests }; | ||
| 101 | }); | ||
| 102 | |||
| 103 | describe("remote thread lifecycle commands", () => { | ||
| 104 | const actions = [ | ||
| 105 | ["settle", {}, { settledOverride: "settled", pinnedAt: null, snoozedUntil: null }], | ||
| 106 | ["unsettle", { reason: "user" }, { settledOverride: "active", settledAt: null }], | ||
| 107 | [ | ||
| 108 | "snooze", | ||
| 109 | { snoozedUntil: "2099-01-01T00:00:00.000Z" }, | ||
| 110 | { snoozedUntil: "2099-01-01T00:00:00.000Z" }, | ||
| 111 | ], | ||
| 112 | ["unsnooze", { reason: "user" }, { snoozedUntil: null, snoozedAt: null }], | ||
| 113 | ["pin", { orderKey: "a" }, { pinnedAt: expect.any(String), pinOrderKey: "a" }], | ||
| 114 | ["unpin", {}, { pinnedAt: null, pinOrderKey: null }], | ||
| 115 | ["reorderPin", { orderKey: "b" }, { pinOrderKey: "b" }], | ||
| 116 | ["reorderActive", { orderKey: "b" }, { activeOrderKey: "b" }], | ||
| 117 | ] as const; | ||
| 118 | |||
| 119 | for (const [action, input, expected] of actions) { | ||
| 120 | it.effect(`shows ${action} before a delayed remote reply and rolls back a rejection`, () => | ||
| 121 | Effect.gen(function* () { | ||
| 122 | const h = yield* makeHarness(); | ||
| 123 | const source = h.snapshotAtom(ENVIRONMENT_ID); | ||
| 124 | const initial = { | ||
| 125 | ...SNAPSHOT, | ||
| 126 | threads: [ | ||
| 127 | { | ||
| 128 | ...SNAPSHOT.threads[0]!, | ||
| 129 | ...(action === "unsettle" || action === "pin" | ||
| 130 | ? { settledOverride: "settled" as const, settledAt: NOW } | ||
| 131 | : {}), | ||
| 132 | ...(action === "unsnooze" || action === "settle" || action === "pin" | ||
| 133 | ? { snoozedUntil: "2099-01-01T00:00:00.000Z", snoozedAt: NOW } | ||
| 134 | : {}), | ||
| 135 | ...(action === "unpin" || action === "settle" | ||
| 136 | ? { pinnedAt: NOW, pinOrderKey: "a" } | ||
| 137 | : {}), | ||
| 138 | }, | ||
| 139 | ], | ||
| 140 | }; | ||
| 141 | h.registry.set(source, initial); | ||
| 142 | const result = h.commands[action].run(h.registry, { | ||
| 143 | environmentId: ENVIRONMENT_ID, | ||
| 144 | input: { | ||
| 145 | threadId: THREAD_ID, | ||
| 146 | commandId: CommandId.make(action), | ||
| 147 | reason: "user", | ||
| 148 | orderKey: "a", | ||
| 149 | snoozedUntil: "2099-01-01T00:00:00.000Z", | ||
| 150 | ...input, | ||
| 151 | }, | ||
| 152 | }); | ||
| 153 | expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject(expected); | ||
| 154 | const request = yield* Queue.take(h.requests); | ||
| 155 | expect(h.registry.get(source)).toBe(initial); | ||
| 156 | yield* Deferred.fail(request.reply, new Error("Remote rejected the action")); | ||
| 157 | expect((yield* Effect.promise(() => result))._tag).toBe("Failure"); | ||
| 158 | expect(h.registry.get(h.visibleAtom)).toBe(initial); | ||
| 159 | }), | ||
| 160 | ); | ||
| 161 | } | ||
| 162 | |||
| 163 | it.effect("keeps the preview after acknowledgement until the matching shell update arrives", () => | ||
| 164 | Effect.gen(function* () { | ||
| 165 | const h = yield* makeHarness(); | ||
| 166 | const result = h.commands.settle.run(h.registry, { | ||
| 167 | environmentId: ENVIRONMENT_ID, | ||
| 168 | input: { threadId: THREAD_ID }, | ||
| 169 | }); | ||
| 170 | const request = yield* Queue.take(h.requests); | ||
| 171 | yield* Deferred.succeed(request.reply, { sequence: 3 }); | ||
| 172 | expect((yield* Effect.promise(() => result))._tag).toBe("Success"); | ||
| 173 | const changed = { | ||
| 174 | ...SNAPSHOT, | ||
| 175 | snapshotSequence: 2, | ||
| 176 | threads: [{ ...SNAPSHOT.threads[0]!, title: "Renamed remotely" }], | ||
| 177 | }; | ||
| 178 | h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), changed); | ||
| 179 | expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject({ | ||
| 180 | title: "Renamed remotely", | ||
| 181 | settledOverride: "settled", | ||
| 182 | }); | ||
| 183 | const confirmed = { | ||
| 184 | ...changed, | ||
| 185 | snapshotSequence: 3, | ||
| 186 | threads: [ | ||
| 187 | { | ||
| 188 | ...changed.threads[0]!, | ||
| 189 | settledOverride: "settled" as const, | ||
| 190 | settledAt: "2026-09-12T12:00:00.000Z", | ||
| 191 | }, | ||
| 192 | ], | ||
| 193 | }; | ||
| 194 | h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), confirmed); | ||
| 195 | expect(h.registry.get(h.visibleAtom)).toBe(confirmed); | ||
| 196 | h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), { ...SNAPSHOT, snapshotSequence: 4 }); | ||
| 197 | expect(h.registry.get(h.visibleAtom)?.threads[0]?.settledOverride).toBeNull(); | ||
| 198 | }), | ||
| 199 | ); | ||
| 200 | |||
| 201 | it.effect( | ||
| 202 | "shows a queued reverse action immediately and preserves it if the earlier action fails", | ||
| 203 | () => | ||
| 204 | Effect.gen(function* () { | ||
| 205 | const h = yield* makeHarness(); | ||
| 206 | const settle = h.commands.settle.run(h.registry, { | ||
| 207 | environmentId: ENVIRONMENT_ID, | ||
| 208 | input: { threadId: THREAD_ID }, | ||
| 209 | }); | ||
| 210 | const first = yield* Queue.take(h.requests); | ||
| 211 | const unsettle = h.commands.unsettle.run(h.registry, { | ||
| 212 | environmentId: ENVIRONMENT_ID, | ||
| 213 | input: { threadId: THREAD_ID, reason: "user" }, | ||
| 214 | }); | ||
| 215 | expect(h.registry.get(h.visibleAtom)?.threads[0]?.settledOverride).toBe("active"); | ||
| 216 | yield* Deferred.fail(first.reply, new Error("Settle rejected")); | ||
| 217 | yield* Effect.promise(() => settle); | ||
| 218 | expect(h.registry.get(h.visibleAtom)?.threads[0]?.settledOverride).toBe("active"); | ||
| 219 | const second = yield* Queue.take(h.requests); | ||
| 220 | expect(second.command.type).toBe("thread.unsettle"); | ||
| 221 | const confirmed = { | ||
| 222 | ...SNAPSHOT, | ||
| 223 | snapshotSequence: 2, | ||
| 224 | threads: [{ ...SNAPSHOT.threads[0]!, settledOverride: "active" as const }], | ||
| 225 | }; | ||
| 226 | h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), confirmed); | ||
| 227 | yield* Deferred.succeed(second.reply, { sequence: 2 }); | ||
| 228 | yield* Effect.promise(() => unsettle); | ||
| 229 | expect(h.registry.get(h.visibleAtom)).toBe(confirmed); | ||
| 230 | }), | ||
| 231 | ); | ||
| 232 | |||
| 233 | it.effect("isolates environments and does not restore a remotely removed thread", () => | ||
| 234 | Effect.gen(function* () { | ||
| 235 | const h = yield* makeHarness(); | ||
| 236 | const otherEnvironment = EnvironmentId.make("other-remote"); | ||
| 237 | const result = h.commands.settle.run(h.registry, { | ||
| 238 | environmentId: ENVIRONMENT_ID, | ||
| 239 | input: { threadId: THREAD_ID }, | ||
| 240 | }); | ||
| 241 | const request = yield* Queue.take(h.requests); | ||
| 242 | expect(h.registry.get(h.commands.snapshotAtom(otherEnvironment))).toBe(SNAPSHOT); | ||
| 243 | const removed = { ...SNAPSHOT, snapshotSequence: 2, threads: [] }; | ||
| 244 | h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), removed); | ||
| 245 | expect(h.registry.get(h.visibleAtom)?.threads).toEqual([]); | ||
| 246 | yield* Deferred.fail(request.reply, new Error("Thread removed")); | ||
| 247 | yield* Effect.promise(() => result); | ||
| 248 | expect(h.registry.get(h.visibleAtom)).toBe(removed); | ||
| 249 | }), | ||
| 250 | ); | ||
| 251 | |||
| 252 | it.effect("keeps pending approvals visible while a lifecycle request is pending", () => | ||
| 253 | Effect.gen(function* () { | ||
| 254 | const h = yield* makeHarness(); | ||
| 255 | const blocked = { | ||
| 256 | ...SNAPSHOT, | ||
| 257 | threads: [{ ...SNAPSHOT.threads[0]!, hasPendingApprovals: true }], | ||
| 258 | }; | ||
| 259 | h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), blocked); | ||
| 260 | const result = h.commands.settle.run(h.registry, { | ||
| 261 | environmentId: ENVIRONMENT_ID, | ||
| 262 | input: { threadId: THREAD_ID }, | ||
| 263 | }); | ||
| 264 | const request = yield* Queue.take(h.requests); | ||
| 265 | expect(h.registry.get(h.visibleAtom)?.threads[0]).toBe(blocked.threads[0]); | ||
| 266 | yield* Deferred.fail(request.reply, new Error("Approval pending")); | ||
| 267 | yield* Effect.promise(() => result); | ||
| 268 | }), | ||
| 269 | ); | ||
| 270 | |||
| 271 | for (const action of ["settle", "snooze"] as const) { | ||
| 272 | it.effect(`restores a confirmed ${action} when a queued undo fails`, () => | ||
| 273 | Effect.gen(function* () { | ||
| 274 | const h = yield* makeHarness(); | ||
| 275 | const parked = | ||
| 276 | action === "settle" | ||
| 277 | ? { settledOverride: "settled" as const } | ||
| 278 | : { snoozedUntil: "2099-01-01T00:00:00.000Z" }; | ||
| 279 | const awake = action === "settle" ? { settledOverride: "active" } : { snoozedUntil: null }; | ||
| 280 | const result = h.commands[action].run(h.registry, { | ||
| 281 | environmentId: ENVIRONMENT_ID, | ||
| 282 | input: { threadId: THREAD_ID, snoozedUntil: "2099-01-01T00:00:00.000Z" }, | ||
| 283 | }); | ||
| 284 | const first = yield* Queue.take(h.requests); | ||
| 285 | const undo = h.commands[action === "settle" ? "unsettle" : "unsnooze"].run(h.registry, { | ||
| 286 | environmentId: ENVIRONMENT_ID, | ||
| 287 | input: { threadId: THREAD_ID, reason: "user" }, | ||
| 288 | }); | ||
| 289 | expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject(awake); | ||
| 290 | yield* Deferred.succeed(first.reply, { sequence: 2 }); | ||
| 291 | expect((yield* Effect.promise(() => result))._tag).toBe("Success"); | ||
| 292 | expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject(awake); | ||
| 293 | const confirmed = { | ||
| 294 | ...SNAPSHOT, | ||
| 295 | snapshotSequence: 2, | ||
| 296 | threads: [{ ...SNAPSHOT.threads[0]!, ...parked }], | ||
| 297 | }; | ||
| 298 | h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), confirmed); | ||
| 299 | expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject(awake); | ||
| 300 | const second = yield* Queue.take(h.requests); | ||
| 301 | expect(second.command.type).toBe( | ||
| 302 | action === "settle" ? "thread.unsettle" : "thread.unsnooze", | ||
| 303 | ); | ||
| 304 | yield* Deferred.fail(second.reply, new Error("Undo rejected")); | ||
| 305 | expect((yield* Effect.promise(() => undo))._tag).toBe("Failure"); | ||
| 306 | expect(h.registry.get(h.visibleAtom)).toBe(confirmed); | ||
| 307 | }), | ||
| 308 | ); | ||
| 309 | |||
| 310 | it.effect(`preserves a newer approval when the ${action} reply arrives after the shell`, () => | ||
| 311 | Effect.gen(function* () { | ||
| 312 | const h = yield* makeHarness(); | ||
| 313 | const result = h.commands[action].run(h.registry, { | ||
| 314 | environmentId: ENVIRONMENT_ID, | ||
| 315 | input: { threadId: THREAD_ID, snoozedUntil: "2099-01-01T00:00:00.000Z" }, | ||
| 316 | }); | ||
| 317 | const request = yield* Queue.take(h.requests); | ||
| 318 | const newer = { | ||
| 319 | ...SNAPSHOT, | ||
| 320 | snapshotSequence: 3, | ||
| 321 | threads: [{ ...SNAPSHOT.threads[0]!, hasPendingApprovals: true }], | ||
| 322 | }; | ||
| 323 | h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), newer); | ||
| 324 | expect(h.registry.get(h.visibleAtom)?.threads[0]).toBe(newer.threads[0]); | ||
| 325 | yield* Deferred.succeed(request.reply, { sequence: 2 }); | ||
| 326 | expect((yield* Effect.promise(() => result))._tag).toBe("Success"); | ||
| 327 | expect(h.registry.get(h.visibleAtom)).toBe(newer); | ||
| 328 | }), | ||
| 329 | ); | ||
| 330 | |||
| 331 | it.effect(`shows an accepted ${action} while the shell still has an old input request`, () => | ||
| 332 | Effect.gen(function* () { | ||
| 333 | const h = yield* makeHarness(); | ||
| 334 | const stale = { | ||
| 335 | ...SNAPSHOT, | ||
| 336 | threads: [{ ...SNAPSHOT.threads[0]!, hasPendingUserInput: true }], | ||
| 337 | }; | ||
| 338 | h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), stale); | ||
| 339 | const result = h.commands[action].run(h.registry, { | ||
| 340 | environmentId: ENVIRONMENT_ID, | ||
| 341 | input: { threadId: THREAD_ID, snoozedUntil: "2099-01-01T00:00:00.000Z" }, | ||
| 342 | }); | ||
| 343 | const request = yield* Queue.take(h.requests); | ||
| 344 | expect(h.registry.get(h.visibleAtom)?.threads[0]).toBe(stale.threads[0]); | ||
| 345 | yield* Deferred.succeed(request.reply, { sequence: 2 }); | ||
| 346 | expect((yield* Effect.promise(() => result))._tag).toBe("Success"); | ||
| 347 | expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject( | ||
| 348 | action === "settle" | ||
| 349 | ? { settledOverride: "settled" } | ||
| 350 | : { snoozedUntil: "2099-01-01T00:00:00.000Z" }, | ||
| 351 | ); | ||
| 352 | expect(h.registry.get(h.visibleAtom)?.threads[0]?.hasPendingUserInput).toBe(false); | ||
| 353 | expect(h.registry.get(h.snapshotAtom(ENVIRONMENT_ID))).toBe(stale); | ||
| 354 | }), | ||
| 355 | ); | ||
| 356 | } | ||
| 357 | }); | ||
packages/client-runtime/src/state/threadCommands.ts+85-2| ... | @@ -1,6 +1,13 @@ | ... | @@ -1,6 +1,13 @@ |
| 1 | import * as Crypto from "effect/Crypto"; | 1 | import * as Crypto from "effect/Crypto"; |
| 2 | import { Atom } from "effect/unstable/reactivity"; | 2 | import { Atom } from "effect/unstable/reactivity"; |
| 3 | import { WS_METHODS } from "@t3tools/contracts"; | 3 | import { |
| 4 | WS_METHODS, | ||
| 5 | type EnvironmentId, | ||
| 6 | type OrchestrationShellSnapshot, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | |||
| 9 | import { createOptimisticThreadLifecycle } from "./threadLifecycle.ts"; | ||
| 10 | import { canSnooze } from "./threadSettled.ts"; | ||
| 4 | 11 | ||
| 5 | import { | 12 | import { |
| 6 | createAtomCommandScheduler, | 13 | createAtomCommandScheduler, |
| ... | @@ -88,6 +95,7 @@ export type { | ... | @@ -88,6 +95,7 @@ export type { |
| 88 | 95 | ||
| 89 | export function createThreadEnvironmentAtoms<R, E>( | 96 | export function createThreadEnvironmentAtoms<R, E>( |
| 90 | runtime: Atom.AtomRuntime<EnvironmentRegistry | Crypto.Crypto | R, E>, | 97 | runtime: Atom.AtomRuntime<EnvironmentRegistry | Crypto.Crypto | R, E>, |
| 98 | snapshotAtom: (environmentId: EnvironmentId) => Atom.Atom<OrchestrationShellSnapshot | null>, | ||
| 91 | ) { | 99 | ) { |
| 92 | const scheduler = createAtomCommandScheduler(); | 100 | const scheduler = createAtomCommandScheduler(); |
| 93 | const concurrency = { | 101 | const concurrency = { |
| ... | @@ -95,7 +103,7 @@ export function createThreadEnvironmentAtoms<R, E>( | ... | @@ -95,7 +103,7 @@ export function createThreadEnvironmentAtoms<R, E>( |
| 95 | key: ({ environmentId, input }: { environmentId: string; input: { threadId: string } }) => | 103 | key: ({ environmentId, input }: { environmentId: string; input: { threadId: string } }) => |
| 96 | JSON.stringify([environmentId, input.threadId]), | 104 | JSON.stringify([environmentId, input.threadId]), |
| 97 | }; | 105 | }; |
| 98 | return { | 106 | const commands = { |
| 99 | create: createEnvironmentCommand(runtime, { | 107 | create: createEnvironmentCommand(runtime, { |
| 100 | label: "environment-data:commands:thread:create", | 108 | label: "environment-data:commands:thread:create", |
| 101 | execute: (input: CreateThreadInput) => createThread(input), | 109 | execute: (input: CreateThreadInput) => createThread(input), |
| ... | @@ -247,4 +255,79 @@ export function createThreadEnvironmentAtoms<R, E>( | ... | @@ -247,4 +255,79 @@ export function createThreadEnvironmentAtoms<R, E>( |
| 247 | concurrency, | 255 | concurrency, |
| 248 | }), | 256 | }), |
| 249 | }; | 257 | }; |
| 258 | const optimistic = createOptimisticThreadLifecycle(snapshotAtom); | ||
| 259 | return { | ||
| 260 | ...commands, | ||
| 261 | snapshotAtom: optimistic.snapshotAtom, | ||
| 262 | settle: optimistic.wrap(commands.settle, (thread, _input, now, accepted) => | ||
| 263 | !accepted && | ||
| 264 | (!canSnooze(thread, { now }) || | ||
| 265 | thread.session?.status === "starting" || | ||
| 266 | thread.session?.status === "running") | ||
| 267 | ? thread | ||
| 268 | : { | ||
| 269 | ...thread, | ||
| 270 | hasPendingApprovals: false, | ||
| 271 | hasPendingUserInput: false, | ||
| 272 | settledOverride: "settled", | ||
| 273 | settledAt: thread.settledOverride === "settled" ? (thread.settledAt ?? now) : now, | ||
| 274 | unsettledAt: null, | ||
| 275 | activeOrderKey: null, | ||
| 276 | pinnedAt: null, | ||
| 277 | pinOrderKey: null, | ||
| 278 | snoozedAt: null, | ||
| 279 | snoozedUntil: null, | ||
| 280 | }, | ||
| 281 | ), | ||
| 282 | unsettle: optimistic.wrap(commands.unsettle, (thread, input, now) => ({ | ||
| 283 | ...thread, | ||
| 284 | settledOverride: input.reason === "user" ? "active" : null, | ||
| 285 | settledAt: null, | ||
| 286 | unsettledAt: thread.settledOverride === "active" ? (thread.unsettledAt ?? null) : now, | ||
| 287 | })), | ||
| 288 | snooze: optimistic.wrap(commands.snooze, (thread, input, now, accepted) => | ||
| 289 | (!accepted && !canSnooze(thread, { now })) || | ||
| 290 | !(Date.parse(input.snoozedUntil) > Date.parse(now)) | ||
| 291 | ? thread | ||
| 292 | : { | ||
| 293 | ...thread, | ||
| 294 | hasPendingApprovals: false, | ||
| 295 | hasPendingUserInput: false, | ||
| 296 | snoozedUntil: input.snoozedUntil, | ||
| 297 | snoozedAt: thread.snoozedUntil === input.snoozedUntil ? (thread.snoozedAt ?? now) : now, | ||
| 298 | }, | ||
| 299 | ), | ||
| 300 | unsnooze: optimistic.wrap(commands.unsnooze, (thread) => ({ | ||
| 301 | ...thread, | ||
| 302 | snoozedUntil: null, | ||
| 303 | snoozedAt: null, | ||
| 304 | })), | ||
| 305 | pin: optimistic.wrap(commands.pin, (thread, input, now) => ({ | ||
| 306 | ...thread, | ||
| 307 | pinnedAt: thread.pinnedAt ?? now, | ||
| 308 | pinOrderKey: thread.pinnedAt == null ? (input.orderKey ?? null) : thread.pinOrderKey, | ||
| 309 | ...(thread.settledOverride === "settled" | ||
| 310 | ? { | ||
| 311 | settledOverride: "active" as const, | ||
| 312 | settledAt: null, | ||
| 313 | unsettledAt: now, | ||
| 314 | } | ||
| 315 | : {}), | ||
| 316 | snoozedUntil: null, | ||
| 317 | snoozedAt: null, | ||
| 318 | })), | ||
| 319 | unpin: optimistic.wrap(commands.unpin, (thread) => ({ | ||
| 320 | ...thread, | ||
| 321 | pinnedAt: null, | ||
| 322 | pinOrderKey: null, | ||
| 323 | })), | ||
| 324 | reorderPin: optimistic.wrap(commands.reorderPin, (thread, input) => ({ | ||
| 325 | ...thread, | ||
| 326 | pinOrderKey: input.orderKey, | ||
| 327 | })), | ||
| 328 | reorderActive: optimistic.wrap(commands.reorderActive, (thread, input) => ({ | ||
| 329 | ...thread, | ||
| 330 | activeOrderKey: input.orderKey, | ||
| 331 | })), | ||
| 332 | }; | ||
| 250 | } | 333 | } |
packages/client-runtime/src/state/threadLifecycle.ts created+102| ... | @@ -0,0 +1,102 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentId, | ||
| 3 | OrchestrationShellSnapshot, | ||
| 4 | OrchestrationThreadShell, | ||
| 5 | ThreadId, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import * as DateTime from "effect/DateTime"; | ||
| 8 | import { Atom } from "effect/unstable/reactivity"; | ||
| 9 | |||
| 10 | import type { AtomCommand } from "./runtime.ts"; | ||
| 11 | |||
| 12 | interface PendingThreadUpdate { | ||
| 13 | readonly threadId: ThreadId; | ||
| 14 | readonly apply: (thread: OrchestrationThreadShell) => OrchestrationThreadShell; | ||
| 15 | sequence?: number; | ||
| 16 | } | ||
| 17 | |||
| 18 | export function createOptimisticThreadLifecycle( | ||
| 19 | sourceSnapshotAtom: ( | ||
| 20 | environmentId: EnvironmentId, | ||
| 21 | ) => Atom.Atom<OrchestrationShellSnapshot | null>, | ||
| 22 | ) { | ||
| 23 | const pendingAtom = Atom.family((_environmentId: EnvironmentId) => | ||
| 24 | Atom.make<ReadonlyArray<PendingThreadUpdate>>([]).pipe(Atom.keepAlive), | ||
| 25 | ); | ||
| 26 | const snapshotAtom = Atom.family((environmentId: EnvironmentId) => | ||
| 27 | Atom.make((get) => { | ||
| 28 | const snapshot = get(sourceSnapshotAtom(environmentId)); | ||
| 29 | const pending = get(pendingAtom(environmentId)); | ||
| 30 | if (snapshot === null || pending.length === 0) return snapshot; | ||
| 31 | const byThread = new Map<ThreadId, PendingThreadUpdate[]>(); | ||
| 32 | for (const update of pending) { | ||
| 33 | if (update.sequence !== undefined && update.sequence <= snapshot.snapshotSequence) continue; | ||
| 34 | const updates = byThread.get(update.threadId) ?? []; | ||
| 35 | updates.push(update); | ||
| 36 | byThread.set(update.threadId, updates); | ||
| 37 | } | ||
| 38 | if (byThread.size === 0) return snapshot; | ||
| 39 | return { | ||
| 40 | ...snapshot, | ||
| 41 | threads: snapshot.threads.map((thread) => | ||
| 42 | (byThread.get(thread.id) ?? []).reduce( | ||
| 43 | (current, update) => update.apply(current), | ||
| 44 | thread, | ||
| 45 | ), | ||
| 46 | ), | ||
| 47 | }; | ||
| 48 | }), | ||
| 49 | ); | ||
| 50 | |||
| 51 | function wrap<Input extends { readonly threadId: ThreadId }, E>( | ||
| 52 | command: AtomCommand< | ||
| 53 | { readonly environmentId: EnvironmentId; readonly input: Input }, | ||
| 54 | { readonly sequence: number }, | ||
| 55 | E | ||
| 56 | >, | ||
| 57 | apply: ( | ||
| 58 | thread: OrchestrationThreadShell, | ||
| 59 | input: Input, | ||
| 60 | now: string, | ||
| 61 | accepted: boolean, | ||
| 62 | ) => OrchestrationThreadShell, | ||
| 63 | ): typeof command { | ||
| 64 | return { | ||
| 65 | label: command.label, | ||
| 66 | run: async (registry, target) => { | ||
| 67 | const now = DateTime.formatIso(DateTime.nowUnsafe()); | ||
| 68 | const pending = pendingAtom(target.environmentId); | ||
| 69 | const source = sourceSnapshotAtom(target.environmentId); | ||
| 70 | const update: PendingThreadUpdate = { | ||
| 71 | threadId: target.input.threadId, | ||
| 72 | apply: (thread) => apply(thread, target.input, now, update.sequence !== undefined), | ||
| 73 | }; | ||
| 74 | const remove = () => | ||
| 75 | registry.update(pending, (current) => current.filter((item) => item !== update)); | ||
| 76 | registry.update(pending, (current) => [...current, update]); | ||
| 77 | let confirmed = false; | ||
| 78 | try { | ||
| 79 | const result = await command.run(registry, target); | ||
| 80 | if (result._tag === "Success") { | ||
| 81 | update.sequence = result.value.sequence; | ||
| 82 | registry.update(pending, (current) => [...current]); | ||
| 83 | const reconcile = (snapshot: OrchestrationShellSnapshot | null) => { | ||
| 84 | if (snapshot === null || snapshot.snapshotSequence >= result.value.sequence) { | ||
| 85 | remove(); | ||
| 86 | unsubscribe(); | ||
| 87 | } | ||
| 88 | }; | ||
| 89 | const unsubscribe = registry.subscribe(source, reconcile); | ||
| 90 | reconcile(registry.get(source)); | ||
| 91 | confirmed = true; | ||
| 92 | } | ||
| 93 | return result; | ||
| 94 | } finally { | ||
| 95 | if (!confirmed) remove(); | ||
| 96 | } | ||
| 97 | }, | ||
| 98 | }; | ||
| 99 | } | ||
| 100 | |||
| 101 | return { snapshotAtom, wrap }; | ||
| 102 | } | ||