Skip to content
25 changes: 24 additions & 1 deletion apps/web/src/components/files/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ import { useAtomCommand } from "~/state/use-atom-command";
import { useAtomQueryRunner } from "~/state/use-atom-query-runner";

import FileBrowserPanel from "./FileBrowserPanel";
import {
checkpointFilesIncludePath,
useCheckpointQueryRefresh,
useCheckpointsSnapshot,
} from "./checkpointFileRefresh";
import {
type FileCommentAnnotationEntry,
type FileCommentAnnotationGroup,
Expand All @@ -61,6 +66,8 @@ import { FileSaveCoordinator } from "./fileSaveCoordinator";
import {
confirmProjectFileQueryData,
getOptimisticProjectFileQueryData,
getProjectEntriesQueryAtom,
getProjectFileQueryAtom,
setProjectFileQueryData,
useProjectFileQuery,
} from "./projectFilesQueryState";
Expand Down Expand Up @@ -783,6 +790,22 @@ export default function FilePreviewPanel({
const isImage = relativePath !== null && isWorkspaceImagePreviewPath(relativePath);
const file = useProjectFileQuery(environmentId, cwd, relativePath, !isImage);
const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);
const checkpointsSnapshot = useCheckpointsSnapshot(threadRef);
const explorerVisible = explorerOpen || relativePath === null;
const refreshScope = `${threadRef.environmentId}:${threadRef.threadId}:${cwd}`;
useCheckpointQueryRefresh(
explorerVisible ? `entries:${refreshScope}` : null,
checkpointsSnapshot,
(changedFiles) => changedFiles.length > 0,
getProjectEntriesQueryAtom(environmentId, cwd),
);
useCheckpointQueryRefresh(
relativePath !== null && !isImage ? `file:${refreshScope}:${relativePath}` : null,
checkpointsSnapshot,
(changedFiles) =>
relativePath !== null && checkpointFilesIncludePath(changedFiles, relativePath),
getProjectFileQueryAtom(environmentId, cwd, relativePath),
);
Comment thread
flamboh marked this conversation as resolved.
// Reading markdown rendered is a preference, not a property of one file. Keeping
// it on the panel meant a thread switch dropped it and forced source back.
const [renderMarkdownPreferred, setRenderMarkdownPreferred] = useLocalStorage(
Expand Down Expand Up @@ -1063,7 +1086,7 @@ export default function FilePreviewPanel({
)
) : null}
</div>
{explorerOpen || relativePath === null ? (
{explorerVisible ? (
<aside
className={cn(
"flex min-h-0 shrink-0 bg-background",
Expand Down
180 changes: 180 additions & 0 deletions apps/web/src/components/files/checkpointFileRefresh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { CheckpointRef, type OrchestrationCheckpointSummary, TurnId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import {
type CheckpointsSnapshot,
buildSnapshot,
checkpointFilesIncludePath,
evaluateScopeRefresh,
} from "./checkpointFileRefresh";

function file(path: string) {
return { path, kind: "modified", additions: 1, deletions: 0 };
}

function snapshot(
maxTurnCount: number,
checkpoints: ReadonlyArray<{
completedAt: string;
files: ReadonlyArray<ReturnType<typeof file>>;
}>,
): CheckpointsSnapshot {
return {
maxTurnCount,
latestCompletedAt: checkpoints.reduce(
(latest, entry) => (entry.completedAt > latest ? entry.completedAt : latest),
"",
),
checkpoints,
};
}

const touchesOpenFile = (files: ReadonlyArray<ReturnType<typeof file>>) =>
checkpointFilesIncludePath(files, "src/main.tsx");

describe("evaluateScopeRefresh", () => {
it("asks to refresh on first observation, since prior freshness is unprovable", () => {
const result = evaluateScopeRefresh(
undefined,
snapshot(2, [{ completedAt: "T2", files: [file("src/main.tsx")] }]),
touchesOpenFile,
);
expect(result.action).toBe("refresh");
expect(result.marker).toEqual({ completedAt: "T2", maxTurnCount: 2 });
});

it("does nothing while checkpoints are unchanged", () => {
const result = evaluateScopeRefresh(
{ completedAt: "T2", maxTurnCount: 2 },
snapshot(2, [{ completedAt: "T2", files: [file("src/main.tsx")] }]),
touchesOpenFile,
);
expect(result.action).toBe("none");
});

it("refreshes when a newer checkpoint touched the scope", () => {
const result = evaluateScopeRefresh(
{ completedAt: "T2", maxTurnCount: 2 },
snapshot(3, [{ completedAt: "T3", files: [file("src/main.tsx")] }]),
touchesOpenFile,
);
expect(result.action).toBe("refresh");
});

it("advances the marker without refreshing when newer checkpoints are irrelevant", () => {
const result = evaluateScopeRefresh(
{ completedAt: "T2", maxTurnCount: 2 },
snapshot(3, [{ completedAt: "T3", files: [file("docs/other.md")] }]),
touchesOpenFile,
);
expect(result.action).toBe("none");
expect(result.marker).toEqual({ completedAt: "T3", maxTurnCount: 3 });
});

it("catches a relevant turn buried under later irrelevant turns", () => {
const result = evaluateScopeRefresh(
{ completedAt: "T2", maxTurnCount: 2 },
snapshot(4, [
{ completedAt: "T2", files: [file("src/main.tsx")] },
{ completedAt: "T3", files: [file("src/main.tsx")] },
{ completedAt: "T4", files: [file("docs/other.md")] },
]),
touchesOpenFile,
);
expect(result.action).toBe("refresh");
});

it("refreshes on a revert, detected as a max turn count drop", () => {
const result = evaluateScopeRefresh(
{ completedAt: "T2", maxTurnCount: 2 },
snapshot(1, [{ completedAt: "T1", files: [] }]),
touchesOpenFile,
);
expect(result.action).toBe("refresh");
});

it("refreshes when reverting away only a mid-turn placeholder", () => {
const result = evaluateScopeRefresh(
{ completedAt: "T2", maxTurnCount: 3 },
snapshot(2, [{ completedAt: "T2", files: [file("src/main.tsx")] }]),
touchesOpenFile,
);
expect(result.action).toBe("refresh");
});

it("refreshes when a revert-then-redo recreated checkpoints under old turn counts", () => {
const result = evaluateScopeRefresh(
{ completedAt: "T3", maxTurnCount: 3 },
snapshot(3, [
{ completedAt: "T1", files: [] },
{ completedAt: "T4", files: [file("src/main.tsx")] },
{ completedAt: "T5", files: [file("docs/other.md")] },
]),
touchesOpenFile,
);
expect(result.action).toBe("refresh");
});
});

describe("buildSnapshot", () => {
function checkpoint(
turnCount: number,
status: OrchestrationCheckpointSummary["status"],
completedAt: string,
files: OrchestrationCheckpointSummary["files"],
): OrchestrationCheckpointSummary {
return {
turnId: TurnId.make(`turn-${turnCount}`),
checkpointTurnCount: turnCount,
checkpointRef: CheckpointRef.make(`ref-${turnCount}`),
status,
files,
assistantMessageId: null,
completedAt,
};
}

it("returns null for an empty history", () => {
expect(buildSnapshot([])).toBeNull();
});

it("scans interrupted and failed captures, whose file lists are real", () => {
const result = buildSnapshot([
checkpoint(1, "ready", "T1", [file("docs/other.md")]),
checkpoint(2, "missing", "T2", [file("src/main.tsx")]),
checkpoint(3, "error", "T3", [file("src/broken.ts")]),
]);
expect(result).toEqual({
maxTurnCount: 3,
latestCompletedAt: "T3",
checkpoints: [
{ completedAt: "T1", files: [file("docs/other.md")] },
{ completedAt: "T2", files: [file("src/main.tsx")] },
{ completedAt: "T3", files: [file("src/broken.ts")] },
],
});
});
});

describe("checkpointFilesIncludePath", () => {
const files = [
{ path: "apps/web/src/main.tsx", kind: "modified", additions: 1, deletions: 0 },
{ path: "packages/shared/util.ts", kind: "modified", additions: 2, deletions: 2 },
];

it("matches an exact cwd-relative path", () => {
expect(checkpointFilesIncludePath(files, "apps/web/src/main.tsx")).toBe(true);
});

it("matches repo-relative paths by suffix when cwd is below the repo root", () => {
expect(checkpointFilesIncludePath(files, "src/main.tsx")).toBe(true);
});

it("does not match a partial file name without a directory boundary", () => {
expect(checkpointFilesIncludePath(files, "ain.tsx")).toBe(false);
});

it("does not match untouched files", () => {
expect(checkpointFilesIncludePath(files, "apps/web/src/other.tsx")).toBe(false);
});
});
139 changes: 139 additions & 0 deletions apps/web/src/components/files/checkpointFileRefresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { useAtomValue } from "@effect/atom-react";
import { parseThreadKey, threadKey } from "@t3tools/client-runtime/state/entities";
import type {
OrchestrationCheckpointFile,
OrchestrationCheckpointSummary,
ScopedThreadRef,
} from "@t3tools/contracts";
import * as Option from "effect/Option";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { useEffect, useRef } from "react";

import { appAtomRegistry } from "~/rpc/atomRegistry";
import { environmentThreadDetails } from "~/state/threads";


interface ScannedCheckpoint {
readonly completedAt: string;
readonly files: ReadonlyArray<OrchestrationCheckpointFile>;
}

export interface CheckpointsSnapshot {
readonly maxTurnCount: number;
readonly latestCompletedAt: string;
readonly checkpoints: ReadonlyArray<ScannedCheckpoint>;
}

export interface ScopeMarker {
readonly completedAt: string;
readonly maxTurnCount: number;
}

export type ScopeAction = "none" | "refresh";

export function evaluateScopeRefresh(
marker: ScopeMarker | undefined,
snapshot: CheckpointsSnapshot,
isRelevant: (files: ReadonlyArray<OrchestrationCheckpointFile>) => boolean,
): { readonly marker: ScopeMarker; readonly action: ScopeAction } {
const nextMarker: ScopeMarker = {
completedAt: snapshot.latestCompletedAt,
maxTurnCount: snapshot.maxTurnCount,
};
if (marker === undefined) {
return { marker: nextMarker, action: "refresh" };
}
if (snapshot.maxTurnCount < marker.maxTurnCount) {
return { marker: nextMarker, action: "refresh" };
}
const relevant = snapshot.checkpoints.some(
(checkpoint) => checkpoint.completedAt > marker.completedAt && isRelevant(checkpoint.files),
Comment thread
flamboh marked this conversation as resolved.
);
return { marker: nextMarker, action: relevant ? "refresh" : "none" };
}

export function checkpointFilesIncludePath(
files: ReadonlyArray<OrchestrationCheckpointFile>,
relativePath: string,
): boolean {
const suffix = `/${relativePath}`;
return files.some((file) => file.path === relativePath || file.path.endsWith(suffix));
}

export function buildSnapshot(
checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>,
): CheckpointsSnapshot | null {
if (checkpoints.length === 0) return null;
let maxTurnCount = 0;
let latestCompletedAt = "";
const scanned: Array<ScannedCheckpoint> = [];
for (const checkpoint of checkpoints) {
if (checkpoint.checkpointTurnCount > maxTurnCount) {
maxTurnCount = checkpoint.checkpointTurnCount;
}
scanned.push({ completedAt: checkpoint.completedAt, files: checkpoint.files });
if (checkpoint.completedAt > latestCompletedAt) {
latestCompletedAt = checkpoint.completedAt;
}
}
return { maxTurnCount, latestCompletedAt, checkpoints: scanned };
}

function snapshotsEqual(a: CheckpointsSnapshot, b: CheckpointsSnapshot): boolean {
return (
a.maxTurnCount === b.maxTurnCount &&
a.latestCompletedAt === b.latestCompletedAt &&
a.checkpoints.length === b.checkpoints.length &&
a.checkpoints.every(
(entry, index) =>
entry.completedAt === b.checkpoints[index]?.completedAt &&
entry.files === b.checkpoints[index]?.files,
)
);
}

const SNAPSHOT_IDLE_TTL_MS = 5 * 60_000;

const checkpointsSnapshotAtomFamily = Atom.family((key: string) => {
const ref = parseThreadKey(key);
let previous: CheckpointsSnapshot | null = null;
return Atom.make((get): CheckpointsSnapshot | null => {
const next = buildSnapshot(get(environmentThreadDetails.checkpointsAtom(ref)));
if (previous !== null && next !== null && snapshotsEqual(previous, next)) {
return previous;
}
previous = next;
return next;
}).pipe(Atom.setIdleTTL(SNAPSHOT_IDLE_TTL_MS), Atom.withLabel(`checkpoints-snapshot:${key}`));
});

export function useCheckpointsSnapshot(threadRef: ScopedThreadRef): CheckpointsSnapshot | null {
return useAtomValue(checkpointsSnapshotAtomFamily(threadKey(threadRef)));
}

const reconciledScopes = new Map<string, ScopeMarker>();

export function useCheckpointQueryRefresh<A, E>(
scopeKey: string | null,
snapshot: CheckpointsSnapshot | null,
isRelevant: (files: ReadonlyArray<OrchestrationCheckpointFile>) => boolean,
queryAtom: Atom.Atom<AsyncResult.AsyncResult<A, E>>,
): void {
const currentIsRelevant = useRef(isRelevant);
currentIsRelevant.current = isRelevant;
const result = useAtomValue(queryAtom);

useEffect(() => {
if (scopeKey === null || snapshot === null) return;
const marker = reconciledScopes.get(scopeKey);
const decision = evaluateScopeRefresh(marker, snapshot, currentIsRelevant.current);
const firstFetchInFlight = result.waiting && Option.isNone(AsyncResult.value(result));
if (decision.action === "refresh" && marker !== undefined && firstFetchInFlight) {
return;
}
reconciledScopes.set(scopeKey, decision.marker);
if (decision.action === "none") return;
if (firstFetchInFlight) return;
Comment thread
flamboh marked this conversation as resolved.
Outdated
appAtomRegistry.refresh(queryAtom);
}, [scopeKey, snapshot, result, queryAtom]);
}
Loading