diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index ff438f006f1c..6b2fd877f7d7 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, @@ -160,6 +161,31 @@ describe("resolveThreadListV2Status", () => { ); }); +describe("isThreadListV2EnvironmentDisconnected", () => { + it.each([ + ["connected", "live", false], + ["connected", "cached", true], + ["connected", "synchronizing", true], + ["connected", "empty", true], + ["connecting", "live", false], + ["connecting", "cached", false], + ["connecting", "synchronizing", false], + ["connecting", "empty", false], + ["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", () => { const threads = [ makeThread({ id: ThreadId.make("active"), title: "Active" }), @@ -1653,6 +1679,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 4c2b35ed6f92..753f2377ed50 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, @@ -28,6 +29,7 @@ import { sortPinnedThreadsByOrderKey, sortSettledThreads, } from "@t3tools/client-runtime/state/thread-sort"; +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import { isListedThreadLineage, type EnvironmentId, type ProjectId } from "@t3tools/contracts"; import type { SnoozePresetSetting } from "@t3tools/contracts/settings"; @@ -219,6 +221,16 @@ export function resolveThreadListV2WokeAt( : null; } +// A connected socket can still hold cached run states until shell synchronization finishes. +export function isThreadListV2EnvironmentDisconnected( + connectionState: EnvironmentConnectionPhase, + shellStatus: EnvironmentShellStatus, +): boolean { + return ( + connectionState !== "connecting" && (connectionState !== "connected" || shellStatus !== "live") + ); +} + export function resolveThreadListV2Status( thread: Pick< EnvironmentThreadShell, @@ -623,6 +635,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; @@ -664,6 +677,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 && @@ -714,7 +729,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/useKeySet.test.tsx b/apps/mobile/src/features/threads/useKeySet.test.tsx new file mode 100644 index 000000000000..1a32f87ededf --- /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 000000000000..52ee3525fe5f --- /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.test.tsx b/apps/mobile/src/features/threads/useThreadListV2.test.tsx index 7eac8955efa1..76fc1930c99b 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); diff --git a/apps/mobile/src/features/threads/useThreadListV2.ts b/apps/mobile/src/features/threads/useThreadListV2.ts index fcf1048d2b6a..341b7ab2d964 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.ts +++ b/apps/mobile/src/features/threads/useThreadListV2.ts @@ -1,30 +1,36 @@ +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 { environmentShell } from "../../state/shell"; +import { workspaceConnections } from "../../state/workspace"; import { buildThreadListV2Items, buildThreadListV2ListItems, getThreadListV2OrderedSection, + isThreadListV2EnvironmentDisconnected, threadListInboxReturns, threadListV2LayoutTime, } 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"; + +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]; @@ -80,6 +86,7 @@ export function useThreadListV2(input: { () => observeInbox(workingShelfEnabled ? threads : null, inboxVersion), [threads, workingShelfEnabled, inboxVersion], ); + const disconnectedEnvironmentIds = useKeySet(useAtomValue(disconnectedEnvironmentIdsAtom)); // Compute move availability per section; per-row planning is quadratic. const moveAvailability = useMemo(() => { const sectionAvailability = (section: "pinned" | "active") => @@ -142,6 +149,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: layoutTime, @@ -175,6 +183,7 @@ export function useThreadListV2(input: { input.settlementEnvironmentIds, input.snoozeEnvironmentIds, input.queuedThreadKeys, + disconnectedEnvironmentIds, input.settledLimit, layoutTime, workingShelfEnabled, @@ -190,7 +199,7 @@ export function useThreadListV2(input: { ], ); useOlderSettledShelfGrowth(olderSettledShelf, layout.listedSettledThreads); - const contentOnlyThreadKeys = useKeySet([...layout.contentOnlyThreadKeys].sort().join("\n")); + const contentOnlyThreadKeys = useKeySet([...layout.contentOnlyThreadKeys]); const pendingTasks = useMemo(() => { const scopedProjectKeys = input.projectRefs == null diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 5e2e5a8e3560..7fa71e1b390e 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -37,6 +37,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/threads/useThreadListV2.test.tsx", "src/features/home/home-list-options.test.tsx", "src/features/workspace/useSearchScrollRestoration.test.tsx",