From 1d623c9302227eb00e4b66f5961cbee5f9f6c6ef Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Mon, 13 Jul 2026 23:00:23 -0600 Subject: [PATCH 1/3] feat: add committed-only changes view --- .../src/routes/agents/lifecycle-routes.ts | 47 +++++-- apps/server/src/shared/git/agent-diff.ts | 66 +++++---- apps/server/src/shared/git/diff-stats.ts | 20 ++- apps/server/test/agent-diff.test.ts | 53 +++++++ apps/server/test/diff-stats.test.ts | 25 ++++ .../components/app/center-pane-tab-bar.tsx | 5 +- .../app/changes-settings-popover.tsx | 132 ++++++++++-------- .../components/app/docs-sections/agents.tsx | 8 +- apps/web/src/hooks/use-agent-diff-stats.ts | 18 ++- apps/web/src/hooks/use-agent-diff.ts | 22 ++- apps/web/src/hooks/use-sse.ts | 5 +- apps/web/src/lib/store.ts | 5 + apps/web/src/lib/tips/tips.ts | 8 ++ 13 files changed, 304 insertions(+), 110 deletions(-) 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/center-pane-tab-bar.tsx b/apps/web/src/components/app/center-pane-tab-bar.tsx index e8ba9393..c42cd068 100644 --- a/apps/web/src/components/app/center-pane-tab-bar.tsx +++ b/apps/web/src/components/app/center-pane-tab-bar.tsx @@ -66,7 +66,8 @@ export const CenterPaneTabBar = memo(function CenterPaneTabBar({ draggable={!isMobile && activeTab !== tab.id} onDragStart={(e) => handleDragStart(e, tab.id)} className={cn( - "flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors", + "relative flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors", + tab.id === "changes" && "w-36 pr-[4.5rem]", activeTab === tab.id ? "text-foreground" : "cursor-grab text-muted-foreground hover:text-foreground/80 active:cursor-grabbing" @@ -86,7 +87,7 @@ export const CenterPaneTabBar = memo(function CenterPaneTabBar({ ) : null} {tab.id === "changes" && hasChanges ? ( - + +{diffStats.added} {"−"} diff --git a/apps/web/src/components/app/changes-settings-popover.tsx b/apps/web/src/components/app/changes-settings-popover.tsx index c5b260c6..6bbaf4a3 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"; @@ -25,65 +27,85 @@ export function ChangesSettingsPopover(): JSX.Element { const [ignoreWhitespace, setIgnoreWhitespace] = useAtom( diffIgnoreWhitespaceAtom ); + const [includeUncommitted, setIncludeUncommitted] = useAtom( + diffIncludeUncommittedAtom + ); return ( - - - - - -
-
- - Diff view - -
- {VIEW_OPTIONS.map((opt) => ( - - ))} + + + + + + +
+
+ + Diff view + +
+ {VIEW_OPTIONS.map((opt) => ( + + ))} +
-
-
- - 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.ts b/apps/web/src/hooks/use-sse.ts index d95c4137..674851ab 100644 --- a/apps/web/src/hooks/use-sse.ts +++ b/apps/web/src/hooks/use-sse.ts @@ -123,8 +123,11 @@ export function useSSE(authState: AuthState): void { } if (payload.type === "agent.diff_state_changed") { + void queryClient.invalidateQueries({ + queryKey: ["agent-diff", payload.agentId], + }); queryClient.setQueryData( - diffStatsQueryKey(payload.agentId), + diffStatsQueryKey(payload.agentId, true), payload.diffStats ); void queryClient.invalidateQueries({ 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/tips.ts b/apps/web/src/lib/tips/tips.ts index 447b3bb7..5127be1c 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 files.", + docsSection: "agents#split-tabs", + since: "0.28.2", + surfaces: ["inline"], + }, { id: "split-tabs", title: "Split Tabs", From 57f04fd2eb323862349454b9b4dc1bbb2ab801cd Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Mon, 13 Jul 2026 23:28:11 -0600 Subject: [PATCH 2/3] fix: address Changes view review feedback --- .../app/center-pane-tab-bar.test.tsx | 41 +++++++++++++++++ .../components/app/center-pane-tab-bar.tsx | 38 +++++++++++----- apps/web/src/hooks/use-sse.test.ts | 45 +++++++++++++++++++ apps/web/src/hooks/use-sse.ts | 31 +++++++++---- .../src/lib/tips/__tests__/use-tip.test.tsx | 8 +++- apps/web/src/lib/tips/tips.ts | 2 +- apps/web/src/lib/tips/use-tip.ts | 9 ++-- 7 files changed, 150 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/components/app/center-pane-tab-bar.test.tsx create mode 100644 apps/web/src/hooks/use-sse.test.ts diff --git a/apps/web/src/components/app/center-pane-tab-bar.test.tsx b/apps/web/src/components/app/center-pane-tab-bar.test.tsx new file mode 100644 index 00000000..48233c75 --- /dev/null +++ b/apps/web/src/components/app/center-pane-tab-bar.test.tsx @@ -0,0 +1,41 @@ +// @vitest-environment jsdom +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { CenterPaneTabBar } from "./center-pane-tab-bar"; + +describe("CenterPaneTabBar", () => { + it("compacts large diff counts while exposing the exact values", () => { + render( + + ); + + const badge = screen.getByTitle("12,345 additions, 98,765 deletions"); + expect(badge.textContent).toBe("+12k−99k"); + expect(badge.getAttribute("aria-label")).toBe( + "12,345 additions, 98,765 deletions" + ); + expect(badge.className).toContain("hidden"); + expect(badge.className).toContain("sm:inline-flex"); + expect(screen.getByRole("tab", { name: /changes/i }).className).toContain( + "sm:w-36" + ); + }); +}); diff --git a/apps/web/src/components/app/center-pane-tab-bar.tsx b/apps/web/src/components/app/center-pane-tab-bar.tsx index c42cd068..e9a774bb 100644 --- a/apps/web/src/components/app/center-pane-tab-bar.tsx +++ b/apps/web/src/components/app/center-pane-tab-bar.tsx @@ -17,6 +17,15 @@ const TABS: TabDef[] = [ { id: "changes", label: "Changes" }, ]; +const compactDiffCountFormatter = new Intl.NumberFormat("en-US", { + notation: "compact", + maximumSignificantDigits: 2, +}); + +export function formatDiffCount(count: number): string { + return compactDiffCountFormatter.format(count).toLowerCase(); +} + type CenterPaneTabBarProps = { activeTab: CenterTab; onTabChange: (tab: CenterTab) => void; @@ -36,6 +45,9 @@ export const CenterPaneTabBar = memo(function CenterPaneTabBar({ }: CenterPaneTabBarProps): JSX.Element { const hasChanges = diffStats && (diffStats.added > 0 || diffStats.deleted > 0); + const diffStatsLabel = diffStats + ? `${diffStats.added.toLocaleString("en-US")} additions, ${diffStats.deleted.toLocaleString("en-US")} deletions` + : undefined; const splitTabs = isSplit ? new Set([splitState.left, splitState.right]) @@ -67,7 +79,7 @@ export const CenterPaneTabBar = memo(function CenterPaneTabBar({ onDragStart={(e) => handleDragStart(e, tab.id)} className={cn( "relative flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors", - tab.id === "changes" && "w-36 pr-[4.5rem]", + tab.id === "changes" && "sm:w-36", activeTab === tab.id ? "text-foreground" : "cursor-grab text-muted-foreground hover:text-foreground/80 active:cursor-grabbing" @@ -85,16 +97,22 @@ export const CenterPaneTabBar = memo(function CenterPaneTabBar({ {activeTab === tab.id && !isSplit ? ( ) : null} - - {tab.id === "changes" && hasChanges ? ( - - +{diffStats.added} - - {"−"} - {diffStats.deleted} + {tab.id === "changes" && hasChanges ? ( + + + - - ) : null} + ) : null} + ); 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 674851ab..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,16 +141,11 @@ export function useSSE(authState: AuthState): void { } if (payload.type === "agent.diff_state_changed") { - void queryClient.invalidateQueries({ - queryKey: ["agent-diff", payload.agentId], - }); - queryClient.setQueryData( - diffStatsQueryKey(payload.agentId, true), + applyDiffStateChanged( + queryClient, + payload.agentId, payload.diffStats ); - void queryClient.invalidateQueries({ - queryKey: agentDiffQueryKey(payload.agentId), - }); return; } 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 5127be1c..76f26210 100644 --- a/apps/web/src/lib/tips/tips.ts +++ b/apps/web/src/lib/tips/tips.ts @@ -78,7 +78,7 @@ export const tips: Tip[] = [ { id: "uncommitted-diff", title: "Filter Uncommitted Changes", - body: "Choose whether Changes includes uncommitted files.", + body: "Choose whether Changes includes uncommitted work.", docsSection: "agents#split-tabs", since: "0.28.2", surfaces: ["inline"], 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"); From cad9599e077c0b46d2bf8103b7a4b0c7e04bac9f Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Tue, 14 Jul 2026 08:42:32 -0600 Subject: [PATCH 3/3] feat: expose diff settings on mobile --- apps/web/src/components/app/agents-view.tsx | 4 +- .../app/changes-settings-popover.test.tsx | 38 ++++++++++++ .../app/changes-settings-popover.tsx | 58 +++++++++++++------ 3 files changed, 81 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/components/app/changes-settings-popover.test.tsx 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) ? ( - ))} + {VIEW_OPTIONS.map((opt) => { + const disabled = isMobile && opt.value === "split"; + return ( + + ); + })}
+ {isMobile ? ( +

+ Split view is available on larger screens. +

+ ) : null}