From bbd678e3e28e534ee62cc78bec8ec9c0f84a3006 Mon Sep 17 00:00:00 2001 From: SAPHID Date: Tue, 27 Oct 2026 16:05:40 +0000 Subject: [PATCH 1/5] fix(mobile): threads on an unreachable environment no longer hide in the Working shelf With the Working shelf on, a thread whose environment stopped answering kept its last cached running state, so it stayed folded into the collapsed shelf indefinitely and a broken thread was invisible. Past a first connection attempt (the rule thread-route hydration uses), the list treats the environment as disconnected and lists its threads in the inbox instead of the shelf until it reconnects. Co-Authored-By: Claude Opus 5.5 --- .../src/features/threads/threadListV2.test.ts | 32 +++++++++++++++++++ .../src/features/threads/threadListV2.ts | 21 +++++++++++- .../src/features/threads/useThreadListV2.ts | 10 ++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 18ae87e431b35..dc482832eb534 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -34,6 +34,7 @@ import { buildThreadListV2Items, buildThreadListV2ListItems, getThreadListV2OrderedSection, + isThreadListV2EnvironmentDisconnected, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozePresets, resolveThreadListV2Status, @@ -154,6 +155,20 @@ describe("resolveThreadListV2Status", () => { ); }); +describe("isThreadListV2EnvironmentDisconnected", () => { + it.each([ + ["connected", false], + ["connecting", false], + ["reconnecting", true], + ["offline", true], + ["error", true], + ["unsupported", true], + ["available", true], + ] as const)("treats %s as disconnected: %s", (connectionState, disconnected) => { + expect(isThreadListV2EnvironmentDisconnected(connectionState)).toBe(disconnected); + }); +}); + describe("queued messages keep a settled thread active", () => { const threads = [ makeThread({ id: ThreadId.make("active"), title: "Active" }), @@ -1648,6 +1663,23 @@ describe("Working section beta", () => { } }); + it("lists a working thread on a disconnected environment outside the shelf", () => { + const unreachableId = EnvironmentId.make("environment-unreachable"); + const unreachable = makeThreadShellFixture({ + environmentId: unreachableId, + id: ThreadId.make("unreachable"), + title: "unreachable", + runtime: running, + }); + const layout = build({ + threads: [...threads, unreachable], + disconnectedEnvironmentIds: new Set([unreachableId]), + }); + + expect(ids(layout)).toContain("unreachable"); + expect(layout.workingCount).toBe(1); + }); + it("orders the inbox by the latest return this device observed", () => { const layout = build({ inboxReturnAt: (thread) => diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index e2f826bae2673..d37b5b0158065 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -27,6 +27,7 @@ import { sortPinnedThreadsByOrderKey, sortSettledThreads, } from "@t3tools/client-runtime/state/thread-sort"; +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { ThreadListProvider } from "../../state/thread-list-environments"; @@ -196,6 +197,16 @@ export function resolveThreadListV2WokeAt( : null; } +/** + * Past a first connection attempt the environment is not answering, so its threads' last run + * states are stale. Matches `threadRouteIsHydrating`. + */ +export function isThreadListV2EnvironmentDisconnected( + connectionState: EnvironmentConnectionPhase, +): boolean { + return connectionState !== "connected" && connectionState !== "connecting"; +} + export function resolveThreadListV2Status( thread: Pick< EnvironmentThreadShell, @@ -601,6 +612,7 @@ export function buildThreadListV2Items(input: { readonly matchedThreadKeys?: ReadonlySet; readonly settlementEnvironmentIds?: ReadonlySet; readonly snoozeEnvironmentIds?: ReadonlySet; + readonly disconnectedEnvironmentIds?: ReadonlySet; readonly settledLimit?: number; readonly now: string; readonly workingShelfEnabled?: boolean; @@ -642,6 +654,8 @@ export function buildThreadListV2Items(input: { const snoozed: EnvironmentThreadShell[] = []; let nextSnoozeWakeAt: string | null = null; const queuedThreadKeys = input.queuedThreadKeys?.size ? input.queuedThreadKeys : undefined; + const isDisconnected = (thread: EnvironmentThreadShell) => + input.disconnectedEnvironmentIds?.has(thread.environmentId) === true; const listedThreads = input.threads.filter( (thread) => thread.archivedAt === null && @@ -692,7 +706,12 @@ export function buildThreadListV2Items(input: { settled.push(thread); } else if (thread.pinnedAt != null) { pinned.push(thread); - } else if (workingShelfEnabled && (input.isWorking ?? isThreadWorking)(thread)) { + } else if ( + workingShelfEnabled && + // A shelf the user collapsed must not hide a thread whose progress is unknown. + !isDisconnected(thread) && + (input.isWorking ?? isThreadWorking)(thread) + ) { working.push(thread); } else { active.push(thread); diff --git a/apps/mobile/src/features/threads/useThreadListV2.ts b/apps/mobile/src/features/threads/useThreadListV2.ts index 2a6f3504e2f5d..493d83caff360 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.ts +++ b/apps/mobile/src/features/threads/useThreadListV2.ts @@ -5,10 +5,12 @@ import { useMemo, useSyncExternalStore } from "react"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { useOlderSettledShelf, useOlderSettledShelfGrowth } from "../../state/olderSettledShelf"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; +import { useWorkspaceEnvironments } from "../../state/workspace"; import { buildThreadListV2Items, buildThreadListV2ListItems, getThreadListV2OrderedSection, + isThreadListV2EnvironmentDisconnected, threadListInboxReturns, } from "./threadListV2"; import { computeThreadMoveAvailability } from "./threadOrder"; @@ -66,6 +68,13 @@ export function useThreadListV2(input: { // oxlint-disable-next-line react/memo-dependencies -- the version signals an expired grace [threads, workingShelfEnabled, inboxVersion], ); + const disconnectedEnvironmentIds = useKeySet( + useWorkspaceEnvironments() + .filter((environment) => isThreadListV2EnvironmentDisconnected(environment.connectionState)) + .map((environment) => environment.environmentId) + .sort() + .join("\n"), + ); // Up/down menu availability for every card, computed once per section per // rebuild (see computeThreadMoveAvailability): per-thread planner calls made // list construction quadratic, and this list rebuilds on every minute tick. @@ -107,6 +116,7 @@ export function useThreadListV2(input: { matchedThreadKeys: new Set(input.searchMatches.keys()), settlementEnvironmentIds: input.settlementEnvironmentIds, snoozeEnvironmentIds: input.snoozeEnvironmentIds, + disconnectedEnvironmentIds, queuedThreadKeys: input.queuedThreadKeys, settledLimit: input.settledLimit, now: input.now, From 2984931816e5dcdce6a474de47c98c91ed56e95b Mon Sep 17 00:00:00 2001 From: SAPHID Date: Sat, 31 Oct 2026 07:37:58 +0000 Subject: [PATCH 2/5] fix(mobile): preserve newline-containing thread list keys --- .../src/features/threads/useKeySet.test.tsx | 31 +++++++++++++++++++ apps/mobile/src/features/threads/useKeySet.ts | 7 +++++ .../src/features/threads/useThreadListV2.ts | 19 ++---------- apps/mobile/vite.config.ts | 1 + 4 files changed, 42 insertions(+), 16 deletions(-) create mode 100644 apps/mobile/src/features/threads/useKeySet.test.tsx create mode 100644 apps/mobile/src/features/threads/useKeySet.ts diff --git a/apps/mobile/src/features/threads/useKeySet.test.tsx b/apps/mobile/src/features/threads/useKeySet.test.tsx new file mode 100644 index 0000000000000..1a32f87ededf4 --- /dev/null +++ b/apps/mobile/src/features/threads/useKeySet.test.tsx @@ -0,0 +1,31 @@ +import { act, useLayoutEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it } from "vite-plus/test"; + +import { useKeySet } from "./useKeySet"; + +it("preserves embedded newlines without changing identity for reordered keys", async () => { + const seen: ReadonlySet[] = []; + function Probe({ keys }: { readonly keys: ReadonlyArray }) { + const value = useKeySet(keys); + useLayoutEffect(() => { + seen.push(value); + }); + return null; + } + const root = createRoot(document.createElement("div")); + try { + await act(async () => root.render()); + expect(seen.at(-1)).toEqual(new Set(["a\nb", "c"])); + const original = seen.at(-1); + await act(async () => root.render()); + expect(seen.at(-1)).toBe(original); + await act(async () => root.render()); + expect(seen.at(-1)).toEqual(new Set(["a", "b", "c"])); + expect(seen.at(-1)).not.toBe(original); + await act(async () => root.render()); + expect(seen.at(-1)?.size).toBe(0); + } finally { + act(() => root.unmount()); + } +}); diff --git a/apps/mobile/src/features/threads/useKeySet.ts b/apps/mobile/src/features/threads/useKeySet.ts new file mode 100644 index 0000000000000..52ee3525fe5fd --- /dev/null +++ b/apps/mobile/src/features/threads/useKeySet.ts @@ -0,0 +1,7 @@ +import { useMemo } from "react"; + +// Stable key sets keep thread row selection handlers stable across list rebuilds. +export function useKeySet(keys: ReadonlyArray): ReadonlySet { + const serializedKeys = JSON.stringify([...keys].sort()); + return useMemo(() => new Set(JSON.parse(serializedKeys) as string[]), [serializedKeys]); +} diff --git a/apps/mobile/src/features/threads/useThreadListV2.ts b/apps/mobile/src/features/threads/useThreadListV2.ts index 493d83caff360..966d0e3728174 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.ts +++ b/apps/mobile/src/features/threads/useThreadListV2.ts @@ -14,18 +14,7 @@ import { threadListInboxReturns, } from "./threadListV2"; import { computeThreadMoveAvailability } from "./threadOrder"; - -/** - * Screens' select handlers read the content-only key set; a fresh one per list rebuild gave - * every row a new handler whenever any thread shell changed. Keyed by a string, the set keeps - * its identity until its keys change. - */ -function useKeySet(newlineSeparatedKeys: string): ReadonlySet { - return useMemo( - () => new Set(newlineSeparatedKeys.length === 0 ? [] : newlineSeparatedKeys.split("\n")), - [newlineSeparatedKeys], - ); -} +import { useKeySet } from "./useKeySet"; type LayoutInput = Parameters[0]; @@ -71,9 +60,7 @@ export function useThreadListV2(input: { const disconnectedEnvironmentIds = useKeySet( useWorkspaceEnvironments() .filter((environment) => isThreadListV2EnvironmentDisconnected(environment.connectionState)) - .map((environment) => environment.environmentId) - .sort() - .join("\n"), + .map((environment) => environment.environmentId), ); // Up/down menu availability for every card, computed once per section per // rebuild (see computeThreadMoveAvailability): per-thread planner calls made @@ -141,7 +128,7 @@ export function useThreadListV2(input: { ), }); useOlderSettledShelfGrowth(olderSettledShelf, layout.listedSettledThreads); - const contentOnlyThreadKeys = useKeySet([...layout.contentOnlyThreadKeys].sort().join("\n")); + const contentOnlyThreadKeys = useKeySet([...layout.contentOnlyThreadKeys]); const scopedProjectKeys = input.projectRefs == null ? null diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 6e24636671608..9026a7a4a9115 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -35,6 +35,7 @@ const reactTests = [ "src/state/use-selected-thread-git-state.test.tsx", "src/features/terminal/useTerminalExitNavigation.test.tsx", "src/features/threads/use-thread-list-now.test.tsx", + "src/features/threads/useKeySet.test.tsx", "src/features/home/home-list-options.test.tsx", "src/features/connection/ConnectionsNewRouteScreen.test.tsx", ]; From 25774b3323f46381780c988cb189290c8990db65 Mon Sep 17 00:00:00 2001 From: SAPHID Date: Sat, 31 Oct 2026 07:54:28 +0000 Subject: [PATCH 3/5] fix(mobile): keep cached threads visible until shell sync --- .../src/features/threads/threadListV2.test.ts | 29 ++++++++++++------- .../src/features/threads/threadListV2.ts | 11 +++---- .../src/features/threads/useThreadListV2.ts | 22 ++++++++++---- 3 files changed, 41 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 39bbcfe373bd1..49101ce99cc84 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -163,16 +163,25 @@ describe("resolveThreadListV2Status", () => { describe("isThreadListV2EnvironmentDisconnected", () => { it.each([ - ["connected", false], - ["connecting", false], - ["reconnecting", true], - ["offline", true], - ["error", true], - ["unsupported", true], - ["available", true], - ] as const)("treats %s as disconnected: %s", (connectionState, disconnected) => { - expect(isThreadListV2EnvironmentDisconnected(connectionState)).toBe(disconnected); - }); + ["connected", "live", false], + ["connected", "cached", true], + ["connected", "synchronizing", true], + ["connected", "empty", true], + ["connecting", "live", false], + ["connecting", "cached", true], + ["reconnecting", "live", true], + ["offline", "live", true], + ["error", "live", true], + ["unsupported", "live", true], + ["available", "live", true], + ] as const)( + "treats %s with a %s shell as disconnected: %s", + (connectionState, shellStatus, disconnected) => { + expect(isThreadListV2EnvironmentDisconnected(connectionState, shellStatus)).toBe( + disconnected, + ); + }, + ); }); describe("queued messages keep a settled thread active", () => { diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index afc78bdc89336..c321c35ada9a8 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -1,3 +1,4 @@ +import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; import { canSnooze, effectiveSnoozed, @@ -199,14 +200,14 @@ export function resolveThreadListV2WokeAt( : null; } -/** - * Past a first connection attempt the environment is not answering, so its threads' last run - * states are stale. Matches `threadRouteIsHydrating`. - */ +// A connected socket can still hold cached run states until shell synchronization finishes. export function isThreadListV2EnvironmentDisconnected( connectionState: EnvironmentConnectionPhase, + shellStatus: EnvironmentShellStatus, ): boolean { - return connectionState !== "connected" && connectionState !== "connecting"; + return ( + (connectionState !== "connected" && connectionState !== "connecting") || shellStatus !== "live" + ); } export function resolveThreadListV2Status( diff --git a/apps/mobile/src/features/threads/useThreadListV2.ts b/apps/mobile/src/features/threads/useThreadListV2.ts index 966d0e3728174..5bec6dbbfaa47 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.ts +++ b/apps/mobile/src/features/threads/useThreadListV2.ts @@ -1,11 +1,14 @@ +import { useAtomValue } from "@effect/atom-react"; import type { OlderSettledScope } from "@t3tools/client-runtime/state/threads"; import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; import { useMemo, useSyncExternalStore } from "react"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { useOlderSettledShelf, useOlderSettledShelfGrowth } from "../../state/olderSettledShelf"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; -import { useWorkspaceEnvironments } from "../../state/workspace"; +import { environmentShell } from "../../state/shell"; +import { workspaceConnections } from "../../state/workspace"; import { buildThreadListV2Items, buildThreadListV2ListItems, @@ -16,6 +19,17 @@ import { import { computeThreadMoveAvailability } from "./threadOrder"; import { useKeySet } from "./useKeySet"; +const disconnectedEnvironmentIdsAtom = Atom.make((get) => + get(workspaceConnections.environmentsAtom) + .filter((environment) => + isThreadListV2EnvironmentDisconnected( + environment.connectionState, + get(environmentShell.statusAtom(environment.environmentId)), + ), + ) + .map((environment) => environment.environmentId), +); + type LayoutInput = Parameters[0]; /** @@ -57,11 +71,7 @@ export function useThreadListV2(input: { // oxlint-disable-next-line react/memo-dependencies -- the version signals an expired grace [threads, workingShelfEnabled, inboxVersion], ); - const disconnectedEnvironmentIds = useKeySet( - useWorkspaceEnvironments() - .filter((environment) => isThreadListV2EnvironmentDisconnected(environment.connectionState)) - .map((environment) => environment.environmentId), - ); + const disconnectedEnvironmentIds = useKeySet(useAtomValue(disconnectedEnvironmentIdsAtom)); // Up/down menu availability for every card, computed once per section per // rebuild (see computeThreadMoveAvailability): per-thread planner calls made // list construction quadratic, and this list rebuilds on every minute tick. From 57c5ad5698de1e40033a77898bc41d1bb7c07f39 Mon Sep 17 00:00:00 2001 From: SAPHID Date: Sat, 31 Oct 2026 08:04:01 +0000 Subject: [PATCH 4/5] fix(mobile): preserve first-connection shelf placement --- apps/mobile/src/features/threads/threadListV2.test.ts | 4 +++- apps/mobile/src/features/threads/threadListV2.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 49101ce99cc84..6b2fd877f7d7c 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -168,7 +168,9 @@ describe("isThreadListV2EnvironmentDisconnected", () => { ["connected", "synchronizing", true], ["connected", "empty", true], ["connecting", "live", false], - ["connecting", "cached", true], + ["connecting", "cached", false], + ["connecting", "synchronizing", false], + ["connecting", "empty", false], ["reconnecting", "live", true], ["offline", "live", true], ["error", "live", true], diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index c321c35ada9a8..51afbb3bbc57c 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -206,7 +206,7 @@ export function isThreadListV2EnvironmentDisconnected( shellStatus: EnvironmentShellStatus, ): boolean { return ( - (connectionState !== "connected" && connectionState !== "connecting") || shellStatus !== "live" + connectionState !== "connecting" && (connectionState !== "connected" || shellStatus !== "live") ); } From 6410a3f7ce47285980bf20a75ec00687b3f6a83c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 2 Nov 2026 01:18:17 +0000 Subject: [PATCH 5/5] test(mobile): isolate thread list connection subscriptions --- apps/mobile/src/features/threads/useThreadListV2.test.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/mobile/src/features/threads/useThreadListV2.test.tsx b/apps/mobile/src/features/threads/useThreadListV2.test.tsx index 7eac8955efa14..76fc1930c99bd 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.test.tsx +++ b/apps/mobile/src/features/threads/useThreadListV2.test.tsx @@ -9,6 +9,7 @@ import { Atom, AtomRegistry } from "effect/reactivity"; import { expect, it, vi } from "vite-plus/test"; import { olderSettledThreads } from "../../state/threads"; +import { workspaceConnections } from "../../state/workspace"; import { makeRawThreadShell } from "../../test-fixtures"; import { useThreadListV2 } from "./useThreadListV2"; @@ -17,9 +18,14 @@ vi.mock("../../state/threads", () => ({ threadFamilies: { mountRows: () => () => {} }, })); vi.mock("../../state/atom-registry", () => ({ appAtomRegistry: undefined })); +vi.mock("../../state/workspace", () => ({ + workspaceConnections: { environmentsAtom: vi.fn() }, +})); +vi.mock("../../state/shell", () => ({ environmentShell: { statusAtom: vi.fn() } })); it("reuses a large thread list across tab renders and updates when its inputs change", async () => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.mocked(workspaceConnections).environmentsAtom = Atom.make([]); using _globals = { [Symbol.dispose]: () => vi.unstubAllGlobals() }; const shelfAtom = Atom.make>(new Map()); vi.mocked(olderSettledThreads.shelfAtom).mockReturnValue(shelfAtom);