From 08afa3337b70d07b519e108a8f0a06a8146b8b8f Mon Sep 17 00:00:00 2001 From: whoisaldo Date: Mon, 26 Oct 2026 13:49:43 -0400 Subject: [PATCH] fix(mobile): opening a new thread before it is created no longer marks it unavailable Tapping a thread whose creation was still queued in the outbox preloaded its detail by id. The server answered not found, and that was cached as a deleted thread. The preload now uses the same creation check as the thread screen and waits until the creation is delivered. --- apps/mobile/src/state/threads.test.ts | 110 ++++++++++++++++++++++++++ apps/mobile/src/state/threads.ts | 17 ++++ 2 files changed, 127 insertions(+) create mode 100644 apps/mobile/src/state/threads.test.ts diff --git a/apps/mobile/src/state/threads.test.ts b/apps/mobile/src/state/threads.test.ts new file mode 100644 index 0000000000000..8cdb476e427e2 --- /dev/null +++ b/apps/mobile/src/state/threads.test.ts @@ -0,0 +1,110 @@ +import { + CommandId, + EnvironmentId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { appAtomRegistry } from "./atom-registry"; +import { pendingThreadCreationOutcomesAtom } from "./pending-thread-creation"; +import { threadOutboxManager } from "./thread-outbox"; +import type { QueuedThreadMessage } from "./thread-outbox-model"; +import { startOpeningEnvironmentThread } from "./threads"; + +const loads = vi.hoisted(() => [] as string[]); + +vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: {} })); +vi.mock("../connection/catalog", () => ({ environmentCatalog: {} })); +vi.mock("./shell", () => ({ environmentShell: {} })); +vi.mock("@t3tools/client-runtime/state/threads", async (importOriginal) => { + const { Atom } = await import("effect/reactivity"); + const stateAtom = Atom.family((key: string) => + Atom.make(() => { + loads.push(key); + return null; + }), + ); + return { + ...(await importOriginal()), + createThreadFamilyAtoms: () => ({}), + createOlderSettledThreadAtoms: () => ({}), + createThreadEnvironmentAtoms: () => ({}), + createEnvironmentThreadDetailAtoms: () => ({}), + createEnvironmentThreadShellAtoms: () => ({}), + createEnvironmentThreadStateAtoms: () => ({ + stateAtom: (environmentId: string, threadId: string) => + stateAtom(`${environmentId}:${threadId}`), + }), + }; +}); + +const environmentId = EnvironmentId.make("environment-1"); +const threadId = ThreadId.make("thread-1"); +const threadKey = `${environmentId}:${threadId}`; +const creation: QueuedThreadMessage = { + environmentId, + threadId, + messageId: MessageId.make("message-1"), + commandId: CommandId.make("command-1"), + text: "Fix the flaky login test", + attachments: [], + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, + creation: { + projectId: ProjectId.make("project-1"), + workspaceMode: "local", + branch: "main", + worktreePath: null, + }, + createdAt: "2026-08-24T12:00:00.000Z", +}; + +describe("startOpeningEnvironmentThread", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.runAllTimers(); + vi.useRealTimers(); + loads.length = 0; + appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); + appAtomRegistry.set(pendingThreadCreationOutcomesAtom, {}); + }); + + it("waits for a queued thread creation to be delivered before loading the thread", () => { + appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { + [threadKey]: [creation], + }); + startOpeningEnvironmentThread(environmentId, threadId); + expect(loads).toEqual([]); + + appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); + appAtomRegistry.set(pendingThreadCreationOutcomesAtom, { + [threadKey]: { kind: "delivered", message: creation }, + }); + startOpeningEnvironmentThread(environmentId, threadId); + expect(loads).toEqual([threadKey]); + }); + + it("does not load a thread whose creation failed", () => { + appAtomRegistry.set(pendingThreadCreationOutcomesAtom, { + [threadKey]: { kind: "failed", message: creation, reason: "Checkout failed" }, + }); + startOpeningEnvironmentThread(environmentId, threadId); + expect(loads).toEqual([]); + }); + + it("loads threads without a pending creation", () => { + const { creation: _creation, ...followUp } = creation; + const otherThreadId = ThreadId.make("thread-2"); + appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { + [threadKey]: [followUp], + }); + startOpeningEnvironmentThread(environmentId, threadId); + startOpeningEnvironmentThread(environmentId, otherThreadId); + expect(loads).toEqual([threadKey, `${environmentId}:${otherThreadId}`]); + }); +}); diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index dffdb251dda70..ee629708e3a18 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -16,8 +16,14 @@ import { useEffect } from "react"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; +import { scopedThreadKey } from "../lib/scopedEntities"; import { appAtomRegistry } from "./atom-registry"; +import { + pendingThreadCreationOutcomesAtom, + selectThreadCreationDetail, +} from "./pending-thread-creation"; import { environmentShell } from "./shell"; +import { threadOutboxManager } from "./thread-outbox"; export const threadFamilies = createThreadFamilyAtoms( connectionAtomRuntime, @@ -59,6 +65,17 @@ const OPENING_THREAD_HOLD_MS = 3_000; * otherwise waits for navigation to commit. */ export function startOpeningEnvironmentThread(environmentId: EnvironmentId, threadId: ThreadId) { + // A thread whose creation is still queued would load as not found and stay deleted; + // the opened screen loads it once creation is delivered. + const { detailRef } = selectThreadCreationDetail({ + threadRef: { environmentId, threadId }, + threadKey: scopedThreadKey(environmentId, threadId), + queuedMessagesByThreadKey: appAtomRegistry.get( + threadOutboxManager.queuedMessagesByThreadKeyAtom, + ), + outcomes: appAtomRegistry.get(pendingThreadCreationOutcomesAtom), + }); + if (detailRef === null) return; const release = appAtomRegistry.mount(environmentThreads.stateAtom(environmentId, threadId)); setTimeout(release, OPENING_THREAD_HOLD_MS); }