1import { useAtomValue } from "@effect/atom-react";
2import type { OrchestrationV2ThreadProjection, ScopedThreadRef } from "@t3tools/contracts";
3import { resolveWorkspaceFilePath } from "@t3tools/shared/path";
4import { Atom } from "effect/reactivity";
5import { createContext, useEffect, useRef, type ReactNode } from "react";
6
7import { projectEnvironment } from "../state/projects";
8import { useAtomCommand } from "../state/use-atom-command";
9
10export const FileMetadataThreadContext = createContext<ScopedThreadRef | null>(null);
11const EMPTY_RETENTION = Atom.make(undefined);
12
13/** The chat view owns this lifetime, so virtualized message rows can unmount
14 * without losing the metadata they already fetched. */
15export function FileMetadataThreadProvider(props: {
16 threadRef: ScopedThreadRef | null;
17 cwd: string | undefined;
18 checkpoints: OrchestrationV2ThreadProjection["checkpoints"] | undefined;
19 children?: ReactNode;
20}) {
21 const { threadRef, cwd, checkpoints, children } = props;
22 useAtomValue(threadRef ? projectEnvironment.fileMetadataThread(threadRef) : EMPTY_RETENTION);
23 const invalidate = useAtomCommand(projectEnvironment.invalidateFileMetadata, {
24 reportFailure: false,
25 });
26 const previous = useRef<{
27 environmentId: ScopedThreadRef["environmentId"];
28 threadId: ScopedThreadRef["threadId"];
29 cwd: string;
30 checkpoints: OrchestrationV2ThreadProjection["checkpoints"];
31 } | null>(null);
32
33 useEffect(() => {
34 if (!threadRef || !cwd || !checkpoints) {
35 previous.current = null;
36 return;
37 }
38 const before = previous.current;
39 previous.current = { ...threadRef, cwd, checkpoints };
40 if (
41 !before ||
42 before.environmentId !== threadRef.environmentId ||
43 before.threadId !== threadRef.threadId ||
44 before.cwd !== cwd
45 )
46 return;
47 const oldById = new Map(before.checkpoints.map((checkpoint) => [checkpoint.id, checkpoint]));
48 const paths = new Set<string>();
49 for (const checkpoint of checkpoints) {
50 const old = oldById.get(checkpoint.id);
51 oldById.delete(checkpoint.id);
52 if (old?.files === checkpoint.files && old?.status === checkpoint.status) continue;
53 for (const file of checkpoint.files) paths.add(resolveWorkspaceFilePath(file.path, cwd));
54 for (const file of old?.files ?? []) paths.add(resolveWorkspaceFilePath(file.path, cwd));
55 }
56 // Rewinds can remove checkpoints as well as add them.
57 for (const checkpoint of oldById.values())
58 for (const file of checkpoint.files) paths.add(resolveWorkspaceFilePath(file.path, cwd));
59 if (paths.size > 0)
60 void invalidate({ environmentId: threadRef.environmentId, input: { paths: [...paths] } });
61 }, [threadRef, cwd, checkpoints, invalidate]);
62
63 return <FileMetadataThreadContext value={threadRef}>{children}</FileMetadataThreadContext>;
64}