diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index c848e063e2bed..c4288b79f6b46 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -36,6 +36,7 @@ import { buildThreadListV2Items, buildThreadListV2ListItems, getThreadListV2OrderedSection, + isThreadListV2EnvironmentDisconnected, layoutThreadListV2RowMenuActions, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SwipeActions, @@ -210,6 +211,17 @@ describe("resolveThreadListV2Status", () => { ); }); +describe("isThreadListV2EnvironmentDisconnected", () => { + it.each([ + ["connected", false], + ["connecting", false], + ["reconnecting", true], + ["offline", true], + ] as const)("treats %s as disconnected: %s", (connectionState, disconnected) => { + expect(isThreadListV2EnvironmentDisconnected(connectionState)).toBe(disconnected); + }); +}); + describe("resolveThreadListV2SwipeActions", () => { it("offers snooze for a presented input request and moves it onto the snoozed shelf", () => { const raw = makeRawThreadShell({ @@ -1719,6 +1731,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 7854f9d7fdb85..dd590696624ed 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -29,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"; import type { MenuAction } from "@react-native-menu/menu"; @@ -257,6 +258,13 @@ export function resolveThreadListV2WokeAt( : null; } +// The first connection attempt keeps cached placement so threads do not jump shelves on launch. +export function isThreadListV2EnvironmentDisconnected( + connectionState: EnvironmentConnectionPhase, +): boolean { + return connectionState !== "connecting" && connectionState !== "connected"; +} + export function resolveThreadListV2Status( thread: Pick< EnvironmentThreadShell, @@ -661,6 +669,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; @@ -702,6 +711,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 && @@ -752,7 +763,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.test.tsx b/apps/mobile/src/features/threads/useThreadListV2.test.tsx index 7eac8955efa14..8ff39e7e4a0e4 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,13 @@ vi.mock("../../state/threads", () => ({ threadFamilies: { mountRows: () => () => {} }, })); vi.mock("../../state/atom-registry", () => ({ appAtomRegistry: undefined })); +vi.mock("../../state/workspace", () => ({ + workspaceConnections: { environmentsAtom: 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 fcf1048d2b6af..99a75028d2ad9 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.ts +++ b/apps/mobile/src/features/threads/useThreadListV2.ts @@ -1,14 +1,18 @@ +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 { workspaceConnections } from "../../state/workspace"; import { buildThreadListV2Items, buildThreadListV2ListItems, getThreadListV2OrderedSection, + isThreadListV2EnvironmentDisconnected, threadListInboxReturns, threadListV2LayoutTime, } from "./threadListV2"; @@ -26,6 +30,14 @@ function useKeySet(newlineSeparatedKeys: string): ReadonlySet { ); } +const disconnectedEnvironmentIdsAtom = Atom.make((get) => + get(workspaceConnections.environmentsAtom) + .filter((environment) => isThreadListV2EnvironmentDisconnected(environment.connectionState)) + .map((environment) => environment.environmentId) + .sort() + .join("\n"), +); + type LayoutInput = Parameters[0]; /** @@ -80,6 +92,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 +155,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 +189,7 @@ export function useThreadListV2(input: { input.settlementEnvironmentIds, input.snoozeEnvironmentIds, input.queuedThreadKeys, + disconnectedEnvironmentIds, input.settledLimit, layoutTime, workingShelfEnabled,