diff --git a/apps/server/src/routes/agents/lifecycle-routes.ts b/apps/server/src/routes/agents/lifecycle-routes.ts index 3f5263a4..05ae5434 100644 --- a/apps/server/src/routes/agents/lifecycle-routes.ts +++ b/apps/server/src/routes/agents/lifecycle-routes.ts @@ -7,6 +7,7 @@ import { import { RENAME_PROMPT } from "../../agents/auto-rename-prompter.js"; import { shouldSuggestSessionRename } from "../../agents/tmux/session-name.js"; import { getAgentDiff, getAgentFileDiff } from "../../shared/git/agent-diff.js"; +import { getDiffStats } from "../../shared/git/diff-stats.js"; import { AGENT_LATEST_EVENT_TYPES, isAgentLatestEventType, @@ -325,6 +326,28 @@ export async function registerAgentLifecycleRoutes( return reply.code(404).send({ error: "Agent not found." }); } + const includeUncommitted = + (request.query as { includeUncommitted?: string }).includeUncommitted !== + "false"; + + if (!includeUncommitted) { + const gitContextWorktreePath = agent.gitContext?.isWorktree + ? agent.gitContext.worktreePath + : null; + const worktreePath = + agent.worktreePath ?? gitContextWorktreePath ?? agent.cwd ?? null; + if (!worktreePath) return { diffStats: null }; + + const baseRef = + agent.baseBranch ?? + (agent.worktreePath || gitContextWorktreePath ? "main" : null); + return { + diffStats: await getDiffStats(worktreePath, baseRef, { + includeUncommitted: false, + }), + }; + } + // Await the signal so first-paint always sees a fresh value rather // than the cold-cache `null` followed by an SSE update milliseconds // later. The 3s freshness window inside the refresher still absorbs @@ -360,11 +383,15 @@ export async function registerAgentLifecycleRoutes( (agent.worktreePath || gitContextWorktreePath ? "main" : null); try { - const ignoreWhitespace = - (request.query as { ignoreWhitespace?: string }).ignoreWhitespace !== - "false"; + const query = request.query as { + ignoreWhitespace?: string; + includeUncommitted?: string; + }; + const ignoreWhitespace = query.ignoreWhitespace !== "false"; + const includeUncommitted = query.includeUncommitted !== "false"; const result = await getAgentDiff(worktreePath, baseRef, undefined, { ignoreWhitespace, + includeUncommitted, }); if (!result) { return { baseRef: null, files: [] }; @@ -378,7 +405,12 @@ export async function registerAgentLifecycleRoutes( app.get("/api/v1/agents/:id/diff/file", async (request, reply) => { const params = request.params as { id?: string }; - const query = request.query as { path?: string; force?: string }; + const query = request.query as { + path?: string; + force?: string; + ignoreWhitespace?: string; + includeUncommitted?: string; + }; const id = params.id ?? ""; if (!query.path) { @@ -410,15 +442,14 @@ export async function registerAgentLifecycleRoutes( (agent.worktreePath || gitContextWorktreePath ? "main" : null); try { - const ignoreWhitespace = - (request.query as { ignoreWhitespace?: string }).ignoreWhitespace !== - "false"; + const ignoreWhitespace = query.ignoreWhitespace !== "false"; + const includeUncommitted = query.includeUncommitted !== "false"; const result = await getAgentFileDiff( worktreePath, baseRef, query.path, undefined, - { ignoreWhitespace } + { ignoreWhitespace, includeUncommitted } ); if (!result) { return reply.code(404).send({ error: "File not found in diff." }); diff --git a/apps/server/src/shared/git/agent-diff.ts b/apps/server/src/shared/git/agent-diff.ts index 12422ca8..941818c0 100644 --- a/apps/server/src/shared/git/agent-diff.ts +++ b/apps/server/src/shared/git/agent-diff.ts @@ -188,7 +188,7 @@ export async function getAgentDiff( worktreePath: string, baseRef: string | null, run: CommandRunner = runCommand, - options?: { ignoreWhitespace?: boolean } + options?: { ignoreWhitespace?: boolean; includeUncommitted?: boolean } ): Promise { const resolvedBase = await resolveBaseRef(worktreePath, baseRef, { runCommand: run, @@ -206,11 +206,15 @@ export async function getAgentDiff( const mergeBaseSha = mergeBaseResult.stdout.trim(); const wsFlag = options?.ignoreWhitespace ? ["-w"] : []; + const includeUncommitted = options?.includeUncommitted !== false; + const diffRange = includeUncommitted + ? [mergeBaseSha] + : [mergeBaseSha, "HEAD"]; const [numstatResult, statusResult, diffResult, untrackedResult] = await Promise.all([ run( "git", - ["-C", worktreePath, "diff", mergeBaseSha, "--numstat", ...wsFlag], + ["-C", worktreePath, "diff", ...diffRange, "--numstat", ...wsFlag], { allowedExitCodes: [0], timeoutMs: GIT_TIMEOUT_MS, @@ -218,7 +222,7 @@ export async function getAgentDiff( ), run( "git", - ["-C", worktreePath, "diff", mergeBaseSha, "--name-status", ...wsFlag], + ["-C", worktreePath, "diff", ...diffRange, "--name-status", ...wsFlag], { allowedExitCodes: [0], timeoutMs: GIT_TIMEOUT_MS, @@ -230,7 +234,7 @@ export async function getAgentDiff( "-C", worktreePath, "diff", - mergeBaseSha, + ...diffRange, "-U3", "--no-color", ...wsFlag, @@ -240,14 +244,16 @@ export async function getAgentDiff( timeoutMs: GIT_TIMEOUT_MS, } ), - run( - "git", - ["-C", worktreePath, "ls-files", "--others", "--exclude-standard"], - { - allowedExitCodes: [0], - timeoutMs: GIT_TIMEOUT_MS, - } - ), + includeUncommitted + ? run( + "git", + ["-C", worktreePath, "ls-files", "--others", "--exclude-standard"], + { + allowedExitCodes: [0], + timeoutMs: GIT_TIMEOUT_MS, + } + ) + : Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }), ]); const entries = parseNumstatWithStatus( @@ -317,7 +323,7 @@ export async function getAgentFileDiff( baseRef: string | null, filePath: string, run: CommandRunner = runCommand, - options?: { ignoreWhitespace?: boolean } + options?: { ignoreWhitespace?: boolean; includeUncommitted?: boolean } ): Promise { const resolvedBase = await resolveBaseRef(worktreePath, baseRef, { runCommand: run, @@ -335,6 +341,10 @@ export async function getAgentFileDiff( const mergeBaseSha = mergeBaseResult.stdout.trim(); const wsFlag2 = options?.ignoreWhitespace ? ["-w"] : []; + const includeUncommitted = options?.includeUncommitted !== false; + const diffRange2 = includeUncommitted + ? [mergeBaseSha] + : [mergeBaseSha, "HEAD"]; const [numstatResult, statusResult, diffResult] = await Promise.all([ run( "git", @@ -342,7 +352,7 @@ export async function getAgentFileDiff( "-C", worktreePath, "diff", - mergeBaseSha, + ...diffRange2, "--numstat", ...wsFlag2, "--", @@ -356,7 +366,7 @@ export async function getAgentFileDiff( "-C", worktreePath, "diff", - mergeBaseSha, + ...diffRange2, "--name-status", ...wsFlag2, "--", @@ -370,7 +380,7 @@ export async function getAgentFileDiff( "-C", worktreePath, "diff", - mergeBaseSha, + ...diffRange2, "-U3", "--no-color", ...wsFlag2, @@ -387,15 +397,21 @@ export async function getAgentFileDiff( ); if (entries.length === 0) { - const { lines, content } = await readUntrackedFile(worktreePath, filePath); - if (lines === 0 && content === null) return null; - return { - path: filePath, - status: "added" as DiffFileStatus, - added: lines, - deleted: 0, - diff: buildUntrackedDiff(filePath, content!), - }; + if (includeUncommitted) { + const { lines, content } = await readUntrackedFile( + worktreePath, + filePath + ); + if (lines === 0 && content === null) return null; + return { + path: filePath, + status: "added" as DiffFileStatus, + added: lines, + deleted: 0, + diff: buildUntrackedDiff(filePath, content!), + }; + } + return null; } const entry = entries[0]!; diff --git a/apps/server/src/shared/git/diff-stats.ts b/apps/server/src/shared/git/diff-stats.ts index 806d83ad..af0ecc6c 100644 --- a/apps/server/src/shared/git/diff-stats.ts +++ b/apps/server/src/shared/git/diff-stats.ts @@ -20,6 +20,8 @@ type CommandRunner = ( export type GetDiffStatsOptions = { /** Override for tests. */ runCommand?: CommandRunner; + /** Include staged, unstaged, and untracked working-tree changes. */ + includeUncommitted?: boolean; }; /** @@ -45,6 +47,7 @@ export async function getDiffStats( options: GetDiffStatsOptions = {} ): Promise { const run = options.runCommand ?? runCommand; + const includeUncommitted = options.includeUncommitted !== false; try { const resolvedBase = await resolveBaseRef(worktreePath, baseRef, { runCommand: run, @@ -61,16 +64,21 @@ export async function getDiffStats( } const mergeBaseSha = mergeBase.stdout.trim(); + const diffRange = includeUncommitted + ? [mergeBaseSha] + : [mergeBaseSha, "HEAD"]; const [tracked, untracked] = await Promise.all([ - run("git", ["-C", worktreePath, "diff", mergeBaseSha, "--numstat"], { + run("git", ["-C", worktreePath, "diff", ...diffRange, "--numstat"], { allowedExitCodes: [0], timeoutMs: GIT_TIMEOUT_MS, }), - run( - "git", - ["-C", worktreePath, "ls-files", "--others", "--exclude-standard"], - { allowedExitCodes: [0], timeoutMs: GIT_TIMEOUT_MS } - ), + includeUncommitted + ? run( + "git", + ["-C", worktreePath, "ls-files", "--others", "--exclude-standard"], + { allowedExitCodes: [0], timeoutMs: GIT_TIMEOUT_MS } + ) + : Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }), ]); const trackedPaths = extractPathsFromNumstat(tracked.stdout); diff --git a/apps/server/test/agent-diff.test.ts b/apps/server/test/agent-diff.test.ts index 224d132e..11fe1ce2 100644 --- a/apps/server/test/agent-diff.test.ts +++ b/apps/server/test/agent-diff.test.ts @@ -362,6 +362,37 @@ describe("getAgentDiff stats consistency", () => { expect(file.added).toBe(2500); expect(file.deleted).toBe(3); }); + + it("uses committed-only ranges and omits untracked files when requested", async () => { + await writeFile(path.join(tmpDir, "uncommitted.ts"), "not committed\n"); + const committedDiff = generateDiffLines("committed.ts", 2); + const runner = vi.fn( + makeMockRunner( + "2\t0\tcommitted.ts", + "A\tcommitted.ts", + committedDiff, + "uncommitted.ts" + ) + ); + + const result = await getAgentDiff(tmpDir, BASE_REF, runner, { + includeUncommitted: false, + }); + + expect(result!.files.map((file) => file.path)).toEqual(["committed.ts"]); + expect( + runner.mock.calls + .map(([, args]) => args.join(" ")) + .filter((args) => args.includes(" diff ")) + ).toEqual( + expect.arrayContaining([ + expect.stringContaining(`diff ${MERGE_BASE_SHA} HEAD`), + ]) + ); + expect( + runner.mock.calls.some(([, args]) => args.includes("ls-files")) + ).toBe(false); + }); }); describe("getAgentFileDiff (force load)", () => { @@ -434,4 +465,26 @@ describe("getAgentFileDiff (force load)", () => { await rm(tmpDir, { recursive: true, force: true }); } }); + + it("does not force-load an untracked file in committed-only mode", async () => { + const tmpDir = await mkdtemp( + path.join(os.tmpdir(), "agent-diff-file-committed-test-") + ); + try { + await writeFile(path.join(tmpDir, "new.ts"), "uncommitted\n"); + const runner = makeMockRunner("", "", ""); + + const result = await getAgentFileDiff( + tmpDir, + BASE_REF, + "new.ts", + runner, + { includeUncommitted: false } + ); + + expect(result).toBeNull(); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } + }); }); diff --git a/apps/server/test/diff-stats.test.ts b/apps/server/test/diff-stats.test.ts index 7179208c..59dc15b3 100644 --- a/apps/server/test/diff-stats.test.ts +++ b/apps/server/test/diff-stats.test.ts @@ -110,6 +110,31 @@ describe("getDiffStats", () => { expect(result).toMatchObject({ added: 15, deleted: 2, files: 2 }); }); + it("compares merge-base to HEAD and skips untracked files when requested", async () => { + const worktreePath = tempRoot; + const committedOnlyKey = `-C ${worktreePath} diff ${MERGE_BASE_SHA} HEAD --numstat`; + const runCommand = withCommands(worktreePath, "main", { + [committedOnlyKey]: () => ok("4\t1\tsrc/committed.ts"), + }); + + const result = await getDiffStats(worktreePath, "main", { + runCommand, + includeUncommitted: false, + }); + + expect(result).toMatchObject({ added: 4, deleted: 1, files: 1 }); + expect(runCommand).toHaveBeenCalledWith( + "git", + ["-C", worktreePath, "diff", MERGE_BASE_SHA, "HEAD", "--numstat"], + expect.any(Object) + ); + expect(runCommand).not.toHaveBeenCalledWith( + "git", + expect.arrayContaining(["ls-files"]), + expect.anything() + ); + }); + it("captures committed AND uncommitted edits to the same file as one net diff", async () => { // Regression: the previous two-stream approach deduped by path and // dropped the uncommitted slice when a file appeared in both. With a diff --git a/apps/web/src/components/app/agents-view.tsx b/apps/web/src/components/app/agents-view.tsx index 079525ea..2e557da0 100644 --- a/apps/web/src/components/app/agents-view.tsx +++ b/apps/web/src/components/app/agents-view.tsx @@ -717,8 +717,8 @@ export function AgentsView({ ) : null}
- {changesMatch && !isMobile && !isSplit ? ( - + {changesMatch && !isSplit ? ( + ) : null} {hasActiveAgent && (!mediaPanelOpen || isMobile) ? ( ); diff --git a/apps/web/src/components/app/changes-settings-popover.test.tsx b/apps/web/src/components/app/changes-settings-popover.test.tsx new file mode 100644 index 00000000..8908c16a --- /dev/null +++ b/apps/web/src/components/app/changes-settings-popover.test.tsx @@ -0,0 +1,38 @@ +// @vitest-environment jsdom +import { createStore, Provider } from "jotai"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it } from "vitest"; + +import { diffViewTypeAtom } from "@/lib/store"; + +import { ChangesSettingsPopover } from "./changes-settings-popover"; + +describe("ChangesSettingsPopover", () => { + it("forces unified on mobile without changing the desktop preference", () => { + const store = createStore(); + store.set(diffViewTypeAtom, "split"); + + render( + + + + + + ); + + fireEvent.click(screen.getByTestId("changes-settings-button")); + + const unified = screen.getByRole("button", { name: "Unified" }); + const split = screen.getByRole("button", { name: "Split" }); + expect(unified.getAttribute("aria-pressed")).toBe("true"); + expect(split.getAttribute("aria-pressed")).toBe("false"); + expect(split.hasAttribute("disabled")).toBe(true); + expect( + screen.getByText("Split view is available on larger screens.") + ).toBeTruthy(); + + fireEvent.click(unified); + expect(store.get(diffViewTypeAtom)).toBe("split"); + }); +}); diff --git a/apps/web/src/components/app/changes-settings-popover.tsx b/apps/web/src/components/app/changes-settings-popover.tsx index c5b260c6..69e1b5bc 100644 --- a/apps/web/src/components/app/changes-settings-popover.tsx +++ b/apps/web/src/components/app/changes-settings-popover.tsx @@ -3,6 +3,7 @@ import { Settings } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; +import { TipSpot } from "@/components/tips/tip-spot"; import { Popover, PopoverContent, @@ -12,6 +13,7 @@ import { type DiffViewType, diffViewTypeAtom, diffIgnoreWhitespaceAtom, + diffIncludeUncommittedAtom, } from "@/lib/store"; import { cn } from "@/lib/utils"; @@ -20,70 +22,114 @@ const VIEW_OPTIONS: { value: DiffViewType; label: string }[] = [ { value: "split", label: "Split" }, ]; -export function ChangesSettingsPopover(): JSX.Element { +type ChangesSettingsPopoverProps = { + isMobile?: boolean; +}; + +export function ChangesSettingsPopover({ + isMobile = false, +}: ChangesSettingsPopoverProps): JSX.Element { const [viewType, setViewType] = useAtom(diffViewTypeAtom); + const effectiveViewType = isMobile ? "unified" : viewType; const [ignoreWhitespace, setIgnoreWhitespace] = useAtom( diffIgnoreWhitespaceAtom ); + const [includeUncommitted, setIncludeUncommitted] = useAtom( + diffIncludeUncommittedAtom + ); return ( - - - - - -
-
- - Diff view - -
- {VIEW_OPTIONS.map((opt) => ( - + + +
+
+ + Diff view + +
+ {VIEW_OPTIONS.map((opt) => { + const disabled = isMobile && opt.value === "split"; + return ( + + ); + })} +
+ {isMobile ? ( +

- {opt.label} - - ))} + Split view is available on larger screens. +

+ ) : null}
-
-
- - Whitespace - -
-
- - + + + ); } diff --git a/apps/web/src/components/app/docs-sections/agents.tsx b/apps/web/src/components/app/docs-sections/agents.tsx index 4f362f23..98b74be2 100644 --- a/apps/web/src/components/app/docs-sections/agents.tsx +++ b/apps/web/src/components/app/docs-sections/agents.tsx @@ -180,10 +180,12 @@ export function AgentsContent() {

Click the gear icon in the tab bar to open diff settings. Toggle between Unified and{" "} - Split view, and check{" "} + Split view, choose whether to{" "} + Include uncommitted changes, and check{" "} Hide whitespace changes to filter out whitespace-only - edits. These settings persist across sessions. On mobile, the diff - always renders in unified mode. + edits. Excluding uncommitted changes limits the diff and its stats to + committed work. These settings persist across sessions. On mobile, the + diff always renders in unified mode.

On desktop, drag the Terminal or{" "} diff --git a/apps/web/src/hooks/use-agent-diff-stats.ts b/apps/web/src/hooks/use-agent-diff-stats.ts index 92f057da..3453ea0e 100644 --- a/apps/web/src/hooks/use-agent-diff-stats.ts +++ b/apps/web/src/hooks/use-agent-diff-stats.ts @@ -1,13 +1,18 @@ import { useCallback } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; import type { DiffStats } from "@/components/app/types"; import { api } from "@/lib/api"; +import { diffIncludeUncommittedAtom } from "@/lib/store"; type DiffStatsResponse = { diffStats: DiffStats | null }; -export function diffStatsQueryKey(agentId: string): [string, string] { - return ["agent-diff", agentId]; +export function diffStatsQueryKey( + agentId: string, + includeUncommitted: boolean +): [string, string, boolean] { + return ["agent-diff", agentId, includeUncommitted]; } /** @@ -30,12 +35,13 @@ export function useAgentDiffStats( refresh: () => void; } { const queryClient = useQueryClient(); + const includeUncommitted = useAtomValue(diffIncludeUncommittedAtom); const query = useQuery({ - queryKey: diffStatsQueryKey(agentId), + queryKey: diffStatsQueryKey(agentId, includeUncommitted), queryFn: async () => { const payload = await api( - `/api/v1/agents/${agentId}/diff-stats` + `/api/v1/agents/${agentId}/diff-stats?includeUncommitted=${includeUncommitted}` ); return payload.diffStats; }, @@ -47,9 +53,9 @@ export function useAgentDiffStats( const refresh = useCallback(() => { void queryClient.invalidateQueries({ - queryKey: diffStatsQueryKey(agentId), + queryKey: diffStatsQueryKey(agentId, includeUncommitted), }); - }, [agentId, queryClient]); + }, [agentId, includeUncommitted, queryClient]); return { diffStats: query.data, refresh }; } diff --git a/apps/web/src/hooks/use-agent-diff.ts b/apps/web/src/hooks/use-agent-diff.ts index e1983190..ffda6554 100644 --- a/apps/web/src/hooks/use-agent-diff.ts +++ b/apps/web/src/hooks/use-agent-diff.ts @@ -1,7 +1,9 @@ import { useCallback } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; import { api } from "@/lib/api"; +import { diffIncludeUncommittedAtom } from "@/lib/store"; export type DiffFileStatus = "modified" | "added" | "deleted" | "renamed"; @@ -43,12 +45,17 @@ export function useAgentDiff( refresh: () => void; } { const queryClient = useQueryClient(); + const includeUncommitted = useAtomValue(diffIncludeUncommittedAtom); const query = useQuery({ - queryKey: [...agentDiffQueryKey(agentId ?? ""), ignoreWhitespace], + queryKey: [ + ...agentDiffQueryKey(agentId ?? ""), + ignoreWhitespace, + includeUncommitted, + ], queryFn: async () => { return api( - `/api/v1/agents/${agentId}/diff?ignoreWhitespace=${ignoreWhitespace}` + `/api/v1/agents/${agentId}/diff?ignoreWhitespace=${ignoreWhitespace}&includeUncommitted=${includeUncommitted}` ); }, enabled: enabled && !!agentId, @@ -76,11 +83,18 @@ export function useAgentFileDiff( data: FileDiffResponse | undefined; isLoading: boolean; } { + const includeUncommitted = useAtomValue(diffIncludeUncommittedAtom); const query = useQuery({ - queryKey: ["agent-diff-file", agentId, filePath, ignoreWhitespace], + queryKey: [ + "agent-diff-file", + agentId, + filePath, + ignoreWhitespace, + includeUncommitted, + ], queryFn: async () => { return api( - `/api/v1/agents/${agentId}/diff/file?path=${encodeURIComponent(filePath!)}&force=true&ignoreWhitespace=${ignoreWhitespace}` + `/api/v1/agents/${agentId}/diff/file?path=${encodeURIComponent(filePath!)}&force=true&ignoreWhitespace=${ignoreWhitespace}&includeUncommitted=${includeUncommitted}` ); }, enabled: enabled && !!agentId && !!filePath, diff --git a/apps/web/src/hooks/use-sse.test.ts b/apps/web/src/hooks/use-sse.test.ts new file mode 100644 index 00000000..c856536c --- /dev/null +++ b/apps/web/src/hooks/use-sse.test.ts @@ -0,0 +1,45 @@ +// @vitest-environment jsdom +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it, vi } from "vitest"; + +import { agentDiffQueryKey } from "@/hooks/use-agent-diff"; +import { diffStatsQueryKey } from "@/hooks/use-agent-diff-stats"; + +import { applyDiffStateChanged } from "./use-sse"; + +describe("applyDiffStateChanged", () => { + it("updates pushed stats and invalidates only committed-only stats", () => { + const queryClient = new QueryClient(); + const includeUncommittedKey = diffStatsQueryKey("agent-1", true); + const committedOnlyKey = diffStatsQueryKey("agent-1", false); + const contentKey = [...agentDiffQueryKey("agent-1"), false, true] as const; + + queryClient.setQueryData(includeUncommittedKey, { added: 1, deleted: 1 }); + queryClient.setQueryData(committedOnlyKey, { added: 2, deleted: 2 }); + queryClient.setQueryData(contentKey, { files: [] }); + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries"); + const pushedStats = { + added: 3, + deleted: 4, + files: 2, + computedAt: 123, + }; + + applyDiffStateChanged(queryClient, "agent-1", pushedStats); + + expect(queryClient.getQueryData(includeUncommittedKey)).toEqual( + pushedStats + ); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: committedOnlyKey, + exact: true, + }); + expect(invalidateQueries).not.toHaveBeenCalledWith({ + queryKey: includeUncommittedKey, + exact: true, + }); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: agentDiffQueryKey("agent-1"), + }); + }); +}); diff --git a/apps/web/src/hooks/use-sse.ts b/apps/web/src/hooks/use-sse.ts index d95c4137..c169e658 100644 --- a/apps/web/src/hooks/use-sse.ts +++ b/apps/web/src/hooks/use-sse.ts @@ -1,5 +1,5 @@ import { useEffect, useRef } from "react"; -import { useQueryClient } from "@tanstack/react-query"; +import { type QueryClient, useQueryClient } from "@tanstack/react-query"; import { type Agent, type AuthState, @@ -70,6 +70,24 @@ function patchAgentHasStream( ); } +export function applyDiffStateChanged( + queryClient: QueryClient, + agentId: string, + diffStats: DiffStats | null +): void { + queryClient.setQueryData( + diffStatsQueryKey(agentId, true), + diffStats + ); + void queryClient.invalidateQueries({ + queryKey: diffStatsQueryKey(agentId, false), + exact: true, + }); + void queryClient.invalidateQueries({ + queryKey: agentDiffQueryKey(agentId), + }); +} + export function useSSE(authState: AuthState): void { const queryClient = useQueryClient(); const eventSourceRef = useRef(null); @@ -123,13 +141,11 @@ export function useSSE(authState: AuthState): void { } if (payload.type === "agent.diff_state_changed") { - queryClient.setQueryData( - diffStatsQueryKey(payload.agentId), + applyDiffStateChanged( + queryClient, + payload.agentId, payload.diffStats ); - void queryClient.invalidateQueries({ - queryKey: agentDiffQueryKey(payload.agentId), - }); return; } diff --git a/apps/web/src/lib/store.ts b/apps/web/src/lib/store.ts index 2e3b22ec..d34aefc5 100644 --- a/apps/web/src/lib/store.ts +++ b/apps/web/src/lib/store.ts @@ -104,6 +104,11 @@ export const diffIgnoreWhitespaceAtom = atomWithLocalStorage( true ); +export const diffIncludeUncommittedAtom = atomWithLocalStorage( + "dispatch:diffIncludeUncommitted", + true +); + export const diffFileTreeOpenAtom = atomWithLocalStorage( "dispatch:diffFileTreeOpen", true diff --git a/apps/web/src/lib/tips/__tests__/use-tip.test.tsx b/apps/web/src/lib/tips/__tests__/use-tip.test.tsx index 7605cef6..19cfe3b4 100644 --- a/apps/web/src/lib/tips/__tests__/use-tip.test.tsx +++ b/apps/web/src/lib/tips/__tests__/use-tip.test.tsx @@ -10,7 +10,7 @@ import { } from "../tips-state"; import { useTip } from "../use-tip"; -vi.mock("@/lib/version", () => ({ BUILD_VERSION: "0.23.0" })); +vi.mock("@/lib/version", () => ({ BUILD_VERSION: "0.29.0" })); function renderUseTip(id: string, store: ReturnType) { return renderHook(() => useTip(id), { @@ -50,6 +50,12 @@ describe("useTip", () => { expect(result.current.shouldShowInline).toBe(false); }); + it("shows a tip added at the user's version on any newer build", () => { + store.set(lastSeenVersionAtom, "0.28.2"); + const { result } = renderUseTip("uncommitted-diff", store); + expect(result.current.shouldShowInline).toBe(true); + }); + it("shouldShowAmbient is true for undismissed tips regardless of version", () => { store.set(lastSeenVersionAtom, "0.23.0"); const { result } = renderUseTip("personas", store); // since: "0.22.0" diff --git a/apps/web/src/lib/tips/tips.ts b/apps/web/src/lib/tips/tips.ts index 447b3bb7..76f26210 100644 --- a/apps/web/src/lib/tips/tips.ts +++ b/apps/web/src/lib/tips/tips.ts @@ -75,6 +75,14 @@ export const tips: Tip[] = [ since: "0.23.9", surfaces: ["ambient"], }, + { + id: "uncommitted-diff", + title: "Filter Uncommitted Changes", + body: "Choose whether Changes includes uncommitted work.", + docsSection: "agents#split-tabs", + since: "0.28.2", + surfaces: ["inline"], + }, { id: "split-tabs", title: "Split Tabs", diff --git a/apps/web/src/lib/tips/use-tip.ts b/apps/web/src/lib/tips/use-tip.ts index 56ef14e7..77e2e348 100644 --- a/apps/web/src/lib/tips/use-tip.ts +++ b/apps/web/src/lib/tips/use-tip.ts @@ -19,15 +19,18 @@ export function useTip(id: string) { const lastSeenVersion = useAtomValue(lastSeenVersionAtom); const isDismissed = dismissed.includes(id); + const isInReleaseWindow = + tip !== null && lastSeenVersion !== null + ? isVersionNewer(BUILD_VERSION, tip.since) && + !isVersionNewer(lastSeenVersion, tip.since) + : false; const shouldShowInline = tip !== null && enabled && !isDismissed && tip.surfaces.includes("inline") && - lastSeenVersion !== null && - isVersionNewer(tip.since, lastSeenVersion) && - !isVersionNewer(tip.since, BUILD_VERSION); + isInReleaseWindow; const shouldShowAmbient = tip !== null && enabled && !isDismissed && tip.surfaces.includes("ambient");