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
43 changes: 43 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
buildThreadListV2Items,
buildThreadListV2ListItems,
getThreadListV2OrderedSection,
isThreadListV2EnvironmentDisconnected,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozePresets,
resolveThreadListV2Status,
Expand Down Expand Up @@ -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" }),
Expand Down Expand Up @@ -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) =>
Expand Down
22 changes: 21 additions & 1 deletion apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell";
import {
canSnooze,
effectiveSnoozed,
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -198,6 +200,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")
);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
}

export function resolveThreadListV2Status(
thread: Pick<
EnvironmentThreadShell,
Expand Down Expand Up @@ -602,6 +614,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 @@ -643,6 +656,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 @@ -693,7 +708,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
31 changes: 31 additions & 0 deletions apps/mobile/src/features/threads/useKeySet.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string>[] = [];
function Probe({ keys }: { readonly keys: ReadonlyArray<string> }) {
const value = useKeySet(keys);
useLayoutEffect(() => {
seen.push(value);
});
return null;
}
const root = createRoot(document.createElement("div"));
try {
await act(async () => root.render(<Probe keys={["a\nb", "c"]} />));
expect(seen.at(-1)).toEqual(new Set(["a\nb", "c"]));
const original = seen.at(-1);
await act(async () => root.render(<Probe keys={["c", "a\nb"]} />));
expect(seen.at(-1)).toBe(original);
await act(async () => root.render(<Probe keys={["a", "b", "c"]} />));
expect(seen.at(-1)).toEqual(new Set(["a", "b", "c"]));
expect(seen.at(-1)).not.toBe(original);
await act(async () => root.render(<Probe keys={[]} />));
expect(seen.at(-1)?.size).toBe(0);
} finally {
act(() => root.unmount());
}
});
7 changes: 7 additions & 0 deletions apps/mobile/src/features/threads/useKeySet.ts
Original file line number Diff line number Diff line change
@@ -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<string>): ReadonlySet<string> {
const serializedKeys = JSON.stringify([...keys].sort());
return useMemo(() => new Set(JSON.parse(serializedKeys) as string[]), [serializedKeys]);
}
31 changes: 19 additions & 12 deletions apps/mobile/src/features/threads/useThreadListV2.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,34 @@
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,
} from "./threadListV2";
import { computeThreadMoveAvailability } from "./threadOrder";
import { useKeySet } from "./useKeySet";

/**
* 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<string> {
return useMemo(
() => new Set(newlineSeparatedKeys.length === 0 ? [] : newlineSeparatedKeys.split("\n")),
[newlineSeparatedKeys],
);
}
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<typeof buildThreadListV2Items>[0];

Expand Down Expand Up @@ -66,6 +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(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.
Expand Down Expand Up @@ -107,6 +113,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,
Expand All @@ -131,7 +138,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
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
];
Expand Down
Loading