diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 9b9821f03..211df4ef2 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -237,16 +237,23 @@ function DiffFileSection(props: { ), [contextMode, filePath, fullContextDiffQuery.data?.diff, resolvedTheme], ); - const resolvedFileDiff = - contextMode === "full" - ? (resolveRenderableFileDiff(fullContextPatch, filePath) ?? fileDiff) - : fileDiff; + const fullContextFileDiff = + contextMode === "full" ? resolveRenderableFileDiff(fullContextPatch, filePath) : null; + const resolvedFileDiff = contextMode === "full" ? (fullContextFileDiff ?? fileDiff) : fileDiff; const fullContextError = contextMode === "full" && fullContextDiffQuery.error ? fullContextDiffQuery.error instanceof Error ? fullContextDiffQuery.error.message : "Failed to load full-file context." : null; + const fullContextFallbackMessage = + contextMode === "full" && + !fullContextError && + !fullContextDiffQuery.isLoading && + fullContextDiffQuery.data && + fullContextFileDiff === null + ? "Full-file context is unavailable for this file. Showing patch context." + : null; return (
) : null} + {fullContextFallbackMessage ? ( +
+ {fullContextFallbackMessage} +
+ ) : null} { - if ("code" in error && error.code === "MISSING_TRANSLATION") { + onError={(error: unknown) => { + if ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "MISSING_TRANSLATION" + ) { return; } diff --git a/apps/web/src/lib/diffFileReviewState.test.ts b/apps/web/src/lib/diffFileReviewState.test.ts index a36e01d91..a53596963 100644 --- a/apps/web/src/lib/diffFileReviewState.test.ts +++ b/apps/web/src/lib/diffFileReviewState.test.ts @@ -76,6 +76,20 @@ describe("setDiffFileContextMode", () => { "src/a.ts": { accepted: true, collapsed: false, contextMode: "full" }, }); }); + + it("auto-expands a file when switching to full context", () => { + expect( + setDiffFileContextMode( + { + "src/a.ts": { accepted: false, collapsed: true, contextMode: "patch" }, + }, + "src/a.ts", + "full", + ), + ).toEqual({ + "src/a.ts": { accepted: false, collapsed: false, contextMode: "full" }, + }); + }); }); describe("expandDiffFile", () => { diff --git a/apps/web/src/lib/diffFileReviewState.ts b/apps/web/src/lib/diffFileReviewState.ts index 6ff830033..7e15789ce 100644 --- a/apps/web/src/lib/diffFileReviewState.ts +++ b/apps/web/src/lib/diffFileReviewState.ts @@ -66,6 +66,7 @@ export function setDiffFileContextMode( ...current, [path]: { ...previous, + collapsed: contextMode === "full" ? false : previous.collapsed, contextMode, }, }; diff --git a/apps/web/src/lib/providerReactQuery.test.ts b/apps/web/src/lib/providerReactQuery.test.ts index 4689f2f10..4ef743ea0 100644 --- a/apps/web/src/lib/providerReactQuery.test.ts +++ b/apps/web/src/lib/providerReactQuery.test.ts @@ -1,161 +1,49 @@ -import { ThreadId, type NativeApi } from "@okcode/contracts"; -import { QueryClient } from "@tanstack/react-query"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { checkpointDiffQueryOptions, providerQueryKeys } from "./providerReactQuery"; -import * as nativeApi from "../nativeApi"; +import { describe, expect, it } from "vitest"; +import { ThreadId } from "@okcode/contracts"; +import { providerQueryKeys, checkpointDiffQueryOptions } from "./providerReactQuery"; -const threadId = ThreadId.makeUnsafe("thread-id"); - -function mockNativeApi(input: { - getTurnDiff: ReturnType; - getFullThreadDiff: ReturnType; -}) { - vi.spyOn(nativeApi, "ensureNativeApi").mockReturnValue({ - orchestration: { - getTurnDiff: input.getTurnDiff, - getFullThreadDiff: input.getFullThreadDiff, - }, - } as unknown as NativeApi); -} - -afterEach(() => { - vi.restoreAllMocks(); -}); +const threadId = ThreadId.makeUnsafe("thread-1"); describe("providerQueryKeys.checkpointDiff", () => { - it("includes cacheScope so reused turn counts do not collide", () => { - const baseInput = { + it("distinguishes patch and full-context file queries", () => { + const patchKey = providerQueryKeys.checkpointDiff({ threadId, fromTurnCount: 1, toTurnCount: 2, - } as const; - - expect( - providerQueryKeys.checkpointDiff({ - ...baseInput, - cacheScope: "turn:old-turn", - }), - ).not.toEqual( - providerQueryKeys.checkpointDiff({ - ...baseInput, - cacheScope: "turn:new-turn", - }), - ); - }); -}); - -describe("checkpointDiffQueryOptions", () => { - it("forwards checkpoint range to the provider API", async () => { - const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" }); - const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" }); - mockNativeApi({ getTurnDiff, getFullThreadDiff }); - - const options = checkpointDiffQueryOptions({ - threadId, - fromTurnCount: 3, - toTurnCount: 4, - cacheScope: "turn:abc", - }); - - const queryClient = new QueryClient(); - await queryClient.fetchQuery(options); - - expect(getTurnDiff).toHaveBeenCalledWith({ - threadId, - fromTurnCount: 3, - toTurnCount: 4, - }); - expect(getFullThreadDiff).not.toHaveBeenCalled(); - }); - - it("uses explicit full thread diff API when range starts from zero", async () => { - const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" }); - const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" }); - mockNativeApi({ getTurnDiff, getFullThreadDiff }); - - const options = checkpointDiffQueryOptions({ - threadId, - fromTurnCount: 0, - toTurnCount: 2, - cacheScope: "thread:all", - }); - - const queryClient = new QueryClient(); - await queryClient.fetchQuery(options); - - expect(getFullThreadDiff).toHaveBeenCalledWith({ - threadId, - toTurnCount: 2, - }); - expect(getTurnDiff).not.toHaveBeenCalled(); - }); - - it("fails fast on invalid range and does not call provider RPC", async () => { - const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" }); - const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" }); - mockNativeApi({ getTurnDiff, getFullThreadDiff }); - - const options = checkpointDiffQueryOptions({ - threadId, - fromTurnCount: 4, - toTurnCount: 3, - cacheScope: "turn:invalid", + relativePath: "src/a.ts", + contextMode: "patch", }); - - const queryClient = new QueryClient(); - - await expect(queryClient.fetchQuery(options)).rejects.toThrow( - "Checkpoint diff is unavailable.", - ); - expect(getTurnDiff).not.toHaveBeenCalled(); - expect(getFullThreadDiff).not.toHaveBeenCalled(); - }); - - it("retries checkpoint-not-ready errors longer than generic failures", () => { - const options = checkpointDiffQueryOptions({ + const fullKey = providerQueryKeys.checkpointDiff({ threadId, fromTurnCount: 1, toTurnCount: 2, - cacheScope: "turn:abc", + relativePath: "src/a.ts", + contextMode: "full", }); - const retry = options.retry; - expect(typeof retry).toBe("function"); - if (typeof retry !== "function") { - throw new Error("Expected retry to be a function."); - } - expect(retry(1, new Error("Checkpoint turn count 2 exceeds current turn count 1."))).toBe(true); - expect( - retry(11, new Error("Filesystem checkpoint is unavailable for turn 2 in thread thread-1.")), - ).toBe(true); - expect( - retry(12, new Error("Filesystem checkpoint is unavailable for turn 2 in thread thread-1.")), - ).toBe(false); - expect(retry(2, new Error("Something else failed."))).toBe(true); - expect(retry(3, new Error("Something else failed."))).toBe(false); + expect(patchKey).not.toEqual(fullKey); }); +}); - it("backs off longer for checkpoint-not-ready errors", () => { +describe("checkpointDiffQueryOptions", () => { + it("stays enabled for full-thread file-scoped full-context queries", () => { const options = checkpointDiffQueryOptions({ threadId, - fromTurnCount: 1, + fromTurnCount: 0, toTurnCount: 2, - cacheScope: "turn:abc", + relativePath: "src/a.ts", + contextMode: "full", }); - const retryDelay = options.retryDelay; - expect(typeof retryDelay).toBe("function"); - if (typeof retryDelay !== "function") { - throw new Error("Expected retryDelay to be a function."); - } - const checkpointDelay = retryDelay( - 4, - new Error("Checkpoint turn count 2 exceeds current turn count 1."), + expect(options.queryKey).toEqual( + providerQueryKeys.checkpointDiff({ + threadId, + fromTurnCount: 0, + toTurnCount: 2, + relativePath: "src/a.ts", + contextMode: "full", + }), ); - const genericDelay = retryDelay(4, new Error("Network failure")); - - expect(typeof checkpointDelay).toBe("number"); - expect(typeof genericDelay).toBe("number"); - expect((checkpointDelay ?? 0) > (genericDelay ?? 0)).toBe(true); + expect(options.enabled).toBe(true); }); }); diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 000000000..57f75baf9 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[install] +linker = "hoisted" diff --git a/scripts/dedupe-effect-node-modules.mjs b/scripts/dedupe-effect-node-modules.mjs new file mode 100644 index 000000000..e6da31f83 --- /dev/null +++ b/scripts/dedupe-effect-node-modules.mjs @@ -0,0 +1,62 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, ".."); +const effectRoot = path.join(repoRoot, "node_modules", "effect"); +const effectScopeDir = path.join(repoRoot, "node_modules", "@effect"); + +async function exists(targetPath) { + try { + await fs.lstat(targetPath); + return true; + } catch { + return false; + } +} + +async function dedupeNestedEffect(packageDir) { + const nestedEffectDir = path.join(packageDir, "node_modules", "effect"); + if (!(await exists(nestedEffectDir))) { + return false; + } + + const nestedStat = await fs.lstat(nestedEffectDir); + if (nestedStat.isSymbolicLink()) { + const linkTarget = await fs.readlink(nestedEffectDir); + const resolvedTarget = path.resolve(path.dirname(nestedEffectDir), linkTarget); + if (resolvedTarget === effectRoot) { + return false; + } + } + + await fs.rm(nestedEffectDir, { recursive: true, force: true }); + const relativeTarget = path.relative(path.dirname(nestedEffectDir), effectRoot); + await fs.symlink(relativeTarget, nestedEffectDir, "dir"); + return true; +} + +async function main() { + if (!(await exists(effectRoot)) || !(await exists(effectScopeDir))) { + return; + } + + const entries = await fs.readdir(effectScopeDir, { withFileTypes: true }); + let dedupedCount = 0; + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const changed = await dedupeNestedEffect(path.join(effectScopeDir, entry.name)); + if (changed) { + dedupedCount += 1; + } + } + + if (dedupedCount > 0) { + console.log(`[dedupe-effect-node-modules] linked root effect into ${dedupedCount} package(s)`); + } +} + +await main();