From d4e1ceee731e37c4003c9429f274881f1908d895 Mon Sep 17 00:00:00 2001 From: Tanner Sharon Date: Mon, 2 Nov 2026 00:47:41 -0600 Subject: [PATCH 01/14] perf(server): leave idle subagent chains out of the shell for clients that read them on demand Shells keep every idle subagent that is the parent of another subagent, so nested chains send most of their rows on each cold connect. One real database sends 331 shell rows where 54 are needed. Clients now send omitIdleSubagentParents. With it, the server keeps an idle subagent only while a live subagent is below it. A chain that wakes later reaches the client as a row whose parent is missing, and the client reads that root's thread family and merges it behind the shell rows, so badge, child-input and sidebar walks see the whole chain. Clients without the flag get the same rows as before, and older servers ignore it. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/ShellStream.test.ts | 17 ++++-- .../src/orchestration-v2/ShellStream.ts | 60 ++++++++++++++++--- apps/server/src/orchestration-v2/http.ts | 2 + apps/server/src/ws.test.ts | 13 +++- apps/server/src/ws.ts | 2 + docs/internals/connection-runtime.md | 5 +- .../src/state/shell-sync.test.ts | 1 + packages/client-runtime/src/state/shell.ts | 4 +- .../src/state/shellSnapshotHttp.ts | 4 +- .../src/state/threadFamilies.test.ts | 27 +++++++-- .../src/state/threadFamilies.ts | 39 ++++++++++-- packages/contracts/src/environmentHttp.ts | 1 + packages/contracts/src/orchestrationV2.ts | 6 ++ 13 files changed, 153 insertions(+), 28 deletions(-) diff --git a/apps/server/src/orchestration-v2/ShellStream.test.ts b/apps/server/src/orchestration-v2/ShellStream.test.ts index 336f9b12f96bf..38bbdd44afffa 100644 --- a/apps/server/src/orchestration-v2/ShellStream.test.ts +++ b/apps/server/src/orchestration-v2/ShellStream.test.ts @@ -181,15 +181,22 @@ describe("omitIdleSubagentShells", () => { // Idle, but a woken grandchild walks through it to reach the root's badge. row("middle", "root"), row("grandchild", "middle"), + row("upper", "root"), + row("lower", "upper"), + row("deep", "lower", { activityRunStatus: "running" }), ]; + const live = ["root", "running", "queued", "asking", "background"]; expect(omitIdleSubagentShells(threads).map((thread) => thread.id)).toEqual([ - "root", - "running", - "queued", - "asking", - "background", + ...live, "middle", + "upper", + "lower", + "deep", ]); + // Clients that walk past a missing parent keep only the chains above live children. + expect( + omitIdleSubagentShells(threads, { omitIdleSubagentParents: true }).map((thread) => thread.id), + ).toEqual([...live, "upper", "lower", "deep"]); }); it("still filters the transcript events of omitted children for a subscriber that opts in", () => { diff --git a/apps/server/src/orchestration-v2/ShellStream.ts b/apps/server/src/orchestration-v2/ShellStream.ts index 0fa5251a7ef73..b01605125a31e 100644 --- a/apps/server/src/orchestration-v2/ShellStream.ts +++ b/apps/server/src/orchestration-v2/ShellStream.ts @@ -138,15 +138,42 @@ function hiddenChildAncestorIds( return ancestorIds; } +/** Live subagents and the subagents above them, so each live one keeps its chain to the root. */ +function liveSubagentChainIds>( + rows: ReadonlyArray, + isLive: (row: T) => boolean, +): ReadonlySet { + const subagents = new Map(); + for (const row of rows) { + if (row.lineage.relationshipToParent === "subagent") subagents.set(row.id, row); + } + const kept = new Set(); + for (const row of subagents.values()) { + if (!isLive(row)) continue; + let current: T | undefined = row; + while (current !== undefined && !kept.has(current.id)) { + kept.add(current.id); + const parentId: string | null = current.lineage.parentThreadId; + current = parentId === null ? undefined : subagents.get(parentId); + } + } + return kept; +} + function omitIdleSubagentShellsWith( threads: ReadonlyArray, facts: Pick, + omitIdleSubagentParents: boolean, ): ReadonlyArray { + const chains = omitIdleSubagentParents + ? liveSubagentChainIds(threads, isLiveSubagentShell) + : null; return threads.filter( (thread) => thread.lineage.relationshipToParent !== "subagent" || - isLiveSubagentShell(thread) || - facts.subagentParentIds.has(thread.id), + (chains === null + ? isLiveSubagentShell(thread) || facts.subagentParentIds.has(thread.id) + : chains.has(thread.id)), ); } @@ -183,14 +210,20 @@ function omitOlderSettledShellsWith( } /** - * Idle subagent children have no sidebar row, so clients that opt in read them from their thread - * family instead. A child stays while it is live or the parent of another child, so the request - * and working walks over shell rows never meet a gap in a nested chain. + * Idle subagent children have no sidebar row; opted-in clients read them from their thread family. + * A child stays while it is live or the parent of another child, so shell walks never meet a gap. + * With `omitIdleSubagentParents` it stays only while live or above a live one, for clients that + * read the family of a child whose parent row is missing. */ export function omitIdleSubagentShells( threads: ReadonlyArray, + options: { readonly omitIdleSubagentParents: boolean } = { omitIdleSubagentParents: false }, ): ReadonlyArray { - return omitIdleSubagentShellsWith(threads, shellTrimFacts(threads)); + return omitIdleSubagentShellsWith( + threads, + shellTrimFacts(threads), + options.omitIdleSubagentParents, + ); } /** @@ -206,7 +239,7 @@ export function activeShellRowsForSubscriber( } { const facts = shellTrimFacts(threads); return { - threads: omitIdleSubagents ? omitIdleSubagentShellsWith(threads, facts) : threads, + threads: omitIdleSubagents ? omitIdleSubagentShellsWith(threads, facts, false) : threads, subagentThreadIds: facts.subagentThreadIds, }; } @@ -230,6 +263,8 @@ export function omitOlderSettledShells(input: { interface ShellSubscriberOptions { readonly omitIdleSubagents: boolean; + /** Only with `omitIdleSubagents`. */ + readonly omitIdleSubagentParents: boolean; readonly omitOlderSettled: boolean; } @@ -239,7 +274,7 @@ function trimShellSnapshot( options: ShellSubscriberOptions & { readonly now: DateTime.Utc }, ): OrchestrationV2ShellSnapshot { const threads = options.omitIdleSubagents - ? omitIdleSubagentShellsWith(base.threads, facts) + ? omitIdleSubagentShellsWith(base.threads, facts, options.omitIdleSubagentParents) : base.threads; if (!options.omitOlderSettled) return { ...base, threads }; const older = omitOlderSettledShellsWith( @@ -258,10 +293,17 @@ function shellSubscriberThreadIds( options: ShellSubscriberOptions, ): ReadonlyArray { const facts = shellTrimFacts(index); + const chains = + options.omitIdleSubagents && options.omitIdleSubagentParents + ? liveSubagentChainIds(index, (entry) => entry.mayBeLive) + : null; const subagents = index.filter( (entry) => entry.lineage.relationshipToParent === "subagent" && - (!options.omitIdleSubagents || entry.mayBeLive || facts.subagentParentIds.has(entry.id)), + (!options.omitIdleSubagents || + (chains === null + ? entry.mayBeLive || facts.subagentParentIds.has(entry.id) + : chains.has(entry.id))), ); const ancestorIds = hiddenChildAncestorIds([ ...subagents, diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index 3bba05f7db63a..5edeebf0f9fa1 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -79,6 +79,7 @@ export const layer = HttpApiBuilder.group( const loadShellSnapshot = Effect.fn("http.orchestration.loadShellSnapshot")( function* (options: { readonly omitIdleSubagents: boolean; + readonly omitIdleSubagentParents: boolean; readonly omitOlderSettled: boolean; }) { const { snapshot } = yield* loadSubscriberShellSnapshot({ @@ -168,6 +169,7 @@ export const layer = HttpApiBuilder.group( yield* requireEnvironmentScope(AuthOrchestrationReadScope); return yield* loadShellSnapshot({ omitIdleSubagents: args.query.omitIdleSubagents === "1", + omitIdleSubagentParents: args.query.omitIdleSubagentParents === "1", omitOlderSettled: args.query.omitOlderSettled === "1", }).pipe( traceLocalHandlerWork, diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 2b64947dbc7df..fe4ec1631b384 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -708,9 +708,12 @@ it.effect("shell snapshots omit idle children and older settled threads only on shell("live", "parent", "running"), ...Array.from({ length: 25 }, (_, index) => shell(`recent-${index}`, null, null, 1)), shell("old", null, null, 30), + shell("middle", "parent", null), + shell("leaf", "middle", null), ]; const firstSnapshot = (input: { readonly omitIdleSubagents?: boolean; + readonly omitIdleSubagentParents?: boolean; readonly omitOlderSettled?: boolean; }) => subscribeOrchestrationV2Shell(input).pipe( @@ -767,12 +770,20 @@ it.effect("shell snapshots omit idle children and older settled threads only on const withoutChildren = yield* firstSnapshot({ omitIdleSubagents: true }); assert.deepEqual( withoutChildren?.threads.map((thread) => thread.id), + ["parent", "live", ...recent, "old", "middle"], + ); + const withoutIdleParents = yield* firstSnapshot({ + omitIdleSubagents: true, + omitIdleSubagentParents: true, + }); + assert.deepEqual( + withoutIdleParents?.threads.map((thread) => thread.id), ["parent", "live", ...recent, "old"], ); const withoutOld = yield* firstSnapshot({ omitIdleSubagents: true, omitOlderSettled: true }); assert.deepEqual( withoutOld?.threads.map((thread) => thread.id), - ["parent", "live", ...recent], + ["parent", "live", ...recent, "middle"], ); assert.deepEqual(withoutOld?.olderSettled?.counts, [ { projectId: ProjectId.make("project"), count: 1 }, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5257da599c4f2..c86ee55e1b86a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1116,6 +1116,7 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc readonly httpSnapshotFallback?: true; readonly requestCompletionMarker?: boolean; readonly omitIdleSubagents?: boolean; + readonly omitIdleSubagentParents?: boolean; readonly omitOlderSettled?: boolean; }) { const sql = yield* SqlClient.SqlClient; @@ -1157,6 +1158,7 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc latestSequence: applicationEvents.latestApplicationSequence, options: { omitIdleSubagents: input.omitIdleSubagents === true, + omitIdleSubagentParents: input.omitIdleSubagentParents === true, omitOlderSettled: input.omitOlderSettled === true, }, }); diff --git a/docs/internals/connection-runtime.md b/docs/internals/connection-runtime.md index 698ddac7a73f9..6097af6950aae 100644 --- a/docs/internals/connection-runtime.md +++ b/docs/internals/connection-runtime.md @@ -78,7 +78,10 @@ on which data is current. Shell snapshots can leave rows out when the client asks. Servers that advertise `shellThreadFamilies` leave out idle subagent children: a child stays while it has a live or queued run, a pending request or background work, or a subagent -child of its own, so badge and working walks never meet a gap. Servers that also +child of its own, so badge and working walks never meet a gap. Clients that also +send `omitIdleSubagentParents` keep an idle child only while a live subagent is +below it. A child that wakes later reaches them through the live tail without the +idle rows above it, so they read its family whenever a parent row is missing. Servers that also advertise `shellOlderSettled` keep only the 25 newest settled top-level threads (and any tied with the 25th, pinned, snoozed, live, or above a kept child); the snapshot's `olderSettled` names that cut and counts the rest per project. Left-out diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 062db03809cfc..a5e5e4b9b0c25 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -879,6 +879,7 @@ describe("environment shell synchronization", () => { expect(yield* Queue.take(subscribeInputs)).toMatchObject({ afterSequence: 1, omitIdleSubagents: true, + omitIdleSubagentParents: true, omitOlderSettled: true, }); expect(yield* Ref.get(loadOptions)).toEqual([ diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index 808fd9fdece56..8305f3dfae5cd 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -524,7 +524,9 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") } const omission = { - ...(omitIdleSubagents ? { omitIdleSubagents: true as const } : {}), + ...(omitIdleSubagents + ? { omitIdleSubagents: true as const, omitIdleSubagentParents: true as const } + : {}), ...(omitOlderSettled ? { omitOlderSettled: true as const } : {}), }; if (!canResume || Option.isNone(current.snapshot)) { diff --git a/packages/client-runtime/src/state/shellSnapshotHttp.ts b/packages/client-runtime/src/state/shellSnapshotHttp.ts index 9ff944ad6b2fe..a81ceeed345b1 100644 --- a/packages/client-runtime/src/state/shellSnapshotHttp.ts +++ b/packages/client-runtime/src/state/shellSnapshotHttp.ts @@ -38,7 +38,9 @@ export const fetchEnvironmentShellSnapshot = Effect.fn( }) { const endpoint = { query: { - ...(input.omitIdleSubagents === true ? { omitIdleSubagents: "1" } : {}), + ...(input.omitIdleSubagents === true + ? { omitIdleSubagents: "1", omitIdleSubagentParents: "1" } + : {}), ...(input.omitOlderSettled === true ? { omitOlderSettled: "1" } : {}), }, }; diff --git a/packages/client-runtime/src/state/threadFamilies.test.ts b/packages/client-runtime/src/state/threadFamilies.test.ts index 2b2a4a6a12780..1e494e65dbd1d 100644 --- a/packages/client-runtime/src/state/threadFamilies.test.ts +++ b/packages/client-runtime/src/state/threadFamilies.test.ts @@ -33,8 +33,12 @@ import { threadFamilyRowsMissingFromShell, } from "./threadFamilies.ts"; -const row = (id: string, title = id) => - ({ id: ThreadId.make(id), title }) as unknown as OrchestrationV2ThreadShell; +const row = (id: string, title = id, parentThreadId: string | null = null, rootThreadId = id) => + ({ + id: ThreadId.make(id), + title, + lineage: { parentThreadId, rootThreadId, relationshipToParent: "subagent" }, + }) as unknown as OrchestrationV2ThreadShell; describe("threadFamilyRowsMissingFromShell", () => { it("adds only the rows the shell left out, once, with shell rows winning", () => { @@ -177,10 +181,10 @@ describe("createThreadFamilyAtoms", () => { threads: [parent], archivedThreads: [], } as OrchestrationV2ShellSnapshot; - const shellAt = (generation: number): EnvironmentShellState => ({ + const shellAt = (generation: number, threads = shellSnapshot.threads) => ({ ...EMPTY_SHELL_STATE, - status: "live", - snapshot: Option.some(shellSnapshot), + status: "live" as const, + snapshot: Option.some({ ...shellSnapshot, threads }), generation, }); const shellState = Atom.make>( @@ -199,7 +203,7 @@ describe("createThreadFamilyAtoms", () => { Effect.tap(() => Effect.yieldNow), ); registry.mount(families.snapshotAtom(TARGET.environmentId)); - families.mount(registry, TARGET.environmentId, parent.id); + const release = families.mount(registry, TARGET.environmentId, parent.id); // A cached shell from before this session's first snapshot reads nothing. expect(threadFamilyRead(registry.get(family))).toBe("pending"); @@ -230,6 +234,17 @@ describe("createThreadFamilyAtoms", () => { yield* settle; expect(threadFamilyRead(registry.get(family))).toBe("settled"); expect(mergedIds()).toEqual(["parent", "idle-child"]); + + // A child that woke after the shell left out its idle parent reads its family unmounted. + release(); + const woken = row("woken", "woken", child.id, parent.id); + registry.set(shellState, AsyncResult.success(shellAt(3, [parent, woken]))); + yield* Queue.offer( + replies, + Effect.succeed({ snapshotSequence: 6, threads: [parent, child, woken] }), + ); + yield* settle; + expect(mergedIds()).toEqual(["parent", "woken", "idle-child"]); }), ), ); diff --git a/packages/client-runtime/src/state/threadFamilies.ts b/packages/client-runtime/src/state/threadFamilies.ts index 0f1bb8d9cd274..ab80cd0f3596c 100644 --- a/packages/client-runtime/src/state/threadFamilies.ts +++ b/packages/client-runtime/src/state/threadFamilies.ts @@ -65,11 +65,29 @@ export function threadFamilyRowsMissingFromShell(input: { return missing; } +/** + * Roots of subagents and side chats whose parent row the shell left out. A shell sent with + * `omitIdleSubagentParents` drops idle chains, so a child that wakes later arrives without them. + */ +function rootsWithMissingParents( + threads: ReadonlyArray, +): ReadonlyArray { + const present = new Set(threads.map((thread) => thread.id)); + const roots = new Set(); + for (const { lineage } of threads) { + if (lineage.parentThreadId === null || present.has(lineage.parentThreadId)) continue; + if (lineage.relationshipToParent === "subagent" || lineage.sideChat === true) { + roots.add(lineage.rootThreadId); + } + } + return [...roots]; +} + /** * Shells with `omitIdleSubagents` or `omitOlderSettled` leave rows to on-demand reads: a family is * every active thread sharing a `lineage.rootThreadId`, read once per shell generation; shelf pages * and search rows mount through `mountRows`. All merge behind shell rows, which win (deltas carry - * every change), and are never persisted. + * every change), and are never persisted. A child whose parent row is missing reads its family too. */ export function createThreadFamilyAtoms( runtime: Atom.AtomRuntime, @@ -203,16 +221,29 @@ export function createThreadFamilyAtoms( readonly families: ReadonlyArray; readonly merged: OrchestrationV2ShellSnapshot; } | null = null; + let orphans: { + readonly threads: ReadonlyArray; + readonly rows: ReadonlyArray>>; + } | null = null; return Atom.make((get): OrchestrationV2ShellSnapshot | null => { const state = shellStateValue(get(shellStateAtom(environmentId))); const snapshot = state === null ? null : Option.getOrNull(state.snapshot); const mounts = get(mountsAtom(environmentId)); - if (snapshot === null || state === null || mounts.size === 0) return snapshot; + if (snapshot === null || state === null) return snapshot; + if (orphans?.threads !== snapshot.threads) { + orphans = { + threads: snapshot.threads, + rows: rootsWithMissingParents(snapshot.threads).map((root) => + familyRowsAtom(familyKey(environmentId, root)), + ), + }; + } + if (mounts.size === 0 && orphans.rows.length === 0) return snapshot; // A read from before the latest authoritative snapshot can hold threads removed before it, // and that snapshot cleared those removals; such reads (a cached search) wait for new ones. const cut = snapshot.olderSettled?.asOfSequence ?? 0; - const families = [...mounts.values()].flatMap(({ rows }) => - get(rows).filter((read) => read.snapshotSequence >= cut), + const families = [...[...mounts.values()].map(({ rows }) => rows), ...orphans.rows].flatMap( + (rows) => get(rows).filter((read) => read.snapshotSequence >= cut), ); // Most shell updates move only the cursor. With the same rows, removals and families, keep // the merged rows so thread lists skip their rebuild. diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 4bbae77fb922d..b5a26c4f6e29f 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -684,6 +684,7 @@ const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({ const EnvironmentOrchestrationShellSnapshotQuery = Schema.Struct({ omitIdleSubagents: Schema.optionalKey(Schema.String), + omitIdleSubagentParents: Schema.optionalKey(Schema.String), omitOlderSettled: Schema.optionalKey(Schema.String), }); diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 8d8cc42c235b2..ea79c100a8446 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3768,6 +3768,12 @@ export const OrchestrationV2SubscribeShellInput = Schema.Struct({ * servers that advertise `shellThreadFamilies`. */ omitIdleSubagents: Schema.optionalKey(Schema.Boolean), + /** + * With `omitIdleSubagents`, an idle subagent stays only while a live one is under it, not as the + * parent of any child. Send only from clients that read the family of a child whose parent row + * is missing; older servers ignore it. + */ + omitIdleSubagentParents: Schema.optionalKey(Schema.Boolean), /** * Snapshots leave out older settled threads and count them in `olderSettled`; read them with * `getOlderSettledThreads`. Send only to servers that advertise `shellOlderSettled`. From 12313a839d76c4ffe90189c5e0058569d8e6e646 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 2 Nov 2026 21:42:17 +0000 Subject: [PATCH 02/14] fix(client): load the viewed thread family after shell trimming --- apps/mobile/src/state/use-thread-selection.ts | 12 ++++++++++-- apps/web/src/components/ThreadRouteView.tsx | 7 +++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 35598d74bc36f..47769aa3f0c2c 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -1,6 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import { useRoute, type RouteProp } from "@react-navigation/native"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { EnvironmentId, ThreadId, @@ -21,7 +21,8 @@ import * as Option from "effect/Option"; import { scopedThreadKey } from "../lib/scopedEntities"; import { useProject, useQuietThreadShell } from "./entities"; -import { useEnvironmentThread } from "./threads"; +import { threadFamilies, useEnvironmentThread } from "./threads"; +import { appAtomRegistry } from "./atom-registry"; import { resolvePendingThreadCreation, pendingThreadCreationOutcomesAtom, @@ -139,6 +140,13 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin threadId: ThreadId.make(routeThreadId), } : lastRouteThreadRef; + const familyEnvironmentId = selectedThreadRef?.environmentId; + const familyThreadId = selectedThreadRef?.threadId; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- keeps the viewed thread's omitted family rows mounted until navigation + useEffect(() => { + if (familyEnvironmentId === undefined || familyThreadId === undefined) return; + return threadFamilies.mount(appAtomRegistry, familyEnvironmentId, familyThreadId); + }, [familyEnvironmentId, familyThreadId]); const selectedThreadShell = useQuietThreadShell(selectedThreadRef); const selectedThreadKey = selectedThreadRef === null diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index a7b7da0a6b53b..68318ec732eb1 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -67,6 +67,13 @@ export function ThreadRouteView({ draftId === null ? null : store.getPromotedDraftRedirect(draftId), ); const serverThreadRef = target.kind === "server" ? target.threadRef : null; + const familyEnvironmentId = serverThreadRef?.environmentId; + const familyThreadId = serverThreadRef?.threadId; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- keeps the viewed thread's omitted family rows mounted until navigation + useEffect(() => { + if (familyEnvironmentId === undefined || familyThreadId === undefined) return; + return threadFamilies.mount(appAtomRegistry, familyEnvironmentId, familyThreadId); + }, [familyEnvironmentId, familyThreadId]); const serverThreadShell = useQuietThreadShell(serverThreadRef); const shellStatus = useEnvironmentShellStatus(serverThreadRef?.environmentId ?? null); const environmentThreadRefs = useEnvironmentThreadRefs(serverThreadRef?.environmentId ?? null); From f61dfb60fdfb936fddd4280d8003f22cde4d834d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 2 Nov 2026 22:04:24 +0000 Subject: [PATCH 03/14] fix(client): wait for family recovery before filtering threads --- apps/web/src/components/ThreadRouteView.tsx | 19 ++++++++++++++++--- .../src/state/threadFamilies.test.ts | 18 ++++++++++-------- .../src/state/threadFamilies.ts | 3 ++- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index 68318ec732eb1..c09ec3ed953fc 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -1,9 +1,10 @@ import { useAtomValue } from "@effect/atom-react"; import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { parseThreadKey, threadKey } from "@t3tools/client-runtime/state/entities"; -import { visibilityParentKey } from "@t3tools/client-runtime/state/threads"; +import { threadFamilyRead, visibilityParentKey } from "@t3tools/client-runtime/state/threads"; import type { ScopedThreadRef } from "@t3tools/contracts"; import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/reactivity"; import { Navigate, useNavigate } from "@tanstack/react-router"; import { useEffect, useState } from "react"; @@ -27,6 +28,8 @@ import { type ThreadRouteTarget, } from "../threadRoutes"; +const EMPTY_FAMILY_ATOM = Atom.make(AsyncResult.success({ snapshotSequence: 0, threads: [] })); + /** * Keeps the family of an open thread the shell leaves out (an older settled thread) in the shell * rows until the route leaves it, so every reader of its shell row finds it. @@ -74,6 +77,14 @@ export function ThreadRouteView({ if (familyEnvironmentId === undefined || familyThreadId === undefined) return; return threadFamilies.mount(appAtomRegistry, familyEnvironmentId, familyThreadId); }, [familyEnvironmentId, familyThreadId]); + const familySettled = + threadFamilyRead( + useAtomValue( + serverThreadRef === null + ? EMPTY_FAMILY_ATOM + : threadFamilies.familyAtom(serverThreadRef.environmentId, serverThreadRef.threadId), + ), + ) === "settled"; const serverThreadShell = useQuietThreadShell(serverThreadRef); const shellStatus = useEnvironmentShellStatus(serverThreadRef?.environmentId ?? null); const environmentThreadRefs = useEnvironmentThreadRefs(serverThreadRef?.environmentId ?? null); @@ -216,10 +227,12 @@ export function ThreadRouteView({ redirect = ; } } else if (hiddenByTagFilter) { - redirect = ; + if (familySettled) redirect = ; } else if (offShellBlocked) { // Before the shell bootstraps, a listed visible thread can still look off-shell. - if (bootstrapComplete && unlistedThread !== null) redirect = ; + if (bootstrapComplete && familySettled && unlistedThread !== null) { + redirect = ; + } } else if (renderState === "ready" || (renderState === "loading" && serverThreadShell !== null)) { view = ( { expect(threadFamilyRead(registry.get(family))).toBe("settled"); expect(mergedIds()).toEqual(["parent", "idle-child"]); - // A new authoritative snapshot never reuses the old read, even while the next one waits - // or fails. + // A new authoritative snapshot never reuses the old read while the next one retries. registry.set(shellState, AsyncResult.success(shellAt(2))); expect(threadFamilyRead(registry.get(family))).toBe("pending"); expect(mergedIds()).toEqual(["parent"]); + const failedRead = yield* Deferred.make(); yield* Queue.offer( replies, - Effect.fail(new OrchestrationV2GetShellSnapshotError({ message: "read failed" })), + Effect.fail(new OrchestrationV2GetShellSnapshotError({ message: "read failed" })).pipe( + Effect.ensuring(Deferred.succeed(failedRead, undefined)), + ), ); - yield* settle; - expect(threadFamilyRead(registry.get(family))).toBe("failed"); - expect(mergedIds()).toEqual(["parent"]); - - families.refresh(registry, TARGET.environmentId, parent.id); + yield* Deferred.await(failedRead); + yield* TestClock.adjust("250 millis"); expect(threadFamilyRead(registry.get(family))).toBe("pending"); + expect(mergedIds()).toEqual(["parent"]); yield* Queue.offer(replies, Effect.succeed({ snapshotSequence: 5, threads: [child] })); yield* settle; expect(threadFamilyRead(registry.get(family))).toBe("settled"); diff --git a/packages/client-runtime/src/state/threadFamilies.ts b/packages/client-runtime/src/state/threadFamilies.ts index ab80cd0f3596c..485dfa33088b3 100644 --- a/packages/client-runtime/src/state/threadFamilies.ts +++ b/packages/client-runtime/src/state/threadFamilies.ts @@ -8,6 +8,7 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Schedule from "effect/Schedule"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; @@ -127,7 +128,7 @@ export function createThreadFamilyAtoms( if (config.shellThreadFamilies !== true) return EMPTY_FAMILY; return yield* request(ORCHESTRATION_V2_WS_METHODS.getThreadFamily, { threadId: input.threadId, - }); + }).pipe(Effect.retry({ times: 2, schedule: Schedule.spaced("250 millis") })); }), }); const queryFor = (environmentId: EnvironmentId, threadId: ThreadId, generation: number) => From beadf76f1d761675cbf28e7c2f682a2a878d7b87 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 2 Nov 2026 23:51:06 +0000 Subject: [PATCH 04/14] fix(web): recover blocked thread family reads --- apps/web/src/components/ThreadRouteView.tsx | 47 +++++++++++++++---- .../src/state/threadFamilies.test.ts | 4 ++ .../src/state/threadFamilies.ts | 6 +++ 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index c09ec3ed953fc..9eb1443b7021c 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -11,6 +11,7 @@ import { useEffect, useState } from "react"; import ChatView from "./ChatView"; import { BackgroundThreadContext } from "./chat/backgroundThread"; import { SidebarInset } from "./ui/sidebar"; +import { Button } from "./ui/button"; import { useComposerDraftStore } from "../composerDraftStore"; import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; import { @@ -29,6 +30,7 @@ import { } from "../threadRoutes"; const EMPTY_FAMILY_ATOM = Atom.make(AsyncResult.success({ snapshotSequence: 0, threads: [] })); +const EMPTY_MOUNTED_ATOM = Atom.make(false); /** * Keeps the family of an open thread the shell leaves out (an older settled thread) in the shell @@ -77,14 +79,19 @@ export function ThreadRouteView({ if (familyEnvironmentId === undefined || familyThreadId === undefined) return; return threadFamilies.mount(appAtomRegistry, familyEnvironmentId, familyThreadId); }, [familyEnvironmentId, familyThreadId]); - const familySettled = - threadFamilyRead( - useAtomValue( - serverThreadRef === null - ? EMPTY_FAMILY_ATOM - : threadFamilies.familyAtom(serverThreadRef.environmentId, serverThreadRef.threadId), - ), - ) === "settled"; + const familyRead = threadFamilyRead( + useAtomValue( + serverThreadRef === null + ? EMPTY_FAMILY_ATOM + : threadFamilies.familyAtom(serverThreadRef.environmentId, serverThreadRef.threadId), + ), + ); + const familyMounted = useAtomValue( + serverThreadRef === null + ? EMPTY_MOUNTED_ATOM + : threadFamilies.mountedAtom(serverThreadRef.environmentId, serverThreadRef.threadId), + ); + const familySettled = familyMounted && familyRead === "settled"; const serverThreadShell = useQuietThreadShell(serverThreadRef); const shellStatus = useEnvironmentShellStatus(serverThreadRef?.environmentId ?? null); const environmentThreadRefs = useEnvironmentThreadRefs(serverThreadRef?.environmentId ?? null); @@ -174,6 +181,30 @@ export function ThreadRouteView({ let view: React.ReactNode = null; let redirect: React.ReactNode = null; + if ( + serverThreadRef !== null && + (hiddenByTagFilter || offShellBlocked) && + familyMounted && + familyRead === "failed" + ) { + view = ( +
+

Could not load the thread family.

+ +
+ ); + } if (target.kind === "draft") { if ( draftSession && diff --git a/packages/client-runtime/src/state/threadFamilies.test.ts b/packages/client-runtime/src/state/threadFamilies.test.ts index d82b3ff01c9d0..65d1e309b3b23 100644 --- a/packages/client-runtime/src/state/threadFamilies.test.ts +++ b/packages/client-runtime/src/state/threadFamilies.test.ts @@ -205,7 +205,10 @@ describe("createThreadFamilyAtoms", () => { Effect.tap(() => Effect.yieldNow), ); registry.mount(families.snapshotAtom(TARGET.environmentId)); + const mounted = families.mountedAtom(TARGET.environmentId, parent.id); + expect(registry.get(mounted)).toBe(false); const release = families.mount(registry, TARGET.environmentId, parent.id); + expect(registry.get(mounted)).toBe(true); // A cached shell from before this session's first snapshot reads nothing. expect(threadFamilyRead(registry.get(family))).toBe("pending"); @@ -239,6 +242,7 @@ describe("createThreadFamilyAtoms", () => { // A child that woke after the shell left out its idle parent reads its family unmounted. release(); + expect(registry.get(mounted)).toBe(false); const woken = row("woken", "woken", child.id, parent.id); registry.set(shellState, AsyncResult.success(shellAt(3, [parent, woken]))); yield* Queue.offer( diff --git a/packages/client-runtime/src/state/threadFamilies.ts b/packages/client-runtime/src/state/threadFamilies.ts index 485dfa33088b3..834dc9c7293ac 100644 --- a/packages/client-runtime/src/state/threadFamilies.ts +++ b/packages/client-runtime/src/state/threadFamilies.ts @@ -162,6 +162,10 @@ export function createThreadFamilyAtoms( Atom.withLabel(`environment-thread-family-mounts:${environmentId}`), ), ); + const mountedAtom = Atom.family((key: string) => { + const [environmentId] = JSON.parse(key) as [EnvironmentId, ThreadId]; + return Atom.make((get) => get(mountsAtom(environmentId)).has(`family:${key}`)); + }); const familyRowsAtom = Atom.family((key: string) => Atom.make((get): ReadonlyArray => { @@ -282,6 +286,8 @@ export function createThreadFamilyAtoms( /** The current generation's family read, for decisions that need a settled answer. */ familyAtom: (environmentId: EnvironmentId, threadId: ThreadId) => familyAtom(familyKey(environmentId, threadId)), + mountedAtom: (environmentId: EnvironmentId, threadId: ThreadId) => + mountedAtom(familyKey(environmentId, threadId)), mount, mountRows, refresh, From 7d352b46fd6de0fcafe0d89f6b9c61605c8ef8f9 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 12 Nov 2026 02:16:26 -0800 Subject: [PATCH 05/14] fix(web,mobile): only a blocked open thread waits for its family read Main now mounts the family of an open thread the shell left out (useOpenThreadFamily), so the route no longer mounts a second one for every thread it opens, and mobile no longer reads a family on every thread open. While tags are hidden, an idle subagent below an idle parent has no loaded parent until that family read lands, so the off-shell redirect waits for it. mountedReadAtom stays pending until the family is mounted, so a cached read cannot answer before its rows are merged. A failed read redirects as before; the retry screen and the in-query retry are gone, because a family read runs again on reconnect and on the next mount. Co-Authored-By: Claude Fable 5.2 --- apps/mobile/src/state/use-thread-selection.ts | 12 +-- apps/web/src/components/ThreadRouteView.tsx | 77 +++++-------------- .../src/state/threadFamilies.test.ts | 29 +++---- .../src/state/threadFamilies.ts | 19 +++-- 4 files changed, 51 insertions(+), 86 deletions(-) diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 47769aa3f0c2c..35598d74bc36f 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -1,6 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import { useRoute, type RouteProp } from "@react-navigation/native"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { EnvironmentId, ThreadId, @@ -21,8 +21,7 @@ import * as Option from "effect/Option"; import { scopedThreadKey } from "../lib/scopedEntities"; import { useProject, useQuietThreadShell } from "./entities"; -import { threadFamilies, useEnvironmentThread } from "./threads"; -import { appAtomRegistry } from "./atom-registry"; +import { useEnvironmentThread } from "./threads"; import { resolvePendingThreadCreation, pendingThreadCreationOutcomesAtom, @@ -140,13 +139,6 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin threadId: ThreadId.make(routeThreadId), } : lastRouteThreadRef; - const familyEnvironmentId = selectedThreadRef?.environmentId; - const familyThreadId = selectedThreadRef?.threadId; - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- keeps the viewed thread's omitted family rows mounted until navigation - useEffect(() => { - if (familyEnvironmentId === undefined || familyThreadId === undefined) return; - return threadFamilies.mount(appAtomRegistry, familyEnvironmentId, familyThreadId); - }, [familyEnvironmentId, familyThreadId]); const selectedThreadShell = useQuietThreadShell(selectedThreadRef); const selectedThreadKey = selectedThreadRef === null diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index 9eb1443b7021c..3f17d2c8ce1ba 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -1,17 +1,16 @@ import { useAtomValue } from "@effect/atom-react"; import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { parseThreadKey, threadKey } from "@t3tools/client-runtime/state/entities"; -import { threadFamilyRead, visibilityParentKey } from "@t3tools/client-runtime/state/threads"; +import { type ThreadFamilyRead, visibilityParentKey } from "@t3tools/client-runtime/state/threads"; import type { ScopedThreadRef } from "@t3tools/contracts"; import * as Option from "effect/Option"; -import { AsyncResult, Atom } from "effect/reactivity"; +import { Atom } from "effect/reactivity"; import { Navigate, useNavigate } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import ChatView from "./ChatView"; import { BackgroundThreadContext } from "./chat/backgroundThread"; import { SidebarInset } from "./ui/sidebar"; -import { Button } from "./ui/button"; import { useComposerDraftStore } from "../composerDraftStore"; import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; import { @@ -29,14 +28,14 @@ import { type ThreadRouteTarget, } from "../threadRoutes"; -const EMPTY_FAMILY_ATOM = Atom.make(AsyncResult.success({ snapshotSequence: 0, threads: [] })); -const EMPTY_MOUNTED_ATOM = Atom.make(false); +const NO_OPEN_FAMILY = Atom.make("settled"); /** - * Keeps the family of an open thread the shell leaves out (an older settled thread) in the shell - * rows until the route leaves it, so every reader of its shell row finds it. + * Keeps the family of an open thread the shell leaves out (an older settled thread, an idle + * subagent) in the shell rows until the route leaves it, so every reader of its shell row finds + * it. Returns whether those rows can still arrive. */ -function useOpenThreadFamily(threadRef: ScopedThreadRef | null, missing: boolean) { +function useOpenThreadFamily(threadRef: ScopedThreadRef | null, missing: boolean): boolean { const key = threadRef === null ? null : scopedThreadKey(threadRef); // Sticks to the route thread, so the row the family brings in does not release it. const [mountedKey, setMountedKey] = useState(null); @@ -48,6 +47,12 @@ function useOpenThreadFamily(threadRef: ScopedThreadRef | null, missing: boolean if (ref === null) return; return threadFamilies.mount(appAtomRegistry, ref.environmentId, ref.threadId); }, [mountedKey]); + const read = useAtomValue( + threadRef === null + ? NO_OPEN_FAMILY + : threadFamilies.mountedReadAtom(threadRef.environmentId, threadRef.threadId), + ); + return missing && read === "pending"; } /** @@ -72,26 +77,6 @@ export function ThreadRouteView({ draftId === null ? null : store.getPromotedDraftRedirect(draftId), ); const serverThreadRef = target.kind === "server" ? target.threadRef : null; - const familyEnvironmentId = serverThreadRef?.environmentId; - const familyThreadId = serverThreadRef?.threadId; - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- keeps the viewed thread's omitted family rows mounted until navigation - useEffect(() => { - if (familyEnvironmentId === undefined || familyThreadId === undefined) return; - return threadFamilies.mount(appAtomRegistry, familyEnvironmentId, familyThreadId); - }, [familyEnvironmentId, familyThreadId]); - const familyRead = threadFamilyRead( - useAtomValue( - serverThreadRef === null - ? EMPTY_FAMILY_ATOM - : threadFamilies.familyAtom(serverThreadRef.environmentId, serverThreadRef.threadId), - ), - ); - const familyMounted = useAtomValue( - serverThreadRef === null - ? EMPTY_MOUNTED_ATOM - : threadFamilies.mountedAtom(serverThreadRef.environmentId, serverThreadRef.threadId), - ); - const familySettled = familyMounted && familyRead === "settled"; const serverThreadShell = useQuietThreadShell(serverThreadRef); const shellStatus = useEnvironmentShellStatus(serverThreadRef?.environmentId ?? null); const environmentThreadRefs = useEnvironmentThreadRefs(serverThreadRef?.environmentId ?? null); @@ -133,7 +118,10 @@ export function ThreadRouteView({ unlistedThreadRef?.environmentId ?? null, unlistedThreadRef?.threadId ?? null, ); - useOpenThreadFamily(serverThreadRef, bootstrapComplete && unlistedThreadRef !== null); + const familyPending = useOpenThreadFamily( + serverThreadRef, + bootstrapComplete && unlistedThreadRef !== null, + ); const hiddenKeys = useAtomValue(environmentThreadShells.hiddenThreadKeysAtom); const unlistedThread = Option.getOrNull(Option.map(unlistedThreadDetail.data, (p) => p.thread)); const unlistedParentKey = @@ -181,30 +169,6 @@ export function ThreadRouteView({ let view: React.ReactNode = null; let redirect: React.ReactNode = null; - if ( - serverThreadRef !== null && - (hiddenByTagFilter || offShellBlocked) && - familyMounted && - familyRead === "failed" - ) { - view = ( -
-

Could not load the thread family.

- -
- ); - } if (target.kind === "draft") { if ( draftSession && @@ -258,10 +222,11 @@ export function ThreadRouteView({ redirect = ; } } else if (hiddenByTagFilter) { - if (familySettled) redirect = ; + redirect = ; } else if (offShellBlocked) { - // Before the shell bootstraps, a listed visible thread can still look off-shell. - if (bootstrapComplete && familySettled && unlistedThread !== null) { + // Before the shell bootstraps, a listed visible thread can still look off-shell. So can an + // idle subagent until its family read brings in the parent the shell left out. + if (bootstrapComplete && unlistedThread !== null && !familyPending) { redirect = ; } } else if (renderState === "ready" || (renderState === "loading" && serverThreadShell !== null)) { diff --git a/packages/client-runtime/src/state/threadFamilies.test.ts b/packages/client-runtime/src/state/threadFamilies.test.ts index 65d1e309b3b23..d7069b67c0dca 100644 --- a/packages/client-runtime/src/state/threadFamilies.test.ts +++ b/packages/client-runtime/src/state/threadFamilies.test.ts @@ -8,14 +8,12 @@ import { type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; -import * as TestClock from "effect/testing/TestClock"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import { @@ -205,10 +203,7 @@ describe("createThreadFamilyAtoms", () => { Effect.tap(() => Effect.yieldNow), ); registry.mount(families.snapshotAtom(TARGET.environmentId)); - const mounted = families.mountedAtom(TARGET.environmentId, parent.id); - expect(registry.get(mounted)).toBe(false); const release = families.mount(registry, TARGET.environmentId, parent.id); - expect(registry.get(mounted)).toBe(true); // A cached shell from before this session's first snapshot reads nothing. expect(threadFamilyRead(registry.get(family))).toBe("pending"); @@ -220,21 +215,21 @@ describe("createThreadFamilyAtoms", () => { expect(threadFamilyRead(registry.get(family))).toBe("settled"); expect(mergedIds()).toEqual(["parent", "idle-child"]); - // A new authoritative snapshot never reuses the old read while the next one retries. + // A new authoritative snapshot never reuses the old read, even while the next one waits + // or fails. registry.set(shellState, AsyncResult.success(shellAt(2))); expect(threadFamilyRead(registry.get(family))).toBe("pending"); expect(mergedIds()).toEqual(["parent"]); - const failedRead = yield* Deferred.make(); yield* Queue.offer( replies, - Effect.fail(new OrchestrationV2GetShellSnapshotError({ message: "read failed" })).pipe( - Effect.ensuring(Deferred.succeed(failedRead, undefined)), - ), + Effect.fail(new OrchestrationV2GetShellSnapshotError({ message: "read failed" })), ); - yield* Deferred.await(failedRead); - yield* TestClock.adjust("250 millis"); - expect(threadFamilyRead(registry.get(family))).toBe("pending"); + yield* settle; + expect(threadFamilyRead(registry.get(family))).toBe("failed"); expect(mergedIds()).toEqual(["parent"]); + + families.refresh(registry, TARGET.environmentId, parent.id); + expect(threadFamilyRead(registry.get(family))).toBe("pending"); yield* Queue.offer(replies, Effect.succeed({ snapshotSequence: 5, threads: [child] })); yield* settle; expect(threadFamilyRead(registry.get(family))).toBe("settled"); @@ -242,7 +237,6 @@ describe("createThreadFamilyAtoms", () => { // A child that woke after the shell left out its idle parent reads its family unmounted. release(); - expect(registry.get(mounted)).toBe(false); const woken = row("woken", "woken", child.id, parent.id); registry.set(shellState, AsyncResult.success(shellAt(3, [parent, woken]))); yield* Queue.offer( @@ -251,6 +245,13 @@ describe("createThreadFamilyAtoms", () => { ); yield* settle; expect(mergedIds()).toEqual(["parent", "woken", "idle-child"]); + + // That read is settled, but a caller waiting on its own mount sees it only once mounted. + const mountedRead = families.mountedReadAtom(TARGET.environmentId, parent.id); + expect(threadFamilyRead(registry.get(family))).toBe("settled"); + expect(registry.get(mountedRead)).toBe("pending"); + families.mount(registry, TARGET.environmentId, parent.id); + expect(registry.get(mountedRead)).toBe("settled"); }), ), ); diff --git a/packages/client-runtime/src/state/threadFamilies.ts b/packages/client-runtime/src/state/threadFamilies.ts index 834dc9c7293ac..8bc107f9b9c95 100644 --- a/packages/client-runtime/src/state/threadFamilies.ts +++ b/packages/client-runtime/src/state/threadFamilies.ts @@ -8,7 +8,6 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import * as Schedule from "effect/Schedule"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; @@ -128,7 +127,7 @@ export function createThreadFamilyAtoms( if (config.shellThreadFamilies !== true) return EMPTY_FAMILY; return yield* request(ORCHESTRATION_V2_WS_METHODS.getThreadFamily, { threadId: input.threadId, - }).pipe(Effect.retry({ times: 2, schedule: Schedule.spaced("250 millis") })); + }); }), }); const queryFor = (environmentId: EnvironmentId, threadId: ThreadId, generation: number) => @@ -162,9 +161,13 @@ export function createThreadFamilyAtoms( Atom.withLabel(`environment-thread-family-mounts:${environmentId}`), ), ); - const mountedAtom = Atom.family((key: string) => { + const mountedReadAtom = Atom.family((key: string) => { const [environmentId] = JSON.parse(key) as [EnvironmentId, ThreadId]; - return Atom.make((get) => get(mountsAtom(environmentId)).has(`family:${key}`)); + return Atom.make((get): ThreadFamilyRead => + get(mountsAtom(environmentId)).has(`family:${key}`) + ? threadFamilyRead(get(familyAtom(key))) + : "pending", + ); }); const familyRowsAtom = Atom.family((key: string) => @@ -286,8 +289,12 @@ export function createThreadFamilyAtoms( /** The current generation's family read, for decisions that need a settled answer. */ familyAtom: (environmentId: EnvironmentId, threadId: ThreadId) => familyAtom(familyKey(environmentId, threadId)), - mountedAtom: (environmentId: EnvironmentId, threadId: ThreadId) => - mountedAtom(familyKey(environmentId, threadId)), + /** + * `familyAtom`'s read once the family is mounted, and pending until then: a read cached from + * an earlier mount settles before `mount` merges its rows. Does not start a read. + */ + mountedReadAtom: (environmentId: EnvironmentId, threadId: ThreadId) => + mountedReadAtom(familyKey(environmentId, threadId)), mount, mountRows, refresh, From ad29ee559953f160f81c1996bf364ef638aa9fcb Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 12 Nov 2026 02:16:57 -0800 Subject: [PATCH 06/14] test(server): transfer budget covers nested idle subagent chains The budget's client queries now send omitIdleSubagentParents, and the fleet fixture gives 20 idle subagents a child of their own. Without the opt-in the cold-connect shell carries 47 rows and 73 KB, over the 27 row and 55 KB caps. The index-path snapshot test also keeps an idle parent above a live subagent. Co-Authored-By: Claude Fable 5.2 --- .../TransferBudgetReport.integration.ts | 3 +- .../TransferBudgetV2Fixture.integration.ts | 28 +++++++++++-------- .../transferBudgetV2.integration.test.ts | 3 +- apps/server/src/ws.test.ts | 9 ++++-- docs/internals/connection-runtime.md | 14 +++++----- 5 files changed, 33 insertions(+), 24 deletions(-) diff --git a/apps/server/integration/TransferBudgetReport.integration.ts b/apps/server/integration/TransferBudgetReport.integration.ts index 0ec5b90af2ab3..7c1d8fac1c558 100644 --- a/apps/server/integration/TransferBudgetReport.integration.ts +++ b/apps/server/integration/TransferBudgetReport.integration.ts @@ -97,7 +97,8 @@ const LONG_TURN_BUDGET = { rows: 75, }; -// About 30% above the observed 42 KB. Without the shell opt-ins it carries all 142 rows (217 KB). +// About 30% above the observed 42 KB. Without the shell opt-ins it carries all 162 rows (250 KB), +// and without `omitIdleSubagentParents` the 20 idle subagents that have a child (47 rows, 73 KB). const FLEET_SHELL_BUDGET = { decodedBytes: 55_000, rows: 27, diff --git a/apps/server/integration/TransferBudgetV2Fixture.integration.ts b/apps/server/integration/TransferBudgetV2Fixture.integration.ts index 85ac051673c1d..81ffd9d55322a 100644 --- a/apps/server/integration/TransferBudgetV2Fixture.integration.ts +++ b/apps/server/integration/TransferBudgetV2Fixture.integration.ts @@ -791,11 +791,13 @@ export function staleTodoListThreadBatches( } export const SHELL_SETTLED_THREADS = 100; -export const SHELL_IDLE_SUBAGENTS = 40; +export const SHELL_IDLE_SUBAGENTS = 60; +const SHELL_NESTED_IDLE_SUBAGENTS = 20; /** * A busy sidebar: settled top-level threads, settled a minute apart, with idle subagent children - * under the newest ones. Opted-in cold connects skip the children and all but 25 settled threads. + * under the newest ones, the newest of which have an idle child of their own. Opted-in cold + * connects skip the children, the nested chains and all but 25 settled threads. */ export function shellFleetEvents( provider: ProviderDriverKind, @@ -810,19 +812,21 @@ export function shellFleetEvents( settledAt, updatedAt: settledAt, }); - if (index < SHELL_SETTLED_THREADS - SHELL_IDLE_SUBAGENTS) return [settled]; + const fromNewest = SHELL_SETTLED_THREADS - index; + if (fromNewest > SHELL_IDLE_SUBAGENTS - SHELL_NESTED_IDLE_SUBAGENTS) return [settled]; + const subagent = (id: string, title: string, parentThreadId: ThreadId) => + threadCreated(provider, ThreadId.make(id), { + title, + createdBy: "agent", + lineage: { parentThreadId, relationshipToParent: "subagent", rootThreadId: threadId }, + }); const childId = ThreadId.make(`transfer-budget-settled-${index}-subagent`); + const child = subagent(childId, `Subagent of settled thread ${index}`, threadId); + if (fromNewest > SHELL_NESTED_IDLE_SUBAGENTS) return [settled, child]; return [ settled, - threadCreated(provider, childId, { - title: `Subagent of settled thread ${index}`, - createdBy: "agent", - lineage: { - parentThreadId: threadId, - relationshipToParent: "subagent", - rootThreadId: threadId, - }, - }), + child, + subagent(`${childId}-nested`, `Nested subagent of settled thread ${index}`, childId), ]; }).flat(); } diff --git a/apps/server/integration/transferBudgetV2.integration.test.ts b/apps/server/integration/transferBudgetV2.integration.test.ts index 8c5fe2447cedb..4f32a4c2dcda4 100644 --- a/apps/server/integration/transferBudgetV2.integration.test.ts +++ b/apps/server/integration/transferBudgetV2.integration.test.ts @@ -87,7 +87,7 @@ import { // boundedThreadSnapshotHttp.ts, threadHistoryHttp.ts, shellSnapshotHttp.ts, threads.ts and shell.ts. const CLIENT_BOUNDED_QUERY = "compactTurnItems=1&dedupeBodies=1&compactIds=1"; const CLIENT_HISTORY_QUERY = "compactIds=1"; -const CLIENT_SHELL_QUERY = "omitIdleSubagents=1&omitOlderSettled=1"; +const CLIENT_SHELL_QUERY = "omitIdleSubagents=1&omitIdleSubagentParents=1&omitOlderSettled=1"; const decodeThreadSnapshot = Schema.decodeUnknownEffect( Schema.fromJsonString(Schema.toCodecJson(OrchestrationV2ThreadDetailSnapshot)), @@ -245,6 +245,7 @@ const subscribe = Effect.fn("transfer.subscribe")(function* ( afterSequence, requestCompletionMarker: true, omitIdleSubagents: true, + omitIdleSubagentParents: true, omitOlderSettled: true, }); yield* stream.pipe( diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index fe4ec1631b384..11148161720e6 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -710,6 +710,9 @@ it.effect("shell snapshots omit idle children and older settled threads only on shell("old", null, null, 30), shell("middle", "parent", null), shell("leaf", "middle", null), + // Idle, and kept either way: a live subagent is below it. + shell("upper", "parent", null), + shell("deep", "upper", "running"), ]; const firstSnapshot = (input: { readonly omitIdleSubagents?: boolean; @@ -770,7 +773,7 @@ it.effect("shell snapshots omit idle children and older settled threads only on const withoutChildren = yield* firstSnapshot({ omitIdleSubagents: true }); assert.deepEqual( withoutChildren?.threads.map((thread) => thread.id), - ["parent", "live", ...recent, "old", "middle"], + ["parent", "live", ...recent, "old", "middle", "upper", "deep"], ); const withoutIdleParents = yield* firstSnapshot({ omitIdleSubagents: true, @@ -778,12 +781,12 @@ it.effect("shell snapshots omit idle children and older settled threads only on }); assert.deepEqual( withoutIdleParents?.threads.map((thread) => thread.id), - ["parent", "live", ...recent, "old"], + ["parent", "live", ...recent, "old", "upper", "deep"], ); const withoutOld = yield* firstSnapshot({ omitIdleSubagents: true, omitOlderSettled: true }); assert.deepEqual( withoutOld?.threads.map((thread) => thread.id), - ["parent", "live", ...recent, "middle"], + ["parent", "live", ...recent, "middle", "upper", "deep"], ); assert.deepEqual(withoutOld?.olderSettled?.counts, [ { projectId: ProjectId.make("project"), count: 1 }, diff --git a/docs/internals/connection-runtime.md b/docs/internals/connection-runtime.md index 6097af6950aae..b396088370034 100644 --- a/docs/internals/connection-runtime.md +++ b/docs/internals/connection-runtime.md @@ -79,13 +79,13 @@ Shell snapshots can leave rows out when the client asks. Servers that advertise `shellThreadFamilies` leave out idle subagent children: a child stays while it has a live or queued run, a pending request or background work, or a subagent child of its own, so badge and working walks never meet a gap. Clients that also -send `omitIdleSubagentParents` keep an idle child only while a live subagent is -below it. A child that wakes later reaches them through the live tail without the -idle rows above it, so they read its family whenever a parent row is missing. Servers that also -advertise `shellOlderSettled` keep only the 25 newest settled top-level threads -(and any tied with the 25th, pinned, snoozed, live, or above a kept child); the -snapshot's `olderSettled` names that cut and counts the rest per project. Left-out -rows load on demand and merge behind shell rows +send `omitIdleSubagentParents` get an idle child only while a live subagent is +below it. A chain that wakes later reaches them as a delta without the idle rows +above it, so they read the family of any shell row whose parent is missing. +Servers that also advertise `shellOlderSettled` keep only the 25 newest settled +top-level threads (and any tied with the 25th, pinned, snoozed, live, or above a +kept child); the snapshot's `olderSettled` names that cut and counts the rest per +project. Left-out rows load on demand and merge behind shell rows ([thread families](../../packages/client-runtime/src/state/threadFamilies.ts)): the family a thread view or a queued message mounts holds every active thread sharing the thread's root, so it also brings back an old settled thread opened by From 2b59d9ff06b3fd6e1f05093554b0e61b8d380438 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 12 Nov 2026 02:29:53 -0800 Subject: [PATCH 07/14] fix(client): an opened idle subagent reads its family once Opening a thread sends a visit, so its row comes back as a delta. With its idle parent left out, the row then read the family a second time under the root's id, next to the read the open thread had mounted. On one real database the largest nested family is 432 rows and 811 KB. The read for a missing parent now uses the child's id, one child per root, so it is the read the open thread already made. Co-Authored-By: Claude Fable 5.2 --- .../src/state/threadFamilies.test.ts | 22 ++++++++++------- .../src/state/threadFamilies.ts | 24 +++++++++++-------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/packages/client-runtime/src/state/threadFamilies.test.ts b/packages/client-runtime/src/state/threadFamilies.test.ts index d7069b67c0dca..29e0a0070b5db 100644 --- a/packages/client-runtime/src/state/threadFamilies.test.ts +++ b/packages/client-runtime/src/state/threadFamilies.test.ts @@ -235,22 +235,26 @@ describe("createThreadFamilyAtoms", () => { expect(threadFamilyRead(registry.get(family))).toBe("settled"); expect(mergedIds()).toEqual(["parent", "idle-child"]); - // A child that woke after the shell left out its idle parent reads its family unmounted. + // Children that woke after the shell left out their idle parent arrive as deltas, and + // read their family unmounted, once for the root. release(); const woken = row("woken", "woken", child.id, parent.id); - registry.set(shellState, AsyncResult.success(shellAt(3, [parent, woken]))); + const sibling = row("woken-sibling", "woken-sibling", child.id, parent.id); + registry.set(shellState, AsyncResult.success(shellAt(2, [parent, woken, sibling]))); yield* Queue.offer( replies, - Effect.succeed({ snapshotSequence: 6, threads: [parent, child, woken] }), + Effect.succeed({ snapshotSequence: 6, threads: [parent, child, woken, sibling] }), ); - yield* settle; - expect(mergedIds()).toEqual(["parent", "woken", "idle-child"]); + const wokenFamily = families.familyAtom(TARGET.environmentId, woken.id); + yield* AtomRegistry.getResult(registry, wokenFamily, { suspendOnWaiting: true }).pipe( + Effect.tap(() => Effect.yieldNow), + ); + expect(mergedIds()).toEqual(["parent", "woken", "woken-sibling", "idle-child"]); - // That read is settled, but a caller waiting on its own mount sees it only once mounted. - const mountedRead = families.mountedReadAtom(TARGET.environmentId, parent.id); - expect(threadFamilyRead(registry.get(family))).toBe("settled"); + // Opening the woken child mounts that same read. It answers only once it is mounted. + const mountedRead = families.mountedReadAtom(TARGET.environmentId, woken.id); expect(registry.get(mountedRead)).toBe("pending"); - families.mount(registry, TARGET.environmentId, parent.id); + families.mount(registry, TARGET.environmentId, woken.id); expect(registry.get(mountedRead)).toBe("settled"); }), ), diff --git a/packages/client-runtime/src/state/threadFamilies.ts b/packages/client-runtime/src/state/threadFamilies.ts index 8bc107f9b9c95..86a178939f32f 100644 --- a/packages/client-runtime/src/state/threadFamilies.ts +++ b/packages/client-runtime/src/state/threadFamilies.ts @@ -66,21 +66,25 @@ export function threadFamilyRowsMissingFromShell(input: { } /** - * Roots of subagents and side chats whose parent row the shell left out. A shell sent with - * `omitIdleSubagentParents` drops idle chains, so a child that wakes later arrives without them. + * Subagents and side chats whose parent row the shell left out, one per root. A shell sent with + * `omitIdleSubagentParents` drops idle chains, so a child that wakes or is opened later arrives + * without them. The family is read by the child, the read an open thread's mount already made. */ -function rootsWithMissingParents( +function threadsWithMissingParents( threads: ReadonlyArray, ): ReadonlyArray { const present = new Set(threads.map((thread) => thread.id)); - const roots = new Set(); - for (const { lineage } of threads) { + const byRoot = new Map(); + for (const { id, lineage } of threads) { if (lineage.parentThreadId === null || present.has(lineage.parentThreadId)) continue; - if (lineage.relationshipToParent === "subagent" || lineage.sideChat === true) { - roots.add(lineage.rootThreadId); + if ( + (lineage.relationshipToParent === "subagent" || lineage.sideChat === true) && + !byRoot.has(lineage.rootThreadId) + ) { + byRoot.set(lineage.rootThreadId, id); } } - return [...roots]; + return [...byRoot.values()]; } /** @@ -241,8 +245,8 @@ export function createThreadFamilyAtoms( if (orphans?.threads !== snapshot.threads) { orphans = { threads: snapshot.threads, - rows: rootsWithMissingParents(snapshot.threads).map((root) => - familyRowsAtom(familyKey(environmentId, root)), + rows: threadsWithMissingParents(snapshot.threads).map((threadId) => + familyRowsAtom(familyKey(environmentId, threadId)), ), }; } From 79b49ffc3757b389637ad9c2b70f79b0a520c6fc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 11:27:32 +0000 Subject: [PATCH 08/14] fix(server): idle subagents above a side chat or fork stay in trimmed shells With omitIdleSubagentParents, an idle subagent stayed only above a live subagent, so a side chat or fork under an idle nested subagent arrived without its parent. The web route then read it as tag-hidden and redirected home before the family read brought the parent back. Such parents now seed the kept chain like live subagents do. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/ShellStream.test.ts | 17 +++++++++++++-- .../src/orchestration-v2/ShellStream.ts | 21 +++++++++++++------ docs/internals/connection-runtime.md | 4 ++-- packages/contracts/src/orchestrationV2.ts | 4 ++-- 4 files changed, 34 insertions(+), 12 deletions(-) diff --git a/apps/server/src/orchestration-v2/ShellStream.test.ts b/apps/server/src/orchestration-v2/ShellStream.test.ts index 38bbdd44afffa..6203d0c6e795a 100644 --- a/apps/server/src/orchestration-v2/ShellStream.test.ts +++ b/apps/server/src/orchestration-v2/ShellStream.test.ts @@ -184,6 +184,17 @@ describe("omitIdleSubagentShells", () => { row("upper", "root"), row("lower", "upper"), row("deep", "lower", { activityRunStatus: "running" }), + // Idle, but a side chat under it needs it as its parent. + row("chat-parent", "root"), + row("chat-sibling", "chat-parent"), + row("chat", "chat-parent", { + lineage: { + parentThreadId: ThreadId.make("chat-parent"), + relationshipToParent: null, + rootThreadId: ThreadId.make("root"), + sideChat: true, + }, + }), ]; const live = ["root", "running", "queued", "asking", "background"]; expect(omitIdleSubagentShells(threads).map((thread) => thread.id)).toEqual([ @@ -192,11 +203,13 @@ describe("omitIdleSubagentShells", () => { "upper", "lower", "deep", + "chat-parent", + "chat", ]); - // Clients that walk past a missing parent keep only the chains above live children. + // Clients that walk past a missing parent keep only the chains above live or other children. expect( omitIdleSubagentShells(threads, { omitIdleSubagentParents: true }).map((thread) => thread.id), - ).toEqual([...live, "upper", "lower", "deep"]); + ).toEqual([...live, "upper", "lower", "deep", "chat-parent", "chat"]); }); it("still filters the transcript events of omitted children for a subscriber that opts in", () => { diff --git a/apps/server/src/orchestration-v2/ShellStream.ts b/apps/server/src/orchestration-v2/ShellStream.ts index b01605125a31e..d440743f9b189 100644 --- a/apps/server/src/orchestration-v2/ShellStream.ts +++ b/apps/server/src/orchestration-v2/ShellStream.ts @@ -138,7 +138,10 @@ function hiddenChildAncestorIds( return ancestorIds; } -/** Live subagents and the subagents above them, so each live one keeps its chain to the root. */ +/** + * Live subagents, the parents of other children (side chats, forks), and the subagents above + * them, so each of those keeps its chain to the root. + */ function liveSubagentChainIds>( rows: ReadonlyArray, isLive: (row: T) => boolean, @@ -148,9 +151,15 @@ function liveSubagentChainIds(); - for (const row of subagents.values()) { - if (!isLive(row)) continue; - let current: T | undefined = row; + for (const row of rows) { + const isSubagent = row.lineage.relationshipToParent === "subagent"; + if (isSubagent && !isLive(row)) continue; + const rowParentId = row.lineage.parentThreadId; + let current: T | undefined = isSubagent + ? row + : rowParentId === null + ? undefined + : subagents.get(rowParentId); while (current !== undefined && !kept.has(current.id)) { kept.add(current.id); const parentId: string | null = current.lineage.parentThreadId; @@ -212,8 +221,8 @@ function omitOlderSettledShellsWith( /** * Idle subagent children have no sidebar row; opted-in clients read them from their thread family. * A child stays while it is live or the parent of another child, so shell walks never meet a gap. - * With `omitIdleSubagentParents` it stays only while live or above a live one, for clients that - * read the family of a child whose parent row is missing. + * With `omitIdleSubagentParents` it stays only while live or above a live subagent or a side chat + * or fork, for clients that read the family of a child whose parent row is missing. */ export function omitIdleSubagentShells( threads: ReadonlyArray, diff --git a/docs/internals/connection-runtime.md b/docs/internals/connection-runtime.md index b396088370034..e66dfcdc8cda6 100644 --- a/docs/internals/connection-runtime.md +++ b/docs/internals/connection-runtime.md @@ -79,8 +79,8 @@ Shell snapshots can leave rows out when the client asks. Servers that advertise `shellThreadFamilies` leave out idle subagent children: a child stays while it has a live or queued run, a pending request or background work, or a subagent child of its own, so badge and working walks never meet a gap. Clients that also -send `omitIdleSubagentParents` get an idle child only while a live subagent is -below it. A chain that wakes later reaches them as a delta without the idle rows +send `omitIdleSubagentParents` get an idle child only while a live subagent, a +side chat or a fork is below it. A chain that wakes later reaches them as a delta without the idle rows above it, so they read the family of any shell row whose parent is missing. Servers that also advertise `shellOlderSettled` keep only the 25 newest settled top-level threads (and any tied with the 25th, pinned, snoozed, live, or above a diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index aa0553884f5f0..0fc7eb3b35ff5 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3799,8 +3799,8 @@ export const OrchestrationV2SubscribeShellInput = Schema.Struct({ */ omitIdleSubagents: Schema.optionalKey(Schema.Boolean), /** - * With `omitIdleSubagents`, an idle subagent stays only while a live one is under it, not as the - * parent of any child. Send only from clients that read the family of a child whose parent row + * With `omitIdleSubagents`, an idle subagent stays only while a live subagent, a side chat or a + * fork is under it, not as the parent of an idle subagent. Send only from clients that read the family of a child whose parent row * is missing; older servers ignore it. */ omitIdleSubagentParents: Schema.optionalKey(Schema.Boolean), From 319364b3575a814c960403f34ba8c0dd0a29680c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 11:42:47 +0000 Subject: [PATCH 09/14] fix(client): woken children whose idle parent the shell left out recover it A failed automatic family read for a shell row whose parent is missing stayed failed until reconnect, so the parent never arrived and the root's approval badge stayed off. The next shell change now retries it, as a remount already does. On web, a listed child whose parent row has not arrived read as tag-hidden and redirected home before its family read finished. The route now mounts that child's family and holds the tag-filter redirect until the read settles. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ThreadRouteView.tsx | 21 +++++++++++++----- .../src/state/threadFamilies.test.ts | 22 +++++++++++++++---- .../src/state/threadFamilies.ts | 13 ++++++++--- 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index 3f17d2c8ce1ba..2a810f8abb0df 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { parseThreadKey, threadKey } from "@t3tools/client-runtime/state/entities"; import { type ThreadFamilyRead, visibilityParentKey } from "@t3tools/client-runtime/state/threads"; -import type { ScopedThreadRef } from "@t3tools/contracts"; +import { isListedThreadLineage, type ScopedThreadRef } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { Atom } from "effect/reactivity"; import { Navigate, useNavigate } from "@tanstack/react-router"; @@ -32,8 +32,8 @@ const NO_OPEN_FAMILY = Atom.make("settled"); /** * Keeps the family of an open thread the shell leaves out (an older settled thread, an idle - * subagent) in the shell rows until the route leaves it, so every reader of its shell row finds - * it. Returns whether those rows can still arrive. + * subagent), or whose parent it leaves out, in the shell rows until the route leaves it, so every + * reader of its shell row finds it. Returns whether those rows can still arrive. */ function useOpenThreadFamily(threadRef: ScopedThreadRef | null, missing: boolean): boolean { const key = threadRef === null ? null : scopedThreadKey(threadRef); @@ -118,9 +118,19 @@ export function ThreadRouteView({ unlistedThreadRef?.environmentId ?? null, unlistedThreadRef?.threadId ?? null, ); + // A woken child can arrive before the idle parent the shell left out; its family brings it. + const listedParentRef = + serverThreadRef === null || + serverThreadShell === null || + serverThreadShell.lineage.parentThreadId === null || + isListedThreadLineage(serverThreadShell.lineage) + ? null + : { ...serverThreadRef, threadId: serverThreadShell.lineage.parentThreadId }; + const listedParentShell = useQuietThreadShell(listedParentRef); + const parentMissing = listedParentRef !== null && listedParentShell === null; const familyPending = useOpenThreadFamily( serverThreadRef, - bootstrapComplete && unlistedThreadRef !== null, + bootstrapComplete && (unlistedThreadRef !== null || parentMissing), ); const hiddenKeys = useAtomValue(environmentThreadShells.hiddenThreadKeysAtom); const unlistedThread = Option.getOrNull(Option.map(unlistedThreadDetail.data, (p) => p.thread)); @@ -222,7 +232,8 @@ export function ThreadRouteView({ redirect = ; } } else if (hiddenByTagFilter) { - redirect = ; + // A thread whose parent row has not arrived yet reads as hidden until its family read settles. + if (!familyPending) redirect = ; } else if (offShellBlocked) { // Before the shell bootstraps, a listed visible thread can still look off-shell. So can an // idle subagent until its family read brings in the parent the shell left out. diff --git a/packages/client-runtime/src/state/threadFamilies.test.ts b/packages/client-runtime/src/state/threadFamilies.test.ts index 29e0a0070b5db..827f301dc7e3f 100644 --- a/packages/client-runtime/src/state/threadFamilies.test.ts +++ b/packages/client-runtime/src/state/threadFamilies.test.ts @@ -241,14 +241,28 @@ describe("createThreadFamilyAtoms", () => { const woken = row("woken", "woken", child.id, parent.id); const sibling = row("woken-sibling", "woken-sibling", child.id, parent.id); registry.set(shellState, AsyncResult.success(shellAt(2, [parent, woken, sibling]))); + const wokenFamily = families.familyAtom(TARGET.environmentId, woken.id); + const settleWoken = AtomRegistry.getResult(registry, wokenFamily, { + suspendOnWaiting: true, + }).pipe( + Effect.ignore, + Effect.tap(() => Effect.yieldNow), + ); yield* Queue.offer( replies, - Effect.succeed({ snapshotSequence: 6, threads: [parent, child, woken, sibling] }), + Effect.fail(new OrchestrationV2GetShellSnapshotError({ message: "read failed" })), ); - const wokenFamily = families.familyAtom(TARGET.environmentId, woken.id); - yield* AtomRegistry.getResult(registry, wokenFamily, { suspendOnWaiting: true }).pipe( - Effect.tap(() => Effect.yieldNow), + yield* settleWoken; + expect(threadFamilyRead(registry.get(wokenFamily))).toBe("failed"); + expect(mergedIds()).toEqual(["parent", "woken", "woken-sibling"]); + + // The next delta retries the failed read instead of keeping the gap until reconnect. + registry.set(shellState, AsyncResult.success(shellAt(2, [parent, { ...woken }, sibling]))); + yield* Queue.offer( + replies, + Effect.succeed({ snapshotSequence: 6, threads: [parent, child, woken, sibling] }), ); + yield* settleWoken; expect(mergedIds()).toEqual(["parent", "woken", "woken-sibling", "idle-child"]); // Opening the woken child mounts that same read. It answers only once it is mounted. diff --git a/packages/client-runtime/src/state/threadFamilies.ts b/packages/client-runtime/src/state/threadFamilies.ts index 86a178939f32f..7c66ef45aef58 100644 --- a/packages/client-runtime/src/state/threadFamilies.ts +++ b/packages/client-runtime/src/state/threadFamilies.ts @@ -243,11 +243,18 @@ export function createThreadFamilyAtoms( const mounts = get(mountsAtom(environmentId)); if (snapshot === null || state === null) return snapshot; if (orphans?.threads !== snapshot.threads) { + const orphanIds = threadsWithMissingParents(snapshot.threads); + // Like a remount, a shell change retries a failed read instead of keeping the gap. + for (const threadId of orphanIds) { + if ( + threadFamilyRead(get.once(familyAtom(familyKey(environmentId, threadId)))) === "failed" + ) { + get.refresh(queryFor(environmentId, threadId, state.generation)); + } + } orphans = { threads: snapshot.threads, - rows: threadsWithMissingParents(snapshot.threads).map((threadId) => - familyRowsAtom(familyKey(environmentId, threadId)), - ), + rows: orphanIds.map((threadId) => familyRowsAtom(familyKey(environmentId, threadId))), }; } if (mounts.size === 0 && orphans.rows.length === 0) return snapshot; From b59f2c45c7dca53f9da4a42d8f9061d2f97036cc Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 12 Nov 2026 03:57:44 -0800 Subject: [PATCH 10/14] test(server): an idle subagent with a remote task keeps its chain with omitIdleSubagentParents Covers #30320 for the new opt-in: the WebSocket shell, the ids the store is asked for, and the HTTP shell over the real index SQL. Each case fails when either idle-subagent filter loses its remote-task term. Co-Authored-By: Claude Fable 5.2 --- apps/server/src/orchestration-v2/http.test.ts | 82 ++++++++++++++++++- apps/server/src/ws.test.ts | 32 +++++--- 2 files changed, 102 insertions(+), 12 deletions(-) diff --git a/apps/server/src/orchestration-v2/http.test.ts b/apps/server/src/orchestration-v2/http.test.ts index a32b4c63507d3..400d35a8454f1 100644 --- a/apps/server/src/orchestration-v2/http.test.ts +++ b/apps/server/src/orchestration-v2/http.test.ts @@ -10,6 +10,7 @@ import { ORCHESTRATION_PROTOCOL_VERSION_TEXT, OrchestrationV2AppThread, OrchestrationV2Run, + OrchestrationV2Subagent, OrchestrationV2TurnItem, OrchestrationV2ThreadTranscript, PROVIDER_SEND_TURN_MAX_FILE_BYTES, @@ -117,6 +118,30 @@ const rolledBackRun = Schema.decodeSync(OrchestrationV2Run)({ contextHandoffId: null, }); +// A finished task that `task-parent` delegated to a linked environment. +const remoteTask = Schema.decodeSync(OrchestrationV2Subagent)({ + id: "node:remote-task", + threadId: "task-parent", + runId: null, + parentNodeId: "node:task-parent-root", + origin: "app_owned", + createdBy: "agent", + driver: "remote", + providerInstanceId: "codex", + providerThreadId: null, + childThreadId: null, + remoteChild: { environmentId: "environment:box", threadId: "thread:on-the-box", label: "Box" }, + nativeTaskRef: null, + prompt: "Run the suite there.", + title: null, + model: null, + status: "completed", + result: "Done", + startedAt: now, + completedAt: now, + updatedAt: now, +}); + // Serves the orchestration HTTP routes over real stores; `allowed` gates the read scope. const serveOrchestrationHttp = (allowed: () => boolean) => Effect.gen(function* () { @@ -340,7 +365,7 @@ it.effect("exports full history with size, scope, and missing-thread checks", () }), ); -it.effect("leaves older settled threads out of the HTTP shell only when asked", () => +it.effect("trims the HTTP shell only when asked", () => Effect.gen(function* () { const { eventSink, handler } = yield* serveOrchestrationHttp(() => true); yield* eventSink.write({ @@ -382,5 +407,60 @@ it.effect("leaves older settled threads out of the HTTP shell only when asked", [thread.id, ...Array.from({ length: 25 }, (_, index) => `settled-${index + 1}`)].toSorted(), ); expect(cut.olderSettled?.counts).toEqual([{ projectId: thread.projectId, count: 1 }]); + + // Two idle chains under the source thread. Only `task-parent` has a task in a linked + // environment, whose clients nest that task under it. + const subagent = (id: string, parent: string) => { + const threadId = ThreadId.make(id); + return { + id: EventId.make(`create-${id}`), + type: "thread.created" as const, + threadId, + occurredAt: now, + payload: { + ...thread, + id: threadId, + lineage: { + rootThreadId: thread.id, + parentThreadId: ThreadId.make(parent), + relationshipToParent: "subagent" as const, + }, + }, + }; + }; + yield* eventSink.write({ + events: [ + subagent("above-task-parent", thread.id), + subagent("task-parent", "above-task-parent"), + subagent("idle-parent", thread.id), + subagent("idle-leaf", "idle-parent"), + { + id: EventId.make("create-remote-task"), + type: "subagent.updated", + threadId: remoteTask.threadId, + occurredAt: now, + payload: remoteTask, + }, + ], + }); + const subagentIds = (query: string) => + shell(query).pipe( + Effect.map((snapshot) => + snapshot.threads + .flatMap((row) => (row.id.startsWith("settled-") ? [] : [row.id])) + .toSorted(), + ), + ); + expect(yield* subagentIds("?omitIdleSubagents=1")).toEqual([ + "above-task-parent", + "idle-parent", + thread.id, + "task-parent", + ]); + expect(yield* subagentIds("?omitIdleSubagents=1&omitIdleSubagentParents=1")).toEqual([ + "above-task-parent", + thread.id, + "task-parent", + ]); }), ); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 2ada625e3e21e..16a7632a885aa 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -715,7 +715,11 @@ it.effect("shell snapshots omit idle children and older settled threads only on shell("deep", "upper", "running"), shell("remote-parent", null, null, 40), shell("remote-child-parent", "parent", null), + // Idle, and kept either way: an idle subagent with a remote task is below it. + shell("above-remote", "parent", null), + shell("remote-nested", "above-remote", null), ]; + let readThreadIds: ReadonlyArray | undefined; const firstSnapshot = (input: { readonly omitIdleSubagents?: boolean; readonly omitIdleSubagentParents?: boolean; @@ -736,15 +740,19 @@ it.effect("shell snapshots omit idle children and older settled threads only on })), ), readShellSnapshot: (options?: { readonly threadIds?: ReadonlyArray }) => - Effect.succeed( - Effect.succeed({ - schemaVersion: 1, - snapshotSequence: 0, - threads: threads.filter( - (thread) => options?.threadIds?.includes(thread.id) ?? true, - ), - archivedThreads: [], - }), + Effect.sync(() => { + readThreadIds = options?.threadIds; + }).pipe( + Effect.as( + Effect.succeed({ + schemaVersion: 1, + snapshotSequence: 0, + threads: threads.filter( + (thread) => options?.threadIds?.includes(thread.id) ?? true, + ), + archivedThreads: [], + }), + ), ), } as unknown as ThreadManagement.ThreadManagementService["Service"]), Effect.provideService(ProjectStore.ProjectStoreV2, { @@ -767,7 +775,7 @@ it.effect("shell snapshots omit idle children and older settled threads only on ); const recent = threads.slice(3, 28).map((thread) => thread.id); - const remote = ["remote-parent", "remote-child-parent"]; + const remote = ["remote-parent", "remote-child-parent", "above-remote", "remote-nested"]; const full = yield* firstSnapshot({}); assert.deepEqual( full?.threads.map((thread) => thread.id), @@ -780,7 +788,7 @@ it.effect("shell snapshots omit idle children and older settled threads only on ["parent", "live", ...recent, "old", "middle", "upper", "deep", ...remote], ); // A linked environment's clients nest its task under its parent, so that parent stays: an - // older settled thread or an idle subagent. + // older settled thread, or an idle subagent with the idle subagents above it. const withoutIdleParents = yield* firstSnapshot({ omitIdleSubagents: true, omitIdleSubagentParents: true, @@ -789,6 +797,8 @@ it.effect("shell snapshots omit idle children and older settled threads only on withoutIdleParents?.threads.map((thread) => thread.id), ["parent", "live", ...recent, "old", "upper", "deep", ...remote], ); + // The store builds only these shells, so the ids it is asked for keep them too. + assert.includeMembers([...(readThreadIds ?? [])], remote); const withoutOld = yield* firstSnapshot({ omitIdleSubagents: true, omitOlderSettled: true }); assert.deepEqual( withoutOld?.threads.map((thread) => thread.id), From 150caf8231a4bbe35ec863d7ff4cf9ad4db9800e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 12 Nov 2026 04:05:48 -0800 Subject: [PATCH 11/14] docs(shell): the idle-subagent trim rules name the remote task parent Co-Authored-By: Claude Fable 5.2 --- apps/server/src/orchestration-v2/ShellStream.ts | 4 ++-- docs/internals/connection-runtime.md | 11 +++++++---- packages/contracts/src/orchestrationV2.ts | 7 ++++--- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration-v2/ShellStream.ts b/apps/server/src/orchestration-v2/ShellStream.ts index c28c405998dcb..f1e2df136dc09 100644 --- a/apps/server/src/orchestration-v2/ShellStream.ts +++ b/apps/server/src/orchestration-v2/ShellStream.ts @@ -144,8 +144,8 @@ function hiddenChildAncestorIds( } /** - * Live subagents, the parents of other children (side chats, forks), and the subagents above - * them, so each of those keeps its chain to the root. + * The subagents `isLive` accepts, the parents of other children (side chats, forks), and the + * subagents above them, so each of those keeps its chain to the root. */ function liveSubagentChainIds>( rows: ReadonlyArray, diff --git a/docs/internals/connection-runtime.md b/docs/internals/connection-runtime.md index e66dfcdc8cda6..f191e193ebaa8 100644 --- a/docs/internals/connection-runtime.md +++ b/docs/internals/connection-runtime.md @@ -78,10 +78,13 @@ on which data is current. Shell snapshots can leave rows out when the client asks. Servers that advertise `shellThreadFamilies` leave out idle subagent children: a child stays while it has a live or queued run, a pending request or background work, or a subagent -child of its own, so badge and working walks never meet a gap. Clients that also -send `omitIdleSubagentParents` get an idle child only while a live subagent, a -side chat or a fork is below it. A chain that wakes later reaches them as a delta without the idle rows -above it, so they read the family of any shell row whose parent is missing. +child of its own, so badge and working walks never meet a gap. It also stays as +the parent of a task in a linked environment, whose clients nest that task under +it. Clients that also send `omitIdleSubagentParents` get an idle child with a +subagent child only while a live subagent, a side chat, a fork or such a task +parent is below it. A chain that wakes later reaches them as a delta without the +idle rows above it, so they read the family of any shell row whose parent is +missing. Servers that also advertise `shellOlderSettled` keep only the 25 newest settled top-level threads (and any tied with the 25th, pinned, snoozed, live, or above a kept child); the snapshot's `olderSettled` names that cut and counts the rest per diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 0fc7eb3b35ff5..f7fb68be8c846 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3799,9 +3799,10 @@ export const OrchestrationV2SubscribeShellInput = Schema.Struct({ */ omitIdleSubagents: Schema.optionalKey(Schema.Boolean), /** - * With `omitIdleSubagents`, an idle subagent stays only while a live subagent, a side chat or a - * fork is under it, not as the parent of an idle subagent. Send only from clients that read the family of a child whose parent row - * is missing; older servers ignore it. + * With `omitIdleSubagents`, an idle subagent no longer stays as the parent of an idle subagent: + * only above a live subagent, a side chat, a fork, or a subagent with a task in a linked + * environment. Send only from clients that read the family of a child whose parent row is + * missing; older servers ignore it. */ omitIdleSubagentParents: Schema.optionalKey(Schema.Boolean), /** From d79c6c3467d5ccbc761e15372b0b66cbbbd56d90 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 12 Nov 2026 07:10:52 -0800 Subject: [PATCH 12/14] fix(mobile): an open thread reads the family rows the shell left out With `omitIdleSubagentParents` the shell leaves an idle subagent chain out. Mobile never mounted the open thread's family, so every reader of a parent's or a subagent's shell row found nothing: the sender label read "Agent", the header lost "Subagent of ...", and a workflow member could not be opened. - The thread screen mounts the open thread's family until it leaves, the same shared `threadFamilies.mount` web uses. - The detail fallback for a thread without a shell row keeps `delegatedFrom` and `linkOrigin`. - The shell cache stores its origin as `originV2`. A client from before this change ignores origin flags it does not know and would resume rows that lack the idle parents it expects. It now finds no origin and loads a fresh snapshot. Co-Authored-By: Claude Fable 5.2 --- .../src/connection/environment-cache-store.ts | 6 +- .../features/threads/ThreadRouteScreen.tsx | 5 +- .../src/state/thread-detail-shell.test.ts | 23 +++++ apps/mobile/src/state/thread-detail-shell.ts | 84 +++++++++++++++++++ apps/mobile/src/state/threads.ts | 18 +++- apps/mobile/src/state/use-thread-selection.ts | 79 +---------------- apps/server/src/orchestration-v2/http.test.ts | 16 ++-- apps/web/src/connection/storage.ts | 6 +- .../src/platform/orchestrationCache.test.ts | 30 +++++++ .../src/platform/orchestrationCache.ts | 7 +- 10 files changed, 178 insertions(+), 96 deletions(-) create mode 100644 apps/mobile/src/state/thread-detail-shell.test.ts create mode 100644 apps/mobile/src/state/thread-detail-shell.ts diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index 3be6c3d302801..5b3191d1c996d 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -111,9 +111,9 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { select: (stored) => stored.environmentId === environmentId ? Option.some( - stored.origin === undefined + stored.originV2 === undefined ? stored.snapshot - : { ...stored.snapshot, origin: stored.origin }, + : { ...stored.snapshot, origin: stored.originV2 }, ) : Option.none(), }).pipe( @@ -130,7 +130,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, environmentId, snapshot, - ...(origin === undefined ? {} : { origin }), + ...(origin === undefined ? {} : { originV2: origin }), }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); yield* database .saveCache( diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 6205fd1281797..9d787c1dc64f1 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -92,7 +92,7 @@ import { useSelectedThreadWorktree } from "../../state/use-selected-thread-workt import { useThreadComposerState } from "../../state/use-thread-composer-state"; import { resolveMergeBackTargetThreadId } from "@t3tools/client-runtime/state/thread-relationships"; import { resolveLatestMergeBackRun } from "@t3tools/client-runtime/state/thread-workflows"; -import { threadEnvironment } from "../../state/threads"; +import { threadEnvironment, useOpenThreadFamily } from "../../state/threads"; import { EMPTY_ENVIRONMENT_THREAD_STATE } from "@t3tools/client-runtime/state/threads"; import { useProjects } from "../../state/entities"; import { projectThreadContentPresentation } from "./threadContentPresentation"; @@ -244,7 +244,8 @@ function LiveThreadRouteController(props: ThreadRouteScreenProps) { useAndroidWorkspaceBack("threads"); const connectionsReady = useConnectionsReady(); const { connectionState } = useRemoteConnectionStatus(); - const { selectedThread } = useThreadSelection(); + const { selectedThread, selectedThreadDetailRef } = useThreadSelection(); + useOpenThreadFamily(selectedThreadDetailRef); const params = props.route.params ?? {}; const environmentIdRaw = firstRouteParam(params.environmentId); const threadIdRaw = firstRouteParam(params.threadId); diff --git a/apps/mobile/src/state/thread-detail-shell.test.ts b/apps/mobile/src/state/thread-detail-shell.test.ts new file mode 100644 index 0000000000000..d9bb2efff028f --- /dev/null +++ b/apps/mobile/src/state/thread-detail-shell.test.ts @@ -0,0 +1,23 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { v2Projection } from "../../../../packages/client-runtime/src/state/orchestrationV2TestFixtures"; +import { threadDetailToShell } from "./thread-detail-shell"; + +describe("threadDetailToShell", () => { + it("keeps where a delegated task came from, which the thread header names", () => { + const delegatedFrom = { + environmentId: EnvironmentId.make("environment-linked"), + threadId: ThreadId.make("thread-parent"), + title: "Parent thread", + }; + const linkOrigin = { sessionId: "session-1", label: "T3 Code", access: "full-access" as const }; + const shell = threadDetailToShell(EnvironmentId.make("environment-1"), { + ...v2Projection, + thread: { ...v2Projection.thread, delegatedFrom, linkOrigin }, + }); + + expect(shell.delegatedFrom).toEqual(delegatedFrom); + expect(shell.linkOrigin).toEqual(linkOrigin); + }); +}); diff --git a/apps/mobile/src/state/thread-detail-shell.ts b/apps/mobile/src/state/thread-detail-shell.ts new file mode 100644 index 0000000000000..99e02b13a4765 --- /dev/null +++ b/apps/mobile/src/state/thread-detail-shell.ts @@ -0,0 +1,84 @@ +import type { + EnvironmentId, + OrchestrationV2ThreadProjection, + OrchestrationV2ThreadShell, +} from "@t3tools/contracts"; +import { + presentThreadShell, + type EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { + deriveLatestThreadRun, + deriveThreadRuntime, +} from "@t3tools/client-runtime/state/thread-execution"; + +function latestUserMessageAt( + projection: OrchestrationV2ThreadProjection, +): OrchestrationV2ThreadShell["latestUserMessageAt"] { + for (let index = projection.messages.length - 1; index >= 0; index -= 1) { + const message = projection.messages[index]; + if (message?.role === "user") { + return message.createdAt; + } + } + + return null; +} + +/** The shell row of a thread the shell left out, from its loaded detail. */ +export function threadDetailToShell( + environmentId: EnvironmentId, + projection: OrchestrationV2ThreadProjection, +): EnvironmentThreadShell { + const thread = projection.thread; + const latestRun = deriveLatestThreadRun(projection); + const runtime = deriveThreadRuntime(projection); + const pendingRequest = + projection.runtimeRequests.find((request) => request.status === "pending") ?? null; + return presentThreadShell(environmentId, { + id: thread.id, + projectId: thread.projectId, + title: thread.title, + providerInstanceId: thread.providerInstanceId, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + branch: thread.branch, + worktreePath: thread.worktreePath, + linkedPullRequest: thread.linkedPullRequest ?? null, + pullRequests: thread.pullRequests, + branchPullRequest: thread.branchPullRequest ?? null, + activeProviderThreadId: thread.activeProviderThreadId, + lineage: thread.lineage, + linkOrigin: thread.linkOrigin, + delegatedFrom: thread.delegatedFrom, + forkedFrom: thread.forkedFrom, + createdBy: thread.createdBy, + creationSource: thread.creationSource, + latestRunId: latestRun?.runId ?? null, + activeRunId: runtime?.activeRunId ?? null, + status: runtime?.status ?? "idle", + pendingRuntimeRequest: + pendingRequest === null + ? null + : { id: pendingRequest.id, kind: pendingRequest.kind, createdAt: pendingRequest.createdAt }, + latestVisibleMessage: null, + latestUserMessageAt: latestUserMessageAt(projection), + hasActionableProposedPlan: false, + itemCount: projection.turnItems.length, + visibleItemCount: projection.visibleTurnItems.length, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + archivedAt: thread.archivedAt, + settledOverride: thread.settledOverride, + settledAt: thread.settledAt, + unsettledAt: thread.unsettledAt, + activeOrderKey: thread.activeOrderKey, + autoSettleDisabledAt: thread.autoSettleDisabledAt, + pinnedAt: thread.pinnedAt, + pinOrderKey: thread.pinOrderKey, + snoozedUntil: thread.snoozedUntil ?? null, + snoozedAt: thread.snoozedAt ?? null, + deletedAt: thread.deletedAt, + }); +} diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index bf2013aef5398..d5ca56d25c590 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -11,8 +11,9 @@ import { createThreadFamilyAtoms, } from "@t3tools/client-runtime/state/threads"; import { createSteadyShellAtoms } from "@t3tools/client-runtime/state/shell"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/reactivity"; +import { useEffect } from "react"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; @@ -111,3 +112,18 @@ export function useEnvironmentThread( ) as EnvironmentThreadState; return state; } + +/** + * Keeps the open thread's family in the shell rows until its screen leaves. Shells leave idle + * subagents out, so the rows of the thread, of its parents and of its subagents may exist only + * there. Pass the ref the detail is read by: a thread still being created has no family to read. + */ +export function useOpenThreadFamily(ref: ScopedThreadRef | null) { + const environmentId = ref?.environmentId ?? null; + const threadId = ref?.threadId ?? null; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- holds the open thread's family in the thread family registry until the screen leaves it + useEffect(() => { + if (environmentId === null || threadId === null) return; + return threadFamilies.mount(appAtomRegistry, environmentId, threadId); + }, [environmentId, threadId]); +} diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 35598d74bc36f..5805d9b65298a 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -4,23 +4,14 @@ import { useState } from "react"; import { EnvironmentId, ThreadId, - type OrchestrationV2ThreadProjection, - type OrchestrationV2ThreadShell, type ScopedProjectRef, type ScopedThreadRef, } from "@t3tools/contracts"; -import { - presentThreadShell, - type EnvironmentThreadShell, -} from "@t3tools/client-runtime/state/shell"; -import { - deriveLatestThreadRun, - deriveThreadRuntime, -} from "@t3tools/client-runtime/state/thread-execution"; import * as Option from "effect/Option"; import { scopedThreadKey } from "../lib/scopedEntities"; import { useProject, useQuietThreadShell } from "./entities"; +import { threadDetailToShell } from "./thread-detail-shell"; import { useEnvironmentThread } from "./threads"; import { resolvePendingThreadCreation, @@ -44,74 +35,6 @@ function firstRouteParam(value: string | string[] | undefined): string | null { return first === undefined || first.trim().length === 0 ? null : first; } -function latestUserMessageAt( - projection: OrchestrationV2ThreadProjection, -): OrchestrationV2ThreadShell["latestUserMessageAt"] { - for (let index = projection.messages.length - 1; index >= 0; index -= 1) { - const message = projection.messages[index]; - if (message?.role === "user") { - return message.createdAt; - } - } - - return null; -} - -function threadDetailToShell( - environmentId: EnvironmentId, - projection: OrchestrationV2ThreadProjection, -): EnvironmentThreadShell { - const thread = projection.thread; - const latestRun = deriveLatestThreadRun(projection); - const runtime = deriveThreadRuntime(projection); - const pendingRequest = - projection.runtimeRequests.find((request) => request.status === "pending") ?? null; - return presentThreadShell(environmentId, { - id: thread.id, - projectId: thread.projectId, - title: thread.title, - providerInstanceId: thread.providerInstanceId, - modelSelection: thread.modelSelection, - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - branch: thread.branch, - worktreePath: thread.worktreePath, - linkedPullRequest: thread.linkedPullRequest ?? null, - pullRequests: thread.pullRequests, - branchPullRequest: thread.branchPullRequest ?? null, - activeProviderThreadId: thread.activeProviderThreadId, - lineage: thread.lineage, - forkedFrom: thread.forkedFrom, - createdBy: thread.createdBy, - creationSource: thread.creationSource, - latestRunId: latestRun?.runId ?? null, - activeRunId: runtime?.activeRunId ?? null, - status: runtime?.status ?? "idle", - pendingRuntimeRequest: - pendingRequest === null - ? null - : { id: pendingRequest.id, kind: pendingRequest.kind, createdAt: pendingRequest.createdAt }, - latestVisibleMessage: null, - latestUserMessageAt: latestUserMessageAt(projection), - hasActionableProposedPlan: false, - itemCount: projection.turnItems.length, - visibleItemCount: projection.visibleTurnItems.length, - createdAt: thread.createdAt, - updatedAt: thread.updatedAt, - archivedAt: thread.archivedAt, - settledOverride: thread.settledOverride, - settledAt: thread.settledAt, - unsettledAt: thread.unsettledAt, - activeOrderKey: thread.activeOrderKey, - autoSettleDisabledAt: thread.autoSettleDisabledAt, - pinnedAt: thread.pinnedAt, - pinOrderKey: thread.pinOrderKey, - snoozedUntil: thread.snoozedUntil ?? null, - snoozedAt: thread.snoozedAt ?? null, - deletedAt: thread.deletedAt, - }); -} - function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefined) { const routeParams = params ?? {}; const routeEnvironmentId = firstRouteParam(routeParams.environmentId); diff --git a/apps/server/src/orchestration-v2/http.test.ts b/apps/server/src/orchestration-v2/http.test.ts index 5e7f6ccaad8bc..949918a057889 100644 --- a/apps/server/src/orchestration-v2/http.test.ts +++ b/apps/server/src/orchestration-v2/http.test.ts @@ -410,6 +410,14 @@ it.effect("trims the HTTP shell only when asked", () => ); expect(cut.olderSettled?.counts).toEqual([{ projectId: thread.projectId, count: 1 }]); + // A client that holds rows the socket can replay from keeps them; any other cursor reloads. + expect(full.projectIdentities).toBeTypeOf("string"); + expect(yield* shell(`?resumeFrom=${full.snapshotSequence}`)).toEqual({ resumable: true }); + expect(yield* shell(`?resumeFrom=${full.snapshotSequence - 1}`)).toEqual({ resumable: true }); + for (const cursor of [full.snapshotSequence + 1, "x"]) { + expect((yield* shell(`?omitOlderSettled=1&resumeFrom=${cursor}`)).threads).toHaveLength(26); + } + // Two idle chains under the source thread. Only `task-parent` has a task in a linked // environment, whose clients nest that task under it. const subagent = (id: string, parent: string) => { @@ -464,13 +472,5 @@ it.effect("trims the HTTP shell only when asked", () => thread.id, "task-parent", ]); - - // A client that holds rows the socket can replay from keeps them; any other cursor reloads. - expect(full.projectIdentities).toBeTypeOf("string"); - expect(yield* shell(`?resumeFrom=${full.snapshotSequence}`)).toEqual({ resumable: true }); - expect(yield* shell(`?resumeFrom=${full.snapshotSequence - 1}`)).toEqual({ resumable: true }); - for (const cursor of [full.snapshotSequence + 1, "x"]) { - expect((yield* shell(`?omitOlderSettled=1&resumeFrom=${cursor}`)).threads).toHaveLength(26); - } }), ); diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index b25abe442ba11..983001986f726 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -852,9 +852,9 @@ export const layer = Layer.effectContext( Effect.map((stored) => stored.environmentId === environmentId ? Option.some( - stored.origin === undefined + stored.originV2 === undefined ? stored.snapshot - : { ...stored.snapshot, origin: stored.origin }, + : { ...stored.snapshot, origin: stored.originV2 }, ) : Option.none(), ), @@ -871,7 +871,7 @@ export const layer = Layer.effectContext( schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, environmentId, snapshot, - ...(origin === undefined ? {} : { origin }), + ...(origin === undefined ? {} : { originV2: origin }), }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); yield* writeDatabaseValue(database, SHELL_STORE_NAME, environmentId, encoded); }, diff --git a/packages/client-runtime/src/platform/orchestrationCache.test.ts b/packages/client-runtime/src/platform/orchestrationCache.test.ts index 8b9b16829a4be..82635ffaa2ac1 100644 --- a/packages/client-runtime/src/platform/orchestrationCache.test.ts +++ b/packages/client-runtime/src/platform/orchestrationCache.test.ts @@ -96,6 +96,36 @@ describe("orchestration cache envelopes", () => { expect(decodeStoredShellSnapshotSync(shell).schemaVersion).toBe(3); }); + it("gives a client from before `originV2` no origin to resume from", () => { + // What that client decodes. It drops keys and origin flags it does not know. + const decodeAsOlderClient = Schema.decodeUnknownSync( + Schema.Struct({ + schemaVersion: Schema.Literal(3), + origin: Schema.optionalKey(Schema.Struct({ omitIdleSubagents: Schema.Boolean })), + }), + ); + const origin = { + serverVersion: "1.0.0", + databaseMigration: 70, + omitIdleSubagents: true, + omitOlderSettled: true, + snapshotAt: 1_800_000_000_000, + }; + const stored = encodeStoredShellSnapshot({ + schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, + environmentId, + snapshot: v2ShellSnapshot, + originV2: origin, + }); + + expect(decodeStoredShellSnapshotSync(stored).originV2).toEqual(origin); + expect(decodeAsOlderClient(stored)).toEqual({ schemaVersion: 3 }); + // A new flag under `origin` would not stop it: it decodes, and the flags it knows match. + expect( + decodeAsOlderClient({ ...stored, origin: { ...origin, omitIdleSubagentParents: true } }), + ).toEqual({ schemaVersion: 3, origin: { omitIdleSubagents: true } }); + }); + it.effect("discards V1-versioned cache envelopes after a decode failure", () => Effect.gen(function* () { let discardCount = 0; diff --git a/packages/client-runtime/src/platform/orchestrationCache.ts b/packages/client-runtime/src/platform/orchestrationCache.ts index 3d24e03f113f9..2b7afdf28aeb0 100644 --- a/packages/client-runtime/src/platform/orchestrationCache.ts +++ b/packages/client-runtime/src/platform/orchestrationCache.ts @@ -31,7 +31,12 @@ export const StoredOrchestrationShellSnapshot = Schema.Struct({ schemaVersion: Schema.Literal(ORCHESTRATION_CACHE_SCHEMA_VERSION), environmentId: EnvironmentId, snapshot: OrchestrationV2ShellSnapshotJson, - origin: Schema.optionalKey(ShellCacheOrigin), + /** + * Not `origin`: clients from before `omitIdleSubagentParents` read that key, ignore flags they + * do not know, and would resume rows that lack the idle parents they expect. They find no + * origin here, so they load a fresh snapshot. Rename again when an omission changes the rows. + */ + originV2: Schema.optionalKey(ShellCacheOrigin), }); export const StoredOrchestrationThreadSnapshot = Schema.Struct({ From 400dcf850a8c4a050c19d3002925421f3c65c112 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 12 Nov 2026 07:32:21 -0800 Subject: [PATCH 13/14] fix(mobile): the iPad detail column reopens a chat the shell left out With columns, a tab switch and a cold start reopen the last chat only when the shell rows hold it. The shell now leaves an idle subagent chain out, so an idle subagent with idle children was no longer reopened. An idle leaf subagent and an older settled chat never were. - The remembered chat is reopened by its reference, the way a link opens it. It stays closed only when the shell applied its removal (archived or deleted in this session) or its environment is gone. - A chat the workspace reopened by itself closes again, for good, when its detail says it was archived or deleted. A chat that only fails to load closes too, where a link would open a new draft. - A thread route waits for the open thread's family read before it gives up on a failed detail read. The family can still bring the row of a thread the shell left out, and a draft that replaced the route cannot be undone. Co-Authored-By: Claude Fable 5.2 --- .../layout/AdaptiveWorkspaceLayout.tsx | 7 +++ .../workspace/workspace-selection-store.ts | 57 ++++++++++++++---- .../workspace-tab-navigation.test.ts | 23 ++++++++ .../workspace/workspace-tab-navigation.ts | 38 ++++++++++-- .../features/threads/ThreadRouteScreen.tsx | 59 ++++++++++++++++++- .../threads/thread-route-hydration.test.ts | 39 ++++++++++++ .../threads/thread-route-hydration.ts | 23 +++++++- apps/mobile/src/state/threads.ts | 12 +++- apps/mobile/src/state/use-thread-selection.ts | 2 + 9 files changed, 236 insertions(+), 24 deletions(-) diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 19ffe072fb58e..4031d014ce246 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -106,9 +106,11 @@ import { useAndroidWorkspaceRoot } from "./workspace/AndroidWorkspaceTabsProvide import { dispatchRootStackReset, rootStackState } from "../../lib/root-stack-reset"; import { currentWorkspaceSelections, + markWorkspaceThreadReopened, workspaceSelectionCheck, } from "./workspace/workspace-selection-store"; import { + threadReopenedByTab, workspaceTabColumns, workspaceTabNavigationState, } from "./workspace/workspace-tab-navigation"; @@ -555,6 +557,11 @@ function AdaptiveWorkspaceLayoutContent( seedPullRequest: withKnownPullRequestSummary, }, ); + if (tab === "threads") { + markWorkspaceThreadReopened( + threadReopenedByTab(workspace, next.routes, currentWorkspaceSelections()), + ); + } if (layout.variant === "medium") { const opened = next.routes.at(-1 - overlays.length)?.key ?? null; setOverlaySidebar((current) => ({ ...current, visible: true, keepOpenFor: opened })); diff --git a/apps/mobile/src/features/layout/workspace/workspace-selection-store.ts b/apps/mobile/src/features/layout/workspace/workspace-selection-store.ts index 19d2653ddedfd..beee61533aa6e 100644 --- a/apps/mobile/src/features/layout/workspace/workspace-selection-store.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-selection-store.ts @@ -19,13 +19,11 @@ import { workspaceRoutesOf } from "../workspace-overlay-routes"; import type { WorkspaceSelections } from "../../../persistence/mobile-preferences"; import { appAtomRegistry } from "../../../state/atom-registry"; import { environmentPresentations } from "../../../state/presentation"; -import { - environmentThreadShells, - startOpeningEnvironmentThread, - threadEnvironment, -} from "../../../state/threads"; +import { environmentShell } from "../../../state/shell"; +import { environmentThreadShells, startOpeningEnvironmentThread } from "../../../state/threads"; import { rememberedSettingsPage, + rememberedThreadReopens, workspaceSelectionsIn, type WorkspaceSelectionCheck, } from "./workspace-tab-navigation"; @@ -38,6 +36,8 @@ const REMEMBERS = Platform.OS === "ios"; let selections: WorkspaceSelections = {}; let saved = selections; let hydration: Promise | null = null; +// The chat a tab switch or a cold start reopened by itself, while it stays the remembered one. +let reopened: WorkspaceSelections["threads"]; function save() { const writing = selections; @@ -89,6 +89,27 @@ export function currentWorkspaceSelections() { export function recordWorkspaceSelections(state: NavigationState | undefined) { if (!REMEMBERS || !state) return; selections = workspaceSelectionsIn(state.routes.slice(0, state.index + 1), selections); + if (reopened && !sameThread(reopened, selections.threads)) reopened = undefined; +} + +function sameThread( + left: { readonly environmentId: string; readonly threadId: string }, + right: { readonly environmentId: string; readonly threadId: string } | undefined, +) { + return left.environmentId === right?.environmentId && left.threadId === right.threadId; +} + +/** Marks the chat a tab switch reopened by itself; see `threadReopenedByTab`. */ +export function markWorkspaceThreadReopened(thread: WorkspaceSelections["threads"]) { + reopened = thread; +} + +/** + * Whether the workspace reopened this chat by itself. Nobody asked for it, so its screen closes + * it when it turns out archived or deleted, where a chat somebody opened shows what it has. + */ +export function workspaceThreadWasReopened(environmentId: EnvironmentId, threadId: ThreadId) { + return reopened !== undefined && sameThread(reopened, { environmentId, threadId }); } function environmentRemoved(environmentId: string) { @@ -101,22 +122,31 @@ function environmentRemoved(environmentId: string) { ); } -/** - * A remembered chat is gone once its environment was removed, or once that environment's - * snapshot has loaded without it. Before it loads there is nothing to contradict it. - */ +/** Reads what `rememberedThreadReopens` decides from: the shell rows and the removals it applied. */ export const workspaceSelectionCheck: WorkspaceSelectionCheck = { thread: (selection) => { if (environmentRemoved(selection.environmentId)) return false; const environmentId = selection.environmentId as EnvironmentId; - if (!appAtomRegistry.get(threadEnvironment.snapshotAtom(environmentId))) return true; - return appAtomRegistry - .get(environmentThreadShells.environmentThreadIndexAtom(environmentId)) - .has(selection.threadId as ThreadId); + const threadId = selection.threadId as ThreadId; + return rememberedThreadReopens({ + listed: appAtomRegistry + .get(environmentThreadShells.environmentThreadIndexAtom(environmentId)) + .has(threadId), + removed: appAtomRegistry + .get(environmentShell.stateValueAtom(environmentId)) + .removedAt.has(threadId), + }); }, pullRequest: (selection) => !environmentRemoved(selection.environmentId), }; +/** Stops reopening a chat its own screen found archived or deleted. */ +export function forgetWorkspaceThread(environmentId: EnvironmentId, threadId: ThreadId) { + const { threads, ...rest } = selections; + if (threads && sameThread(threads, { environmentId, threadId })) selections = rest; + if (reopened && sameThread(reopened, { environmentId, threadId })) reopened = undefined; +} + /** Whether the window lays the workspace out in columns, by the rule the layout itself uses. */ export function workspaceHasColumns() { const metrics = currentNativeLayoutMetrics(); @@ -153,6 +183,7 @@ export async function launchWorkspaceURL(): Promise { } await metrics; if (!thread || !workspaceHasColumns() || !workspaceSelectionCheck.thread(thread)) return null; + reopened = thread; return Linking.createURL( `threads/${encodeURIComponent(thread.environmentId)}/${encodeURIComponent(thread.threadId)}`, ); diff --git a/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts index 3f217fd70c8bc..8d0a97ea3b74a 100644 --- a/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vite-plus/test"; import { restoreSettingsLinkState } from "../../settings/settings-navigation"; import { + rememberedThreadReopens, + threadReopenedByTab, workspaceSelectionsIn, workspaceTabColumns, workspaceTabNavigationState, @@ -48,6 +50,27 @@ describe("workspace tab selections", () => { }); }); +describe("reopening the remembered chat", () => { + it("reopens a chat the shell left out, and not one the shell removed", () => { + // An idle subagent or an older settled chat: no row, and nothing removed it. + expect(rememberedThreadReopens({ listed: false, removed: false })).toBe(true); + expect(rememberedThreadReopens({ listed: true, removed: false })).toBe(true); + // Archived or deleted in this session. + expect(rememberedThreadReopens({ listed: false, removed: true })).toBe(false); + // Archived, then restored: the row is back. + expect(rememberedThreadReopens({ listed: true, removed: true })).toBe(true); + }); + + it("marks the chat a tab switch put on screen, and not one that was already there", () => { + const selections = { threads: thread.params }; + expect(threadReopenedByTab([home, pullRequest], [home, thread], selections)).toBe( + selections.threads, + ); + expect(threadReopenedByTab([home, thread], [home, thread], selections)).toBe(undefined); + expect(threadReopenedByTab([home, pullRequest], [home], selections)).toBe(undefined); + }); +}); + describe("opening a workspace tab", () => { const columns = { columns: true, check: available }; diff --git a/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.ts b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.ts index 6a44e617e53ed..ff645645f6064 100644 --- a/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.ts @@ -38,6 +38,37 @@ export function rememberedSettingsPage(selections: WorkspaceSelections): Setting : DEFAULT_SETTINGS_PAGE; } +/** + * Whether a remembered chat is reopened. Shells leave idle subagents and older settled chats out, + * so a missing row does not say the chat is gone; a removal the shell applied does. A chat + * reopened without a row closes itself when its detail says it was archived or deleted. + */ +export function rememberedThreadReopens(input: { + readonly listed: boolean; + readonly removed: boolean; +}) { + return input.listed || !input.removed; +} + +function showsThread(route: RouteLike, thread: ThreadSelection) { + return ( + route.name === "Thread" && + stringParam(route.params, "environmentId") === thread.environmentId && + stringParam(route.params, "threadId") === thread.threadId + ); +} + +/** The remembered chat, when opening a tab put it on screen and it was not there before. */ +export function threadReopenedByTab( + previous: readonly RouteLike[], + next: readonly RouteLike[], + selections: WorkspaceSelections, +) { + const thread = selections.threads; + if (!thread || previous.some((route) => showsThread(route, thread))) return undefined; + return next.some((route) => showsThread(route, thread)) ? thread : undefined; +} + /** Whether a remembered chat or pull request can still be opened. */ export interface WorkspaceSelectionCheck { readonly thread: (selection: ThreadSelection) => boolean; @@ -167,12 +198,7 @@ function detailRoute( const thread = selections.threads; if (!thread || !check.thread(thread)) return undefined; return ( - state.routes.find( - (route) => - route.name === "Thread" && - stringParam(route.params, "environmentId") === thread.environmentId && - stringParam(route.params, "threadId") === thread.threadId, - ) ?? { + state.routes.find((route) => showsThread(route, thread)) ?? { key: thread.key, name: "Thread", params: { environmentId: thread.environmentId, threadId: thread.threadId }, diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 9d787c1dc64f1..bf641eebc25ae 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -105,6 +105,7 @@ import { ThreadFileNavigatorPane } from "../files/thread-file-navigator-pane"; import { InspectorSheetContext } from "../layout/workspace-inspector-sheet"; import type { InspectorContent } from "../layout/inspector-panes"; import { + reopenedThreadIsGone, threadDetailErrorIsCurrent, threadRouteIsHydrating, threadRouteOpensDraft, @@ -126,6 +127,10 @@ import { useStartSideChat } from "./useStartSideChat"; import { useThreadStatusAnnouncements } from "./useThreadStatusAnnouncements"; import type { ThreadInspectorMode, ThreadInspectorTarget } from "./thread-inspector-navigation"; import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspaceDetail"; +import { + forgetWorkspaceThread, + workspaceThreadWasReopened, +} from "../layout/workspace/workspace-selection-store"; function firstRouteParam(value: string | string[] | undefined): string | null { const first = Array.isArray(value) ? value[0] : value; @@ -225,6 +230,23 @@ function UnavailableThreadDraftRedirect(props: { readonly environmentId: Environ return ; } +/** A chat the workspace reopened by itself and that cannot open: back to the list, for good. */ +function ReopenedThreadClose(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; +}) { + const goBack = useWorkspaceBackNavigation("threads"); + const close = useEffectEvent(() => { + forgetWorkspaceThread(props.environmentId, props.threadId); + goBack(); + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pops this route off the navigation stack on mount, because its thread was archived or deleted + useEffect(() => { + close(); + }, []); + return ; +} + export function ThreadRouteScreen(props: ThreadRouteScreenProps) { return ( @@ -244,8 +266,8 @@ function LiveThreadRouteController(props: ThreadRouteScreenProps) { useAndroidWorkspaceBack("threads"); const connectionsReady = useConnectionsReady(); const { connectionState } = useRemoteConnectionStatus(); - const { selectedThread, selectedThreadDetailRef } = useThreadSelection(); - useOpenThreadFamily(selectedThreadDetailRef); + const { selectedThread, selectedThreadListed, selectedThreadDetailRef } = useThreadSelection(); + const familyPending = useOpenThreadFamily(selectedThreadDetailRef); const params = props.route.params ?? {}; const environmentIdRaw = firstRouteParam(params.environmentId); const threadIdRaw = firstRouteParam(params.threadId); @@ -266,6 +288,18 @@ function LiveThreadRouteController(props: ThreadRouteScreenProps) { ? null : scopedThreadKey(selectedThread.environmentId, selectedThread.id); const selectedThreadDetailState = useSelectedThreadDetailState(); + // A shell row proves a reopened chat exists. Until one arrives, its detail can still close it. + const [listedThreadKey, setListedThreadKey] = useState(null); + if (selectedThreadListed && routeThreadKey !== null && listedThreadKey !== routeThreadKey) { + setListedThreadKey(routeThreadKey); + } + const reopenedUnproven = + routeName === "Thread" && + !props.inspectorPresentation && + environmentId !== null && + threadIdRaw !== null && + listedThreadKey !== routeThreadKey && + workspaceThreadWasReopened(environmentId, ThreadId.make(threadIdRaw)); const detailError = selectedThreadDetailState.error; const [errorBeforeConnected, setErrorBeforeConnected] = useState(() => routeConnectionState === "connected" ? Option.none() : detailError, @@ -290,6 +324,19 @@ function LiveThreadRouteController(props: ThreadRouteScreenProps) { return withInspectorHeader(); } + const closeReopened = ( + + ); + if ( + reopenedUnproven && + reopenedThreadIsGone({ + detailStatus: selectedThreadDetailState.status, + thread: selectedThreadKey === routeThreadKey ? selectedThread : null, + }) + ) { + return closeReopened; + } + if (selectedThread !== null && selectedThreadKey === routeThreadKey) { return withInspectorHeader( , @@ -311,6 +358,7 @@ function LiveThreadRouteController(props: ThreadRouteScreenProps) { error: detailError, errorBeforeConnected, }), + familyPending, }); if (stillHydrating) { @@ -325,7 +373,12 @@ function LiveThreadRouteController(props: ThreadRouteScreenProps) { detailStatus: selectedThreadDetailState.status, }) ) { - return ; + // A draft nobody asked for must not take the place of a chat that only failed to reopen. + return reopenedUnproven ? ( + closeReopened + ) : ( + + ); } return withInspectorHeader( diff --git a/apps/mobile/src/features/threads/thread-route-hydration.test.ts b/apps/mobile/src/features/threads/thread-route-hydration.test.ts index 9a7f1043609df..58ec59b896ecd 100644 --- a/apps/mobile/src/features/threads/thread-route-hydration.test.ts +++ b/apps/mobile/src/features/threads/thread-route-hydration.test.ts @@ -2,6 +2,7 @@ import * as Option from "effect/Option"; import { describe, expect, it } from "vite-plus/test"; import { + reopenedThreadIsGone, threadDetailErrorIsCurrent, threadRouteIsHydrating, threadRouteOpensDraft, @@ -16,9 +17,22 @@ const settled = { shellHasError: false, detailStatus: "live" as const, detailHasError: false, + familyPending: false, }; describe("threadRouteIsHydrating", () => { + it("waits for the family read of a thread the shell left out when its detail fails", () => { + const failed = { ...settled, detailStatus: "empty" as const, detailHasError: true }; + expect(threadRouteIsHydrating({ ...failed, familyPending: true })).toBe(true); + expect(threadRouteIsHydrating(failed)).toBe(false); + expect( + threadRouteIsHydrating({ ...failed, familyPending: true, connectionState: "reconnecting" }), + ).toBe(false); + expect( + threadRouteIsHydrating({ ...failed, familyPending: true, detailStatus: "deleted" }), + ).toBe(false); + }); + it("waits for shell and thread detail hydration", () => { expect(threadRouteIsHydrating({ ...settled, shellStatus: "synchronizing" })).toBe(true); expect(threadRouteIsHydrating({ ...settled, shellStatus: "empty" })).toBe(true); @@ -153,3 +167,28 @@ describe("threadDetailErrorIsCurrent", () => { ).toBe(true); }); }); + +describe("reopenedThreadIsGone", () => { + const open = { archivedAt: null, deletedAt: null }; + + it("closes a reopened chat only when its detail says it was archived or deleted", () => { + expect(reopenedThreadIsGone({ detailStatus: "deleted", thread: null })).toBe(true); + expect( + reopenedThreadIsGone({ detailStatus: "live", thread: { ...open, archivedAt: "2026-01-01" } }), + ).toBe(true); + expect( + reopenedThreadIsGone({ + detailStatus: "cached", + thread: { ...open, deletedAt: "2026-01-01" }, + }), + ).toBe(true); + expect(reopenedThreadIsGone({ detailStatus: "live", thread: open })).toBe(false); + for (const detailStatus of ["empty", "cached", "synchronizing", "live"] as const) { + expect(reopenedThreadIsGone({ detailStatus, thread: null })).toBe(false); + } + }); + + it("keeps a chat still being created, whose first read can miss", () => { + expect(reopenedThreadIsGone({ detailStatus: "deleted", thread: open })).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-route-hydration.ts b/apps/mobile/src/features/threads/thread-route-hydration.ts index a6e3454d47659..0c6a5b56ae64d 100644 --- a/apps/mobile/src/features/threads/thread-route-hydration.ts +++ b/apps/mobile/src/features/threads/thread-route-hydration.ts @@ -12,10 +12,15 @@ export function threadRouteIsHydrating(input: { readonly shellHasError: boolean; readonly detailStatus: EnvironmentThreadStatus; readonly detailHasError: boolean; + /** The open thread's family read can still bring the row of a thread the shell left out. */ + readonly familyPending: boolean; }): boolean { - if (input.detailStatus === "deleted" || input.shellHasError || input.detailHasError) { + if (input.detailStatus === "deleted" || input.shellHasError) { return false; } + if (input.detailHasError) { + return input.connectionState === "connected" && input.familyPending; + } if (input.isLoadingConnections) { return true; } @@ -73,3 +78,19 @@ export function threadDetailErrorIsCurrent(input: { !(input.connectionState === "connected" && input.error === input.errorBeforeConnected) ); } + +/** + * Whether a chat the workspace reopened by itself must close again. Shells leave idle subagents + * and older settled chats out, so the reopened chat may have no row; then only its detail can say + * that it was archived or deleted. + */ +export function reopenedThreadIsGone(input: { + readonly detailStatus: EnvironmentThreadStatus; + readonly thread: { + readonly archivedAt: string | null; + readonly deletedAt: string | null; + } | null; +}): boolean { + if (input.thread === null) return input.detailStatus === "deleted"; + return input.thread.archivedAt !== null || input.thread.deletedAt !== null; +} diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index d5ca56d25c590..d03e9fc029a32 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -9,6 +9,7 @@ import { createOlderSettledThreadAtoms, createThreadEnvironmentAtoms, createThreadFamilyAtoms, + type ThreadFamilyRead, } from "@t3tools/client-runtime/state/threads"; import { createSteadyShellAtoms } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; @@ -113,12 +114,15 @@ export function useEnvironmentThread( return state; } +const NO_OPEN_FAMILY = Atom.make("settled"); + /** * Keeps the open thread's family in the shell rows until its screen leaves. Shells leave idle * subagents out, so the rows of the thread, of its parents and of its subagents may exist only * there. Pass the ref the detail is read by: a thread still being created has no family to read. + * Returns whether those rows can still arrive. */ -export function useOpenThreadFamily(ref: ScopedThreadRef | null) { +export function useOpenThreadFamily(ref: ScopedThreadRef | null): boolean { const environmentId = ref?.environmentId ?? null; const threadId = ref?.threadId ?? null; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- holds the open thread's family in the thread family registry until the screen leaves it @@ -126,4 +130,10 @@ export function useOpenThreadFamily(ref: ScopedThreadRef | null) { if (environmentId === null || threadId === null) return; return threadFamilies.mount(appAtomRegistry, environmentId, threadId); }, [environmentId, threadId]); + const read = useAtomValue( + environmentId === null || threadId === null + ? NO_OPEN_FAMILY + : threadFamilies.mountedReadAtom(environmentId, threadId), + ); + return read === "pending"; } diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 5805d9b65298a..8ac98a57cb579 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -122,6 +122,8 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin return { selectedThreadRef, selectedThread, + /** Whether the shell rows hold the thread; `selectedThread` can also come from its detail. */ + selectedThreadListed: selectedThreadShell !== null, selectedThreadCreation, selectedThreadDetailRef, selectedThreadProject, From 9ac21c0a92be8cf65465462969c8790b048f0087 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 12 Nov 2026 08:52:31 -0800 Subject: [PATCH 14/14] fix(mobile): a cold start reopens a subagent by its real id, and a kept screen proves a reopened chat again Found on an iPad simulator. - The launch link for the last chat went through `Linking.createURL`, which encodes the path a second time. A delegated subagent's id holds `%` and `:`, so the route asked for a thread that does not exist and turned into a new draft. The link is now the app prefix plus the encoded path. - iOS keeps a thread screen mounted across a tab switch. What that screen learned about its thread now counts for one reopening only, so a chat archived in between cannot stay open because it had a shell row before. - A kept screen whose route a tab switch dropped no longer goes back on behalf of the page in front. Co-Authored-By: Claude Fable 5.2 --- .../workspace/workspace-selection-store.ts | 21 +++++++++----- .../features/threads/ThreadRouteScreen.tsx | 29 +++++++++++-------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/features/layout/workspace/workspace-selection-store.ts b/apps/mobile/src/features/layout/workspace/workspace-selection-store.ts index beee61533aa6e..5db063127a1a4 100644 --- a/apps/mobile/src/features/layout/workspace/workspace-selection-store.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-selection-store.ts @@ -38,6 +38,7 @@ let saved = selections; let hydration: Promise | null = null; // The chat a tab switch or a cold start reopened by itself, while it stays the remembered one. let reopened: WorkspaceSelections["threads"]; +let reopenings = 0; function save() { const writing = selections; @@ -102,14 +103,19 @@ function sameThread( /** Marks the chat a tab switch reopened by itself; see `threadReopenedByTab`. */ export function markWorkspaceThreadReopened(thread: WorkspaceSelections["threads"]) { reopened = thread; + if (thread) reopenings += 1; } /** - * Whether the workspace reopened this chat by itself. Nobody asked for it, so its screen closes - * it when it turns out archived or deleted, where a chat somebody opened shows what it has. + * The number of the reopening that put this chat on screen, or null when somebody opened it. + * Nobody asked for a reopened chat, so its screen closes it when it turns out archived or + * deleted, where a chat somebody opened shows what it has. iOS keeps a screen mounted across tab + * switches, so what the screen learned counts for one reopening only. */ -export function workspaceThreadWasReopened(environmentId: EnvironmentId, threadId: ThreadId) { - return reopened !== undefined && sameThread(reopened, { environmentId, threadId }); +export function workspaceThreadReopening(environmentId: EnvironmentId, threadId: ThreadId) { + return reopened !== undefined && sameThread(reopened, { environmentId, threadId }) + ? reopenings + : null; } function environmentRemoved(environmentId: string) { @@ -183,10 +189,9 @@ export async function launchWorkspaceURL(): Promise { } await metrics; if (!thread || !workspaceHasColumns() || !workspaceSelectionCheck.thread(thread)) return null; - reopened = thread; - return Linking.createURL( - `threads/${encodeURIComponent(thread.environmentId)}/${encodeURIComponent(thread.threadId)}`, - ); + markWorkspaceThreadReopened(thread); + // Not `createURL(path)`: it encodes the path again, and a subagent's id holds `%` and `:`. + return `${Linking.createURL("/")}threads/${encodeURIComponent(thread.environmentId)}/${encodeURIComponent(thread.threadId)}`; } /** Whether the workspace shows its first detail page, directly above its list. */ diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index bf641eebc25ae..6a8f84f84a034 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -129,7 +129,7 @@ import type { ThreadInspectorMode, ThreadInspectorTarget } from "./thread-inspec import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspaceDetail"; import { forgetWorkspaceThread, - workspaceThreadWasReopened, + workspaceThreadReopening, } from "../layout/workspace/workspace-selection-store"; function firstRouteParam(value: string | string[] | undefined): string | null { @@ -236,11 +236,15 @@ function ReopenedThreadClose(props: { readonly threadId: ThreadId; }) { const goBack = useWorkspaceBackNavigation("threads"); + const navigation = useNavigation(); + const routeKey = useRoute().key; const close = useEffectEvent(() => { forgetWorkspaceThread(props.environmentId, props.threadId); - goBack(); + // iOS keeps this screen mounted after a tab switch dropped its route; going back from there + // would act on the page in front. + if (navigation.getState()?.routes.some((route) => route.key === routeKey)) goBack(); }); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pops this route off the navigation stack on mount, because its thread was archived or deleted + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- leaves this route for the list on mount: the workspace reopened the chat by itself and it cannot open useEffect(() => { close(); }, []); @@ -288,18 +292,19 @@ function LiveThreadRouteController(props: ThreadRouteScreenProps) { ? null : scopedThreadKey(selectedThread.environmentId, selectedThread.id); const selectedThreadDetailState = useSelectedThreadDetailState(); - // A shell row proves a reopened chat exists. Until one arrives, its detail can still close it. - const [listedThreadKey, setListedThreadKey] = useState(null); - if (selectedThreadListed && routeThreadKey !== null && listedThreadKey !== routeThreadKey) { - setListedThreadKey(routeThreadKey); - } - const reopenedUnproven = + const reopening = routeName === "Thread" && !props.inspectorPresentation && environmentId !== null && - threadIdRaw !== null && - listedThreadKey !== routeThreadKey && - workspaceThreadWasReopened(environmentId, ThreadId.make(threadIdRaw)); + threadIdRaw !== null + ? workspaceThreadReopening(environmentId, ThreadId.make(threadIdRaw)) + : null; + // A shell row proves a reopened chat exists. Until one arrives, its detail can still close it. + const [provenReopening, setProvenReopening] = useState(null); + if (reopening !== null && selectedThreadListed && provenReopening !== reopening) { + setProvenReopening(reopening); + } + const reopenedUnproven = reopening !== null && provenReopening !== reopening; const detailError = selectedThreadDetailState.error; const [errorBeforeConnected, setErrorBeforeConnected] = useState(() => routeConnectionState === "connected" ? Option.none() : detailError,