feat: add pierre file tree to full-screen git panel (#1487)

* feat: add pierre file tree to full-screen git panel

Render the dashboard git panel with real changed-file data using pierre
diffs, add a full-screen toggle, and show a pierre FileTree explorer on the
right when expanded.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* feat: always show git panel as collapsible resizable card

- git panel renders on every thread with collapse to a floating expand
  button and drag-resize, persisted to localStorage (matches left sidebar)
- panel content wrapped in a rounded card with Git/Desktop/Terminal tabs
  above it; Desktop/Terminal and Review/Commits show Coming Soon
- send button morphs into a stop button during an active run

* fix: follow app theme for pierre diff rendering

diffOptions used themeType "system" so @pierre/diffs followed the OS
color scheme instead of the app's .dark class, producing unreadable
dark-on-light diffs when the two disagreed. Add useDiffOptions() which
resolves themeType from the app theme, and use it at all diff render
sites.

* fix: align git panel card bottom gutter with prompt bar

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Johannes du Plessis 2026-06-10 15:13:12 -07:00 • committed by GitHub
parent 259ec57186
commit 4cabdb2351
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 492 additions and 101 deletions

View file

@ -7,8 +7,10 @@
"dependencies": {
"@base-ui/react": "^1.4.1",
"@fontsource-variable/inter": "^5.2.8",
"@monaco-editor/react": "^4.7.0",
"@phosphor-icons/react": "^2.1.10",
"@pierre/diffs": "^1.2.1",
"@pierre/trees": "1.0.0-beta.4",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-devtools": "^0.10.0",
"@tanstack/react-query": "^5.100.10",
@ -22,6 +24,7 @@
"clsx": "^2.1.1",
"diff": "^9.0.0",
"lucide-react": "^1.16.0",
"monaco-editor": "^0.52.2",
"nitro": "latest",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@ -407,6 +410,10 @@
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="],
"@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="],
"@mswjs/interceptors": ["@mswjs/interceptors@0.41.9", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
@ -447,6 +454,8 @@
"@pierre/theme": ["@pierre/theme@1.0.3", "", {}, "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA=="],
"@pierre/trees": ["@pierre/trees@1.0.0-beta.4", "", { "dependencies": { "preact": "11.0.0-beta.0", "preact-render-to-string": "6.6.5" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg=="],
@ -1607,6 +1616,8 @@
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"monaco-editor": ["monaco-editor@0.52.2", "", {}, "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"msw": ["msw@2.14.6", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.11", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg=="],
@ -1725,6 +1736,10 @@
"powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
"preact": ["preact@11.0.0-beta.0", "", {}, "sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg=="],
"preact-render-to-string": ["preact-render-to-string@6.6.5", "", { "peerDependencies": { "preact": ">=10 || >= 11.0.0-0" } }, "sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
@ -1905,6 +1920,8 @@
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],

View file

@ -17,6 +17,7 @@
"@monaco-editor/react": "^4.7.0",
"@phosphor-icons/react": "^2.1.10",
"@pierre/diffs": "^1.2.1",
"@pierre/trees": "1.0.0-beta.4",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-devtools": "^0.10.0",
"@tanstack/react-query": "^5.100.10",

View file

@ -1,62 +1,306 @@
import { useMemo, useState } from "react";
import { MultiFileDiff } from "@pierre/diffs/react";
import { CaretDownIcon, GitPullRequestIcon } from "@phosphor-icons/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { MultiFileDiff } from "@pierre/diffs/react"
import {
FileTree,
useFileTree,
useFileTreeSelection,
} from "@pierre/trees/react"
import {
ArrowsInIcon,
ArrowsOutIcon,
CaretDownIcon,
GitPullRequestIcon,
SidebarSimpleIcon,
} from "@phosphor-icons/react"
import type { GitStatus, GitStatusEntry } from "@pierre/trees"
import { diffOptions } from "@/components/agents/utils/diffUtils";
import type { AgentThread } from "@/lib/agents/types";
import { cn } from "@/lib/utils";
import type { AgentThread } from "@/lib/agents/types"
import type { ChangedFileSummaryItem } from "@/components/agents/ported"
import { useDiffOptions } from "@/components/agents/utils/diffUtils"
import { summarizeChangedFiles } from "@/components/agents/ported"
import { Z } from "@/components/agents/z-index"
import { cn } from "@/lib/utils"
interface AgentGitPanelProps {
thread: AgentThread;
thread: AgentThread
}
interface PanelFile {
filePath: string
treePath: string
additions: number
deletions: number
originalContent: string
modifiedContent: string
status: GitStatus
}
function deriveStatus(file: ChangedFileSummaryItem): GitStatus {
if (file.originalContent.length === 0 && file.modifiedContent.length > 0) {
return "added"
}
if (file.modifiedContent.length === 0 && file.originalContent.length > 0) {
return "deleted"
}
return "modified"
}
function commonDirPrefix(paths: Array<string>): string {
const first = paths[0]
if (paths.length === 0 || first === undefined) return ""
const base = first.split("/").slice(0, -1)
let depth = base.length
for (const path of paths) {
const segments = path.split("/").slice(0, -1)
let i = 0
while (i < depth && i < segments.length && segments[i] === base[i]) i++
depth = i
}
return depth === 0 ? "" : `${base.slice(0, depth).join("/")}/`
}
const PANEL_STORAGE_WIDTH = "open-swe.gitpanel.width"
const PANEL_STORAGE_COLLAPSED = "open-swe.gitpanel.collapsed"
const PANEL_DEFAULT_WIDTH = 420
const PANEL_MIN_WIDTH = 320
const PANEL_MAX_WIDTH = 720
function readStoredPanelWidth(): number {
if (typeof window === "undefined") return PANEL_DEFAULT_WIDTH
const raw = window.localStorage.getItem(PANEL_STORAGE_WIDTH)
const parsed = raw ? Number(raw) : NaN
if (!Number.isFinite(parsed)) return PANEL_DEFAULT_WIDTH
return Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, parsed))
}
function readStoredPanelCollapsed(): boolean {
if (typeof window === "undefined") return false
return window.localStorage.getItem(PANEL_STORAGE_COLLAPSED) === "1"
}
function PanelResizeHandle({
width,
onResize,
}: {
width: number
onResize: (next: number) => void
}) {
const startRef = useRef<{ x: number; width: number } | null>(null)
const [dragging, setDragging] = useState(false)
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault()
startRef.current = { x: e.clientX, width }
setDragging(true)
e.currentTarget.setPointerCapture(e.pointerId)
}
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!startRef.current) return
onResize(startRef.current.width - (e.clientX - startRef.current.x))
}
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
startRef.current = null
setDragging(false)
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId)
}
}
useEffect(() => {
if (!dragging) return
const prev = document.body.style.cursor
document.body.style.cursor = "col-resize"
return () => {
document.body.style.cursor = prev
}
}, [dragging])
return (
<div
role="separator"
aria-orientation="vertical"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
className={cn(
"absolute top-0 left-0 z-20 h-full w-1 cursor-col-resize touch-none select-none",
"after:absolute after:inset-y-0 after:left-0 after:w-px after:bg-transparent after:transition-colors",
"hover:after:bg-[var(--ui-border)]",
dragging && "after:bg-[var(--ui-border)]"
)}
/>
)
}
function treeThemeStyle(): React.CSSProperties {
return {
"--trees-theme-sidebar-bg": "var(--ui-surface)",
"--trees-theme-sidebar-fg": "var(--ui-text)",
"--trees-theme-sidebar-border": "var(--ui-border)",
"--trees-theme-sidebar-header-fg": "var(--ui-text-dim)",
"--trees-theme-list-hover-bg": "var(--ui-panel-2)",
"--trees-theme-list-active-selection-bg": "var(--ui-accent-bubble)",
"--trees-theme-list-active-selection-fg": "var(--ui-text)",
"--trees-theme-input-bg": "var(--ui-panel)",
"--trees-theme-input-border": "var(--ui-border)",
"--trees-theme-focus-ring": "var(--ui-accent)",
"--trees-theme-scrollbar-thumb": "var(--ui-border)",
"--trees-theme-git-added-fg": "var(--ui-success)",
"--trees-theme-git-deleted-fg": "var(--ui-danger)",
"--trees-theme-git-modified-fg": "var(--ui-accent)",
} as React.CSSProperties
}
export function AgentGitPanel({ thread }: AgentGitPanelProps) {
const [tab, setTab] = useState<"diff" | "review" | "commits">("diff");
const pr = thread.pr;
const [topTab, setTopTab] = useState<"git" | "desktop" | "terminal">("git")
const [tab, setTab] = useState<"diff" | "review" | "commits">("diff")
const [collapsed, setCollapsedState] = useState(() => readStoredPanelCollapsed())
const [width, setWidthState] = useState(() => readStoredPanelWidth())
const [fullScreen, setFullScreen] = useState(false)
const fileContents = useMemo(() => {
const defaults: Record<string, { original: string; modified: string }> = {
"app.js": {
original: "export function init() {\n mount();\n}\n",
modified: "export function init() {\n // test comment\n mount();\n}\n",
},
"contextPanel.js": {
original: "export function renderPanel() {\n return panel;\n}\n",
modified: "export function renderPanel() {\n // test comment\n return panel;\n}\n",
},
"settings.js": {
original: "export const defaults = {};\n",
modified: "export const defaults = {};\n// test comment\n",
},
};
const setCollapsed = useCallback((next: boolean) => {
setCollapsedState(next)
window.localStorage.setItem(PANEL_STORAGE_COLLAPSED, next ? "1" : "0")
}, [])
return (thread.changedFiles ?? []).map((file) => ({
path: file.path,
const setWidth = useCallback((next: number) => {
const clamped = Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, next))
setWidthState(clamped)
window.localStorage.setItem(PANEL_STORAGE_WIDTH, String(clamped))
}, [])
const [selectedTreePath, setSelectedTreePath] = useState<string | null>(null)
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({})
const pr = thread.pr
const chunks = useMemo(
() => thread.messages.flatMap((message) => message.chunks),
[thread.messages]
)
const files = useMemo<Array<PanelFile>>(() => {
const summary = summarizeChangedFiles(chunks)
const prefix = commonDirPrefix(summary.map((file) => file.filePath))
return summary.map((file) => ({
filePath: file.filePath,
treePath:
prefix && file.filePath.startsWith(prefix)
? file.filePath.slice(prefix.length)
: file.filePath,
additions: file.additions,
original: defaults[file.path]?.original ?? "",
modified: defaults[file.path]?.modified ?? file.patch ?? "",
}));
}, [thread.changedFiles]);
deletions: file.deletions,
originalContent: file.originalContent,
modifiedContent: file.modifiedContent,
status: deriveStatus(file),
}))
}, [chunks])
const totals = useMemo(
() =>
files.reduce(
(acc, file) => ({
additions: acc.additions + file.additions,
deletions: acc.deletions + file.deletions,
}),
{ additions: 0, deletions: 0 }
),
[files]
)
useEffect(() => {
if (!selectedTreePath) return
const target = files.find((file) => file.treePath === selectedTreePath)
if (!target) return
sectionRefs.current[target.filePath]?.scrollIntoView({
block: "start",
behavior: "smooth",
})
}, [selectedTreePath, files])
if (collapsed) {
return (
<button
type="button"
onClick={() => setCollapsed(false)}
aria-label="Expand git panel"
title="Expand git panel"
className="fixed top-3 right-3 z-30 flex size-7 items-center justify-center rounded-md border border-border bg-background text-muted-foreground shadow-sm hover:bg-accent hover:text-foreground"
>
<SidebarSimpleIcon className="size-4" />
</button>
)
}
return (
<aside className="flex h-full w-[420px] shrink-0 flex-col border-l border-[var(--ui-border)] bg-[var(--ui-surface)]">
<div className="flex h-11 shrink-0 items-center gap-1 border-b border-[var(--ui-border)] px-3">
{(["Git", "Desktop", "Terminal"] as const).map((label, i) => (
<aside
className={cn(
"relative flex shrink-0 flex-col bg-[var(--ui-bg)]",
fullScreen ? "fixed inset-0 !w-full" : "h-full"
)}
style={fullScreen ? { zIndex: Z.MODAL } : { width }}
>
<div className="flex h-11 shrink-0 items-center gap-1 px-3">
{(
[
["git", "Git"],
["desktop", "Desktop"],
["terminal", "Terminal"],
] as const
).map(([id, label]) => (
<button
key={label}
key={id}
type="button"
onClick={() => setTopTab(id)}
className={cn(
"rounded-md px-2.5 py-1 text-xs transition-colors",
i === 0
topTab === id
? "bg-[var(--ui-accent-bubble)] font-medium text-[var(--ui-text)]"
: "text-[var(--ui-text-dim)] hover:bg-[var(--ui-panel-2)]",
: "text-[var(--ui-text-dim)] hover:bg-[var(--ui-panel-2)]"
)}
>
{label}
</button>
))}
<button
type="button"
onClick={() => {
setFullScreen(false)
setCollapsed(true)
}}
aria-label="Collapse git panel"
title="Collapse git panel"
className="ml-auto rounded-md p-1.5 text-[var(--ui-text-dim)] transition-colors hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)]"
>
<SidebarSimpleIcon className="size-4" />
</button>
<button
type="button"
onClick={() => setFullScreen((v) => !v)}
aria-label={fullScreen ? "Exit full screen" : "Enter full screen"}
className="rounded-md p-1.5 text-[var(--ui-text-dim)] transition-colors hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)]"
>
{fullScreen ? (
<ArrowsInIcon className="size-4" />
) : (
<ArrowsOutIcon className="size-4" />
)}
</button>
</div>
<div
className={cn(
"flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-[var(--ui-border)] bg-[var(--ui-surface)] shadow-sm",
fullScreen ? "mx-3 mb-3" : "mr-4 mb-4 ml-1"
)}
>
{topTab !== "git" ? (
<div className="flex flex-1 items-center justify-center p-6 text-xs text-[var(--ui-text-dim)]">
Coming Soon
</div>
) : (
<>
{pr && (
<div className="border-b border-[var(--ui-border)] px-4 py-3">
<div className="flex items-start justify-between gap-3">
@ -74,12 +318,6 @@ export function AgentGitPanel({ thread }: AgentGitPanelProps) {
</span>
</div>
</div>
<button
type="button"
className="shrink-0 rounded-md border border-[var(--ui-border)] px-2.5 py-1 text-xs hover:bg-[var(--ui-panel-2)]"
>
Mark as ready
</button>
</div>
</div>
)}
@ -89,7 +327,7 @@ export function AgentGitPanel({ thread }: AgentGitPanelProps) {
[
["diff", "Diff"],
["review", "Review"],
["commits", `Commits (${fileContents.length})`],
["commits", "Commits"],
] as const
).map(([id, label]) => (
<button
@ -100,58 +338,155 @@ export function AgentGitPanel({ thread }: AgentGitPanelProps) {
"rounded-md px-2.5 py-1 text-xs transition-colors",
tab === id
? "bg-[var(--ui-accent-bubble)] font-medium text-[var(--ui-text)]"
: "text-[var(--ui-text-dim)] hover:bg-[var(--ui-panel-2)]",
: "text-[var(--ui-text-dim)] hover:bg-[var(--ui-panel-2)]"
)}
>
{label}
</button>
))}
{files.length > 0 && (
<span className="ml-auto flex items-center gap-2 text-[11px] text-[var(--ui-text-dim)]">
<span>
{files.length} file{files.length === 1 ? "" : "s"}
</span>
<span className="text-[var(--ui-success)]">
+{totals.additions}
</span>
<span className="text-[var(--ui-danger)]">-{totals.deletions}</span>
</span>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{tab === "diff" && fileContents.length > 0 ? (
<div className="space-y-2 p-2">
{fileContents.map((file) => (
<FileDiffSection key={file.path} file={file} />
))}
</div>
) : (
<div className="p-6 text-center text-xs text-[var(--ui-text-dim)]">
{tab === "commits" ? "Commit history will appear here." : "No diff available."}
<div className="flex min-h-0 flex-1">
<div className="min-h-0 flex-1 overflow-y-auto">
{tab === "diff" && files.length > 0 ? (
<div className="space-y-2 p-2">
{files.map((file) => (
<FileDiffSection
key={file.filePath}
file={file}
sectionRef={(node) => {
sectionRefs.current[file.filePath] = node
}}
/>
))}
</div>
) : (
<div className="p-6 text-center text-xs text-[var(--ui-text-dim)]">
{tab === "diff" ? "No diff available." : "Coming Soon"}
</div>
)}
</div>
{fullScreen && files.length > 0 && (
<div className="w-72 shrink-0 border-l border-[var(--ui-border)] bg-[var(--ui-surface)]">
<FileTreeExplorer
files={files}
selectedTreePath={selectedTreePath}
onSelect={setSelectedTreePath}
/>
</div>
)}
</div>
</>
)}
</div>
{!fullScreen && <PanelResizeHandle width={width} onResize={setWidth} />}
</aside>
);
)
}
function FileDiffSection({
file,
sectionRef,
}: {
file: { path: string; additions: number; original: string; modified: string };
file: PanelFile
sectionRef: (node: HTMLDivElement | null) => void
}) {
const [open, setOpen] = useState(true);
const [open, setOpen] = useState(true)
const diffOptions = useDiffOptions()
return (
<div className="mb-2 overflow-hidden rounded-lg border border-[var(--ui-border)]">
<div
ref={sectionRef}
className="mb-2 scroll-mt-2 overflow-hidden rounded-lg border border-[var(--ui-border)]"
>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-2 bg-[var(--ui-panel-2)] px-3 py-2 text-left text-xs"
>
<CaretDownIcon className={cn("size-3 transition-transform", !open && "-rotate-90")} />
<span className="font-medium text-[var(--ui-text)]">{file.path}</span>
<span className="ml-auto text-[var(--ui-success)]">+{file.additions}</span>
<CaretDownIcon
className={cn("size-3 transition-transform", !open && "-rotate-90")}
/>
<span className="truncate font-medium text-[var(--ui-text)]">
{file.treePath}
</span>
<span className="ml-auto flex shrink-0 items-center gap-2">
<span className="text-[var(--ui-success)]">+{file.additions}</span>
<span className="text-[var(--ui-danger)]">-{file.deletions}</span>
</span>
</button>
{open && (
<div className="max-h-[320px] overflow-auto bg-[var(--ui-panel)] p-2 font-mono text-[11px] leading-5">
<div className="max-h-[420px] overflow-auto bg-[var(--ui-panel)] p-2 font-mono text-[11px] leading-5">
<MultiFileDiff
oldFile={{ name: file.path, contents: file.original }}
newFile={{ name: file.path, contents: file.modified }}
oldFile={{ name: file.treePath, contents: file.originalContent }}
newFile={{ name: file.treePath, contents: file.modifiedContent }}
options={diffOptions}
/>
</div>
)}
</div>
);
)
}
function FileTreeExplorer({
files,
selectedTreePath,
onSelect,
}: {
files: Array<PanelFile>
selectedTreePath: string | null
onSelect: (path: string) => void
}) {
const paths = useMemo(() => files.map((file) => file.treePath), [files])
const gitStatus = useMemo<Array<GitStatusEntry>>(
() => files.map((file) => ({ path: file.treePath, status: file.status })),
[files]
)
const { model } = useFileTree({
paths,
gitStatus,
initialExpansion: "open",
flattenEmptyDirectories: true,
search: true,
icons: "standard",
})
useEffect(() => {
model.resetPaths(paths)
}, [model, paths])
useEffect(() => {
model.setGitStatus(gitStatus)
}, [model, gitStatus])
const selection = useFileTreeSelection(model)
useEffect(() => {
const path = selection[0]
if (path) onSelect(path)
}, [selection, onSelect])
useEffect(() => {
if (selectedTreePath) {
model.scrollToPath(selectedTreePath, { focus: false })
}
}, [model, selectedTreePath])
return (
<div className="flex h-full flex-col">
<FileTree model={model} style={{ height: "100%", ...treeThemeStyle() }} />
</div>
)
}

View file

@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query"
import type { PendingPrompt } from "@/lib/agents/pendingPrompts"
import type { AgentThread, ImageChunk, Message } from "@/lib/agents/types"
import type { ModelSelection } from "@/lib/agents/useModelOptions"
import { AgentGitPanel } from "@/components/agents/AgentGitPanel"
import { AgentPromptBar } from "@/components/agents/AgentPromptBar"
import { MessageView } from "@/components/agents/ported"
import {
@ -169,8 +170,8 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
hasActiveRun && thread.messages.length === 0 && pendingPrompts.length > 0
return (
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex min-w-0 flex-1">
<div className="flex min-w-0 flex-1 flex-col">
{hasMessages ? (
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<MessageView
@ -220,6 +221,7 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
</div>
)}
</div>
<AgentGitPanel thread={thread} />
</div>
)
}

View file

@ -360,14 +360,14 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({
<ImagePlus className="size-4" />
</button>
{canCancel && (
{canCancel && !canSubmit ? (
<button
type="button"
onClick={onCancel}
disabled={cancelling}
aria-label="Cancel run"
title="Cancel run"
className="flex size-7 shrink-0 items-center justify-center rounded-full border border-[var(--ui-border)] bg-[var(--ui-panel-2)] text-[color:var(--ui-text-muted)] transition-colors hover:text-[color:var(--ui-text)] disabled:cursor-default disabled:opacity-60"
className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[var(--ui-accent)] text-white transition-opacity hover:opacity-90 disabled:cursor-default disabled:opacity-60"
>
{cancelling ? (
<LoaderCircle className="size-3.5 animate-spin" />
@ -379,21 +379,21 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({
/>
)}
</button>
) : (
<button
type="button"
onClick={handleSubmit}
disabled={!canSubmit}
aria-label="Send message"
className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[var(--ui-accent)] text-white transition-opacity hover:opacity-90 disabled:cursor-default disabled:opacity-40"
>
{disabled ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<ArrowUp className="size-3.5" strokeWidth={2.5} />
)}
</button>
)}
<button
type="button"
onClick={handleSubmit}
disabled={!canSubmit}
aria-label="Send message"
className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[var(--ui-accent)] text-white transition-opacity hover:opacity-90 disabled:cursor-default disabled:opacity-40"
>
{disabled ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<ArrowUp className="size-3.5" strokeWidth={2.5} />
)}
</button>
</div>
</div>
</div>

View file

@ -3,7 +3,7 @@ import { useRef, useEffect, useLayoutEffect, useCallback, memo, useMemo, useStat
import { ChevronDown, ChevronUp } from "lucide-react";
import { diffLines } from "diff";
import { MultiFileDiff } from "@pierre/diffs/react";
import { diffOptions } from "@/components/agents/utils/diffUtils";
import { useDiffOptions } from "@/components/agents/utils/diffUtils";
import { CodeBlock } from "./CodeBlock";
import { Markdown } from "./Markdown";
import { ToolExecution } from "./ToolExecution";
@ -180,6 +180,7 @@ const TurnChangedFilesCard = memo(function TurnChangedFilesCard({
projectPath?: string;
}) {
const [expandedByPath, setExpandedByPath] = useState<Record<string, boolean>>({});
const diffOptions = useDiffOptions();
const toggleFile = useCallback((filePath: string) => {
setExpandedByPath((prev) => ({ ...prev, [filePath]: !prev[filePath] }));

View file

@ -21,7 +21,7 @@ import {
X,
Undo2,
} from "lucide-react";
import { diffOptions } from "@/components/agents/utils/diffUtils";
import { useDiffOptions } from "@/components/agents/utils/diffUtils";
const STATUS_LABELS: Partial<Record<GitFileStatus, string>> = {
"index-modified": "M",
@ -961,6 +961,7 @@ const DiffCard = memo(function DiffCard({
defaultExpanded: boolean;
}) {
const [expanded, setExpanded] = useState(defaultExpanded);
const diffOptions = useDiffOptions();
return (
<div

View file

@ -1,9 +1,9 @@
import { memo, useState, useCallback, useRef, useLayoutEffect } from "react";
import { memo, useState, useCallback, useMemo, useRef, useLayoutEffect } from "react";
import { diffLines } from "diff";
import { MultiFileDiff } from "@pierre/diffs/react";
import type { ToolExecutionChunk, AcpToolKind } from "@/lib/agents/types";
import { DiffView } from "./DiffView";
import { diffOptions } from "@/components/agents/utils/diffUtils";
import { useDiffOptions } from "@/components/agents/utils/diffUtils";
interface ToolExecutionProps {
chunk: ToolExecutionChunk;
@ -106,11 +106,6 @@ function formatToolDisplay(
}
}
const inlineDiffOptions = {
...diffOptions,
disableFileHeader: true,
};
const InlineDiffCollapsible = memo(function InlineDiffCollapsible({
filePath,
fileName,
@ -130,6 +125,11 @@ const InlineDiffCollapsible = memo(function InlineDiffCollapsible({
}) {
const [expanded, setExpanded] = useState(false);
const toggle = useCallback(() => setExpanded((prev) => !prev), []);
const diffOptions = useDiffOptions();
const inlineDiffOptions = useMemo(
() => ({ ...diffOptions, disableFileHeader: true }),
[diffOptions]
);
const scrollRef = useRef<HTMLDivElement>(null);
const [scrolledFromTop, setScrolledFromTop] = useState(false);
const [scrolledFromBottom, setScrolledFromBottom] = useState(false);

View file

@ -7,7 +7,12 @@ export { HeaderBar } from "./HeaderBar";
export { Logo } from "./Logo";
export { Markdown } from "./Markdown";
export { MarkdownTable } from "./MarkdownTable";
export { MessageView, summarizeChangedFiles, type MessageViewScrollControl } from "./MessageView";
export {
MessageView,
summarizeChangedFiles,
type ChangedFileSummaryItem,
type MessageViewScrollControl,
} from "./MessageView";
export { PanelResizeHandle } from "./PanelResizeHandle";
export { ShellCommand } from "./ShellCommand";
export { TodoList } from "./TodoList";

View file

@ -1,3 +1,6 @@
import { useMemo } from "react";
import { useResolvedTheme } from "@/lib/theme";
export const DIFF_UNSAFE_CSS = `
[data-diffs-header],
[data-diff],
@ -62,3 +65,11 @@ export const diffOptions = {
unsafeCSS: DIFF_UNSAFE_CSS,
collapsedContextThreshold: 4,
};
export function useDiffOptions() {
const resolvedTheme = useResolvedTheme();
return useMemo(
() => ({ ...diffOptions, themeType: resolvedTheme }),
[resolvedTheme]
);
}

View file

@ -125,8 +125,8 @@ export interface FileRoutesByFullPath {
'/review': typeof ReviewRoute
'/usage': typeof UsageRoute
'/agents/$threadId': typeof AgentsThreadIdRoute
'/review/styles': typeof ReviewStylesRoute
'/agents/instructions': typeof AgentsInstructionsRoute
'/review/styles': typeof ReviewStylesRoute
'/agents/': typeof AgentsIndexRoute
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
'/agents/automations/new': typeof AgentsAutomationsNewRoute
@ -143,8 +143,8 @@ export interface FileRoutesByTo {
'/review': typeof ReviewRoute
'/usage': typeof UsageRoute
'/agents/$threadId': typeof AgentsThreadIdRoute
'/review/styles': typeof ReviewStylesRoute
'/agents/instructions': typeof AgentsInstructionsRoute
'/review/styles': typeof ReviewStylesRoute
'/agents': typeof AgentsIndexRoute
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
'/agents/automations/new': typeof AgentsAutomationsNewRoute
@ -163,8 +163,8 @@ export interface FileRoutesById {
'/review': typeof ReviewRoute
'/usage': typeof UsageRoute
'/agents/$threadId': typeof AgentsThreadIdRoute
'/review_/styles': typeof ReviewStylesRoute
'/agents_/instructions': typeof AgentsInstructionsRoute
'/review_/styles': typeof ReviewStylesRoute
'/agents/': typeof AgentsIndexRoute
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
'/agents/automations/new': typeof AgentsAutomationsNewRoute
@ -184,8 +184,8 @@ export interface FileRouteTypes {
| '/review'
| '/usage'
| '/agents/$threadId'
| '/review/styles'
| '/agents/instructions'
| '/review/styles'
| '/agents/'
| '/agents/automations/$scheduleId'
| '/agents/automations/new'
@ -202,8 +202,8 @@ export interface FileRouteTypes {
| '/review'
| '/usage'
| '/agents/$threadId'
| '/review/styles'
| '/agents/instructions'
| '/review/styles'
| '/agents'
| '/agents/automations/$scheduleId'
| '/agents/automations/new'
@ -221,8 +221,8 @@ export interface FileRouteTypes {
| '/review'
| '/usage'
| '/agents/$threadId'
| '/review_/styles'
| '/agents_/instructions'
| '/review_/styles'
| '/agents/'
| '/agents/automations/$scheduleId'
| '/agents/automations/new'
@ -240,8 +240,8 @@ export interface RootRouteChildren {
MySettingsRoute: typeof MySettingsRoute
ReviewRoute: typeof ReviewRoute
UsageRoute: typeof UsageRoute
ReviewStylesRoute: typeof ReviewStylesRoute
AgentsInstructionsRoute: typeof AgentsInstructionsRoute
ReviewStylesRoute: typeof ReviewStylesRoute
ReviewRepositoriesOwnerRoute: typeof ReviewRepositoriesOwnerRoute
}
@ -398,8 +398,8 @@ const rootRouteChildren: RootRouteChildren = {
MySettingsRoute: MySettingsRoute,
ReviewRoute: ReviewRoute,
UsageRoute: UsageRoute,
ReviewStylesRoute: ReviewStylesRoute,
AgentsInstructionsRoute: AgentsInstructionsRoute,
ReviewStylesRoute: ReviewStylesRoute,
ReviewRepositoriesOwnerRoute: ReviewRepositoriesOwnerRoute,
}
export const routeTree = rootRouteImport

View file

@ -1658,6 +1658,14 @@
resolved "https://registry.yarnpkg.com/@pierre/theme/-/theme-1.0.3.tgz#eec66e55576f981000f4816263632d3ba1479229"
integrity sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA==
"@pierre/trees@1.0.0-beta.4":
version "1.0.0-beta.4"
resolved "https://registry.yarnpkg.com/@pierre/trees/-/trees-1.0.0-beta.4.tgz#d19881431e2396c19641b6bd36d1d4e5a267cac8"
integrity sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag==
dependencies:
preact "11.0.0-beta.0"
preact-render-to-string "6.6.5"
"@rolldown/binding-android-arm64@1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz#ebe1264e43ba5bb224c58c85e0ac238f87e5ad14"
@ -6710,6 +6718,16 @@ powershell-utils@^0.1.0:
resolved "https://registry.yarnpkg.com/powershell-utils/-/powershell-utils-0.1.0.tgz#5a42c9a824fb4f2f251ccb41aaae73314f5d6ac2"
integrity sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==
preact-render-to-string@6.6.5:
version "6.6.5"
resolved "https://registry.yarnpkg.com/preact-render-to-string/-/preact-render-to-string-6.6.5.tgz#1be84cc8565e3bcfcf2a33cb9c61185e6498ab31"
integrity sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA==
preact@11.0.0-beta.0:
version "11.0.0-beta.0"
resolved "https://registry.yarnpkg.com/preact/-/preact-11.0.0-beta.0.tgz#c6e5c6e8657abf18b4a637eae88b5b377d36973f"
integrity sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg==
prettier-plugin-tailwindcss@^0.7.2:
version "0.7.4"
resolved "https://registry.yarnpkg.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.4.tgz#eadd3f9d2e4bfba2994b22f80dda25804a608a9a"