Skip to content
Merged
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
24 changes: 17 additions & 7 deletions apps/mobile/src/state/entities.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vite-plus/test";
import { Atom, AtomRegistry } from "effect/reactivity";
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts";

vi.mock("./atom-registry", () => ({ appAtomRegistry: AtomRegistry.make() }));
vi.mock("./projects", () => ({ environmentProjects: {} }));
Expand All @@ -18,11 +18,15 @@ vi.mock("../native/ProjectChipIconImages", () => ({
}));
vi.mock("./threads", () => {
const shell = Atom.make<{ title: string } | null>(null);
const absent = Atom.make(null);
return {
environmentThreadDetails: {},
environmentThreadShells: { threadShellAtom: () => shell },
environmentThreadShells: {
threadShellAtom: (ref: ScopedThreadRef) => (ref.threadId === childId ? shell : absent),
},
threadFamilies: {
mount: (registry: AtomRegistry.AtomRegistry) => {
mount: (registry: AtomRegistry.AtomRegistry, _env: EnvironmentId, id: ThreadId) => {
if (id !== childId) return () => {};
registry.set(shell, { title: "Child result" });
return () => registry.set(shell, null);
},
Expand All @@ -31,9 +35,11 @@ vi.mock("./threads", () => {
});

import { appAtomRegistry } from "./atom-registry";
import { useLiveThreadLinkLabels } from "./entities";
import { resolveThreadLink, useLiveThreadLinkLabels } from "./entities";
import { environmentThreadShells } from "./threads";

const childId = ThreadId.make("thread:delegated-task:command%3Amcp%3Achild");

it("keeps a linked child title mounted until the message stops citing it", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const container = document.createElement("div");
Expand All @@ -47,11 +53,15 @@ it("keeps a linked child title mounted until the message stops citing it", async
await act(async () =>
root.render(
<RegistryContext.Provider value={appAtomRegistry}>
<Probe text="[Child](t3-thread://v2/local/child)" />
<Probe text={`<t3-thread://v2/local/${childId}>`} />
</RegistryContext.Provider>,
),
);
expect(container.textContent).toBe("[Child result](t3-thread://v2/local/child)");
expect(container.textContent).toBe(`[Child result](t3-thread://v2/local/${childId})`);
const target = { environmentId: EnvironmentId.make("local"), threadId: childId };
expect(
resolveThreadLink((atom) => appAtomRegistry.get(atom), target, target.environmentId),
).toEqual(target);
expect(renders).toBeLessThan(20);
await act(async () =>
root.render(
Expand All @@ -65,7 +75,7 @@ it("keeps a linked child title mounted until the message stops citing it", async
appAtomRegistry.get(
environmentThreadShells.threadShellAtom({
environmentId: EnvironmentId.make("local"),
threadId: ThreadId.make("child"),
threadId: childId,
}),
),
).toBeNull();
Expand Down
16 changes: 7 additions & 9 deletions apps/mobile/src/state/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,19 +210,17 @@ export function useSideChats(ref: ScopedThreadRef) {

export function useLiveThreadLinkLabels(markdown: string, environmentId: EnvironmentId): string {
const refs = hasThreadLinks(markdown) ? threadLinkRefs(markdown, environmentId) : [];
const refsKey = JSON.stringify(refs.map(threadKey));
const titles = useAtomValue(
refs.length === 0
? EMPTY_THREAD_LINK_TITLES_ATOM
: threadLinkTitlesAtom(JSON.stringify(refs.map(threadKey))),
refs.length === 0 ? EMPTY_THREAD_LINK_TITLES_ATOM : threadLinkTitlesAtom(refsKey),
);
// Shells leave out idle subagent children and older settled threads, so a cited thread the
// shell lacks is read with its family until it lands; only a settled empty read proves it gone.
const refsKey = JSON.stringify(refs.map(threadKey));
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- keeps cited families mounted until the links change; releasing on title arrival removes that title again
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- keeps cited rows mounted for the message's lifetime, independently of the family data it loads
useEffect(() => {
const releases = (JSON.parse(refsKey) as ReadonlyArray<string>).map((key) => {
const releases = decodeThreadKeys(JSON.parse(refsKey)).map((key) => {
const ref = scopeThreadRef(...decodeThreadKey(JSON.parse(key)));
return threadFamilies.mount(appAtomRegistry, ref.environmentId, ref.threadId);
return threadFamilies.mount(appAtomRegistry, ref.environmentId, ref.threadId, {
onlyWhenMissing: true,
});
});
return () => {
for (const release of releases) release();
Expand Down
52 changes: 52 additions & 0 deletions apps/web/src/components/chat/MarkdownThreadLink.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { RegistryContext, useAtomValue } from "@effect/atom-react";
import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts";
import { Atom } from "effect/reactivity";
import { expect, it, vi } from "vite-plus/test";

import { render } from "../../../test/render";
import { appAtomRegistry } from "../../rpc/atomRegistry";
import { threadFamilies } from "../../state/threads";
import { MarkdownThreadLink } from "./MarkdownThreadLink";
import "../../index.css";

const environmentId = EnvironmentId.make("local");
const threadId = ThreadId.make("thread:delegated-task:command%3Amcp%3Achild");
const child = { environmentId, id: threadId, title: "Code store foundation" };
const familyLoaded = Atom.make(false);

vi.mock("../../state/entities", async (original) => ({
...(await original<typeof import("../../state/entities")>()),
useThreadShell: (ref: ScopedThreadRef | null) => {
const loaded = useAtomValue(familyLoaded);
return loaded && ref?.environmentId === environmentId && ref.threadId === threadId
? child
: null;
},
useThreadRefs: () => [],
useProject: () => null,
}));

it("opens a cited idle child's literal ID and keeps its title until the citation unmounts", async () => {
using _family = vi.spyOn(threadFamilies, "mount").mockImplementation((registry, env, id) => {
if (env !== environmentId || id !== threadId) return () => {};
registry.set(familyLoaded, true);
return () => registry.set(familyLoaded, false);
});
await using screen = await render(
<RegistryContext value={appAtomRegistry}>
<p>
The repairs are verified in{" "}
<MarkdownThreadLink
environmentId={environmentId}
link={{ environmentId, threadId }}
label={null}
/>
.
</p>
</RegistryContext>,
);
const link = screen.getByRole("link", { name: "Thread, Code store foundation" });
await expect.element(link).toHaveAttribute("href", `/local/${encodeURIComponent(threadId)}`);
await screen.unmount();
expect(appAtomRegistry.get(familyLoaded)).toBe(false);
});
22 changes: 11 additions & 11 deletions apps/web/src/components/chat/MarkdownThreadLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,17 @@ export function MarkdownThreadLink(props: {
written === null && decodedId !== null ? scopeThreadRef(environmentId, decodedId) : null,
);
const local = written ?? decoded;
// Shells leave out idle subagent children and older settled threads, so a cited thread the
// shell lacks is read with its family until it lands; only a settled empty read proves it gone.
const missingThreadId = local === null ? (decodedId ?? props.link.threadId) : null;
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- mounts the family read for a cited thread the shell omits while this link shows it
useEffect(
() =>
missingThreadId === null
? undefined
: threadFamilies.mount(appAtomRegistry, environmentId, missingThreadId),
[environmentId, missingThreadId],
);
const threadId = props.link.threadId;
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- keeps cited rows mounted for the link's lifetime, independently of the family data it loads
useEffect(() => {
const ids = decodedId === null ? [threadId] : [threadId, decodedId];
const releases = ids.map((id) =>
threadFamilies.mount(appAtomRegistry, environmentId, id, { onlyWhenMissing: true }),
);
return () => {
for (const release of releases) release();
};
}, [environmentId, threadId, decodedId]);
const guess = local === null && props.link.environmentId === null;
const refs = useThreadRefs(guess);
const remote = useThreadShell(guess ? guessThreadLinkRef(props.link, refs) : null);
Expand Down
91 changes: 90 additions & 1 deletion packages/client-runtime/src/state/threadFamilies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,12 @@ describe("createThreadFamilyAtoms", () => {
yield* Queue.unbounded<
Effect.Effect<OrchestrationV2ThreadFamily, OrchestrationV2GetShellSnapshotError>
>();
const requests: ThreadId[] = [];
const client = {
[ORCHESTRATION_V2_WS_METHODS.getThreadFamily]: () => Effect.flatten(Queue.take(replies)),
[ORCHESTRATION_V2_WS_METHODS.getThreadFamily]: ({ threadId }: { threadId: ThreadId }) =>
Effect.sync(() => requests.push(threadId)).pipe(
Effect.andThen(Effect.flatten(Queue.take(replies))),
),
} as unknown as WsRpcProtocolClient;
const session = {
client,
Expand Down Expand Up @@ -231,6 +235,91 @@ describe("createThreadFamilyAtoms", () => {
yield* settle;
expect(threadFamilyRead(registry.get(family))).toBe("settled");
expect(mergedIds()).toEqual(["parent", "idle-child"]);

const visible = row("visible-citation");
const omitted = row("omitted-citation");
registry.set(
shellState,
AsyncResult.success({
...shellAt(2),
snapshot: Option.some({ ...shellSnapshot, threads: [parent, visible] }),
}),
);
const releaseVisible = families.mount(registry, TARGET.environmentId, visible.id, {
onlyWhenMissing: true,
});
const releaseOmitted = families.mount(registry, TARGET.environmentId, omitted.id, {
onlyWhenMissing: true,
});
yield* Queue.offerAll(replies, [
Effect.succeed({ snapshotSequence: 5, threads: [omitted] }),
Effect.succeed({ snapshotSequence: 5, threads: [visible] }),
]);
yield* AtomRegistry.getResult(
registry,
families.familyAtom(TARGET.environmentId, omitted.id),
{ suspendOnWaiting: true },
);
expect(requests).toEqual([parent.id, parent.id, parent.id, omitted.id]);
expect(mergedIds()).toEqual([
"parent",
"visible-citation",
"idle-child",
"omitted-citation",
]);

registry.set(shellState, AsyncResult.success(shellAt(2)));
yield* AtomRegistry.getResult(
registry,
families.familyAtom(TARGET.environmentId, visible.id),
{ suspendOnWaiting: true },
);
expect(requests).toEqual([parent.id, parent.id, parent.id, omitted.id, visible.id]);
expect(mergedIds()).toEqual([
"parent",
"idle-child",
"visible-citation",
"omitted-citation",
]);
releaseVisible();
releaseOmitted();
expect(mergedIds()).toEqual(["parent", "idle-child"]);

const unavailable = row("cached-failure");
const unavailableFamily = families.familyAtom(TARGET.environmentId, unavailable.id);
const releaseFailed = families.mount(registry, TARGET.environmentId, unavailable.id);
yield* Queue.offer(
replies,
Effect.fail(new OrchestrationV2GetShellSnapshotError({ message: "read failed" })),
);
yield* AtomRegistry.getResult(registry, unavailableFamily, {
suspendOnWaiting: true,
}).pipe(Effect.ignore);
expect(threadFamilyRead(registry.get(unavailableFamily))).toBe("failed");
releaseFailed();
registry.set(
shellState,
AsyncResult.success({
...shellAt(2),
snapshot: Option.some({ ...shellSnapshot, threads: [parent, unavailable] }),
}),
);
const releaseRetry = families.mount(registry, TARGET.environmentId, unavailable.id, {
onlyWhenMissing: true,
});
expect(requests.filter((id) => id === unavailable.id)).toHaveLength(1);
registry.set(shellState, AsyncResult.success(shellAt(2)));
yield* settle;
expect(threadFamilyRead(registry.get(unavailableFamily))).toBe("pending");
yield* Queue.offer(
replies,
Effect.succeed({ snapshotSequence: 5, threads: [unavailable] }),
);
yield* AtomRegistry.getResult(registry, unavailableFamily, { suspendOnWaiting: true });
expect(requests.filter((id) => id === unavailable.id)).toHaveLength(2);
expect(mergedIds()).toEqual(["parent", "idle-child", "cached-failure"]);
releaseRetry();
expect(mergedIds()).toEqual(["parent", "idle-child"]);
}),
),
);
Expand Down
57 changes: 52 additions & 5 deletions packages/client-runtime/src/state/threadFamilies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,26 @@ export function createThreadFamilyAtoms<R, ER, E>(
}).pipe(Atom.withLabel(`environment-thread-family-rows:${key}`)),
);

const shellThreadIdsAtom = Atom.family((environmentId: EnvironmentId) => {
let previous: ReadonlyArray<OrchestrationV2ThreadShell> | undefined;
let ids: ReadonlySet<ThreadId> = new Set();
return Atom.make((get) => {
const state = shellStateValue(get(shellStateAtom(environmentId)));
const threads = state === null ? undefined : Option.getOrNull(state.snapshot)?.threads;
if (threads !== previous) {
previous = threads;
ids = new Set(threads?.map(({ id }) => id));
}
return ids;
}).pipe(Atom.withLabel(`environment-shell-thread-ids:${environmentId}`));
});
const missingFamilyRowsAtom = Atom.family((key: string) => {
const [environmentId, threadId] = JSON.parse(key) as [EnvironmentId, ThreadId];
return Atom.make((get) =>
get(shellThreadIdsAtom(environmentId)).has(threadId) ? EMPTY_READS : get(familyRowsAtom(key)),
).pipe(Atom.withLabel(`environment-missing-thread-family-rows:${key}`));
});

/**
* Merges `rows` behind the shell rows until the returned release runs. Reads read under `key`
* share one entry; each read's `snapshotSequence` decides which removals drop its rows.
Expand Down Expand Up @@ -186,14 +206,41 @@ export function createThreadFamilyAtoms<R, ER, E>(
registry: AtomRegistry.AtomRegistry,
environmentId: EnvironmentId,
threadId: ThreadId,
options: { readonly onlyWhenMissing?: boolean } = {},
): (() => void) => {
const key = familyKey(environmentId, threadId);
const release = mountRows(registry, environmentId, `family:${key}`, familyRowsAtom(key));
// Mounting again after a failure retries instead of keeping the failure until reconnect.
if (threadFamilyRead(registry.get(familyAtom(key))) === "failed") {
refresh(registry, environmentId, threadId);
const onlyWhenMissing = options.onlyWhenMissing === true;
const release = mountRows(
registry,
environmentId,
`${onlyWhenMissing ? "missing-family" : "family"}:${key}`,
onlyWhenMissing ? missingFamilyRowsAtom(key) : familyRowsAtom(key),
);
const retryFailed = () => {
if (threadFamilyRead(registry.get(familyAtom(key))) === "failed") {
refresh(registry, environmentId, threadId);
}
};
if (!onlyWhenMissing) {
retryFailed();
return release;
}
return release;
let wasMissing = false;
// A cached failure must retry when the shell first omits a still-mounted citation.
const unsubscribe = registry.subscribe(
shellThreadIdsAtom(environmentId),
(ids) => {
const missing = !ids.has(threadId);
const becameMissing = missing && !wasMissing;
wasMissing = missing;
if (becameMissing) retryFailed();
},
{ immediate: true },
);
return () => {
unsubscribe();
release();
};
};

const snapshotAtom = Atom.family((environmentId: EnvironmentId) => {
Expand Down
Loading