Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { AsyncResult, Atom } from "effect/reactivity";
import { describe, expect, it, vi } from "vite-plus/test";

type FamilyRead = AsyncResult.AsyncResult<
{ readonly threads: ReadonlyArray<{ readonly id: string }> },
Error
>;

const harness = await vi.hoisted(async () => {
const { AsyncResult, Atom } = await import("effect/reactivity");
return {
family: Atom.make<FamilyRead>(AsyncResult.initial(true)).pipe(Atom.keepAlive),
mounts: Atom.make<ReadonlyArray<string>>([]).pipe(Atom.keepAlive),
};
});

vi.mock("react-native", () => ({
Platform: { OS: "ios", isPad: true },
Dimensions: { get: () => ({ width: 1024, height: 768 }) },
AppState: { addEventListener: () => undefined },
}));
vi.mock("expo-linking", () => ({ createURL: (path: string) => `t3code://${path}` }));
vi.mock("../../../persistence/imperative", () => ({
loadPreferences: () => Promise.resolve({}),
savePreferencesPatch: () => Promise.resolve(),
}));
vi.mock("../../../native/native-layout-metrics", () => ({
currentNativeLayoutMetrics: () => null,
firstNativeLayoutMetrics: () => Promise.resolve(null),
}));
vi.mock("../../../connection/catalog", () => ({
environmentCatalog: { catalogValueAtom: Atom.make({ isReady: true }) },
}));
vi.mock("../../../state/presentation", () => ({
environmentPresentations: { presentationsAtom: Atom.make(new Map([["env", {}]])) },
}));
// A loaded snapshot whose shell rows leave the remembered chat out. As in `threadFamilies`, a
// mounted family's settled rows join the thread index.
vi.mock("../../../state/threads", () => ({
startOpeningEnvironmentThread: () => undefined,
threadEnvironment: { snapshotAtom: () => Atom.make({ threads: [] }) },
environmentThreadShells: {
environmentThreadIndexAtom: (environmentId: string) =>
Atom.make((get) => {
const family = get(harness.family);
const rows =
get(harness.mounts).includes(`${environmentId}/thread-a`) && AsyncResult.isSuccess(family)
? family.value.threads
: [];
return new Map(rows.map((row) => [row.id, row]));
}),
},
threadFamilies: {
familyAtom: () => harness.family,
mount: (registry: typeof appAtomRegistry, environmentId: string, threadId: string) => {
const key = `${environmentId}/${threadId}`;
registry.set(harness.mounts, [...registry.get(harness.mounts), key]);
return () =>
registry.set(
harness.mounts,
registry.get(harness.mounts).filter((mounted) => mounted !== key),
);
},
// A refresh puts the read back in flight, as `registry.refresh` does on the real query.
refresh: (registry: typeof appAtomRegistry) =>
registry.set(harness.family, AsyncResult.waiting(registry.get(harness.family))),
},
}));

import { appAtomRegistry } from "../../../state/atom-registry";
import { recordWorkspaceSelections, workspaceSelectionCheck } from "./workspace-selection-store";

function remember(threadId: string) {
recordWorkspaceSelections({
stale: false,
type: "stack",
key: "root",
index: 0,
routeNames: ["Thread"],
routes: [
{ key: `Thread-${threadId}`, name: "Thread", params: { environmentId: "env", threadId } },
],
});
}

describe("workspaceSelectionCheck.thread", () => {
it.each([
{
name: "family read pending",
family: AsyncResult.initial(true),
expected: true,
readInFlight: true,
},
{
name: "family read failed",
family: AsyncResult.fail(new Error("read failed")),
expected: true,
readInFlight: true,
},
{
name: "settled family holds the chat",
family: AsyncResult.success({ threads: [{ id: "thread-a" }] }),
expected: true,
readInFlight: false,
},
{
name: "settled family without the chat",
family: AsyncResult.success({ threads: [] }),
expected: false,
readInFlight: false,
},
])("remembered chat missing from the shell rows: $name", (row) => {
remember("thread-a");
appAtomRegistry.set(harness.family, row.family);
expect(workspaceSelectionCheck.thread({ environmentId: "env", threadId: "thread-a" })).toBe(
row.expected,
);
expect(appAtomRegistry.get(harness.family).waiting).toBe(row.readInFlight);
});

it("keeps only the remembered chat's family mounted", () => {
remember("thread-a");
remember("thread-b");
expect(appAtomRegistry.get(harness.mounts)).toEqual(["env/thread-b"]);
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { NavigationState } from "@react-navigation/native";
import { threadFamilyRead } from "@t3tools/client-runtime/state/threads";
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import * as Linking from "expo-linking";
import { AppState, Dimensions, Platform } from "react-native";
Expand All @@ -23,6 +24,7 @@ import {
environmentThreadShells,
startOpeningEnvironmentThread,
threadEnvironment,
threadFamilies,
} from "../../../state/threads";
import {
rememberedSettingsPage,
Expand All @@ -38,6 +40,34 @@ const REMEMBERS = Platform.OS === "ios";
let selections: WorkspaceSelections = {};
let saved = selections;
let hydration: Promise<void> | null = null;
let rememberedFamily: {
readonly selection: NonNullable<WorkspaceSelections["threads"]>;
readonly release: () => void;
} | null = null;

// A shell may leave the remembered chat out (older settled, idle subagent). Its mounted family
// keeps the chat's row in the thread index while it exists, so the check can trust that index.
function mountRememberedFamily() {
const selection = selections.threads;
const mounted = rememberedFamily?.selection;
if (
selection?.environmentId === mounted?.environmentId &&
selection?.threadId === mounted?.threadId
) {
return;
}
rememberedFamily?.release();
rememberedFamily = selection
? {
selection,
release: threadFamilies.mount(
appAtomRegistry,
selection.environmentId as EnvironmentId,
selection.threadId as ThreadId,
),
}
: null;
}

function save() {
const writing = selections;
Expand Down Expand Up @@ -70,6 +100,7 @@ export function hydrateWorkspaceSelections() {
.then((preferences) => {
selections = { ...withoutRouteKeys(preferences.workspaceSelections), ...selections };
saved = selections;
mountRememberedFamily();
AppState.addEventListener("change", (status) => {
if (status !== "active") save();
});
Expand All @@ -89,6 +120,7 @@ export function currentWorkspaceSelections() {
export function recordWorkspaceSelections(state: NavigationState | undefined) {
if (!REMEMBERS || !state) return;
selections = workspaceSelectionsIn(state.routes.slice(0, state.index + 1), selections);
mountRememberedFamily();
}

function environmentRemoved(environmentId: string) {
Expand All @@ -103,16 +135,28 @@ function environmentRemoved(environmentId: string) {

/**
* A remembered chat is gone once its environment was removed, or once that environment's
* snapshot has loaded without it. Before it loads there is nothing to contradict it.
* snapshot has loaded without it and the current shell's read of its family has settled. Before
* then the chat's own page decides.
*/
export const workspaceSelectionCheck: WorkspaceSelectionCheck = {
thread: (selection) => {
if (environmentRemoved(selection.environmentId)) return false;
const environmentId = selection.environmentId as EnvironmentId;
const threadId = selection.threadId as ThreadId;
if (!appAtomRegistry.get(threadEnvironment.snapshotAtom(environmentId))) return true;
return appAtomRegistry
.get(environmentThreadShells.environmentThreadIndexAtom(environmentId))
.has(selection.threadId as ThreadId);
if (
appAtomRegistry
.get(environmentThreadShells.environmentThreadIndexAtom(environmentId))
.has(threadId)
) {
return true;
}
const read = threadFamilyRead(
appAtomRegistry.get(threadFamilies.familyAtom(environmentId, threadId)),
);
// Otherwise a failed read would keep reopening the chat until the next shell.
if (read === "failed") threadFamilies.refresh(appAtomRegistry, environmentId, threadId);
return read !== "settled";
},
pullRequest: (selection) => !environmentRemoved(selection.environmentId),
};
Expand Down
Loading