Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
buildThreadListV2Items,
buildThreadListV2ListItems,
getThreadListV2OrderedSection,
isThreadListV2EnvironmentDisconnected,
layoutThreadListV2RowMenuActions,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SwipeActions,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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) =>
Expand Down
18 changes: 17 additions & 1 deletion apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -661,6 +669,7 @@ export function buildThreadListV2Items(input: {
readonly matchedThreadKeys?: ReadonlySet<string>;
readonly settlementEnvironmentIds?: ReadonlySet<EnvironmentId>;
readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
readonly disconnectedEnvironmentIds?: ReadonlySet<string>;
readonly settledLimit?: number;
readonly now: string;
readonly workingShelfEnabled?: boolean;
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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) &&
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
(input.isWorking ?? isThreadWorking)(thread)
) {
working.push(thread);
} else {
active.push(thread);
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/features/threads/useThreadListV2.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<ReadonlyMap<EnvironmentId, OlderSettledShelfEnvironment>>(new Map());
vi.mocked(olderSettledThreads.shelfAtom).mockReturnValue(shelfAtom);
Expand Down
15 changes: 15 additions & 0 deletions apps/mobile/src/features/threads/useThreadListV2.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -26,6 +30,14 @@ function useKeySet(newlineSeparatedKeys: string): ReadonlySet<string> {
);
}

const disconnectedEnvironmentIdsAtom = Atom.make((get) =>
get(workspaceConnections.environmentsAtom)
.filter((environment) => isThreadListV2EnvironmentDisconnected(environment.connectionState))
.map((environment) => environment.environmentId)
.sort()
.join("\n"),
);

type LayoutInput = Parameters<typeof buildThreadListV2Items>[0];

/**
Expand Down Expand Up @@ -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") =>
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -175,6 +189,7 @@ export function useThreadListV2(input: {
input.settlementEnvironmentIds,
input.snoozeEnvironmentIds,
input.queuedThreadKeys,
disconnectedEnvironmentIds,
input.settledLimit,
layoutTime,
workingShelfEnabled,
Expand Down
Loading