feat: add View PR link in git panel header (#1498)

* feat: add View PR link in git panel header

Surface a clickable "View PR" link in the agent git panel header so
users can jump straight from a chat thread to its pull request instead
of hunting for the URL in the conversation. Uses the pr.url already
captured in thread metadata.

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

* refactor: use shared buttonVariants for View PR link

Style the View PR link with the shared buttonVariants (outline/sm)
helper instead of hand-rolled classes, matching the existing
anchor-as-button pattern used in login.tsx and AutomationsList.tsx.

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

* feat: show PR diff in git panel diff tab

Adds /threads/{id}/pr-diff endpoint fetching PR files + contents from GitHub;
git panel prefers it and falls back to the live message-derived diff.

* feat: auto-expand git panel when a PR lands mid-run

Panel still defaults to collapsed and remembers manual toggles; the
auto-expand is ephemeral and not written to localStorage.

* fix: git panel always starts collapsed

Drop localStorage persistence of collapsed state; panel opens only via
manual toggle or when a PR lands mid-run, and re-collapses on thread switch.

* fix: address PR review findings

Require user OAuth token for pr-diff (no app-token fallback) so GitHub
enforces current repo access; render placeholder for binary/oversized files.

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Caroline di Vittorio 2026-06-11 11:48:53 -07:00 • committed by GitHub
parent da20e57bcc
commit 8f983e9e40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 247 additions and 28 deletions

View file

@ -110,6 +110,7 @@ from .thread_api import (
cancel_dashboard_thread,
delete_dashboard_thread,
get_dashboard_thread,
get_dashboard_thread_pr_diff,
get_dashboard_thread_state,
list_dashboard_threads,
proxy_dashboard_thread_commands,
@ -923,6 +924,18 @@ async def api_get_thread(
)
@router.get("/threads/{thread_id}/pr-diff")
async def api_get_thread_pr_diff(
thread_id: str,
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
return await get_dashboard_thread_pr_diff(
thread_id,
session["sub"],
email=session.get("email"),
)
@router.post("/threads/{thread_id}/messages")
async def api_send_thread_message(
thread_id: str,

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import base64
import binascii
import json
@ -891,6 +892,129 @@ async def get_dashboard_thread_state(
return result
_PR_DIFF_MAX_FILES = 50
_PR_DIFF_MAX_FILE_BYTES = 200_000
_PR_DIFF_FETCH_CONCURRENCY = 5
_GITHUB_API = "https://api.github.com"
# No app-installation-token fallback: PR file contents must be fetched with
# the user's own credential so GitHub enforces their current repo access.
async def _github_token_for_login(login: str) -> str:
token = await get_valid_access_token(login)
if not token:
raise HTTPException(401, "github token unavailable, re-login required")
return token
async def _fetch_file_at_ref(
client: httpx.AsyncClient,
semaphore: asyncio.Semaphore,
full_name: str,
path: str,
ref: str,
) -> str | None:
async with semaphore:
response = await client.get(
f"{_GITHUB_API}/repos/{full_name}/contents/{path}",
params={"ref": ref},
headers={"Accept": "application/vnd.github.raw+json"},
)
if response.status_code == 404:
return ""
if response.status_code != 200:
return None
if len(response.content) > _PR_DIFF_MAX_FILE_BYTES:
return None
try:
return response.content.decode("utf-8")
except UnicodeDecodeError:
return None
async def get_dashboard_thread_pr_diff(
thread_id: str, login: str, *, email: str | None = None
) -> dict[str, Any]:
metadata = await _authorized_thread_metadata(thread_id, login, email=email)
pr_number = metadata.get("pr_number")
_, _, full_name = _metadata_repo(metadata)
if not isinstance(pr_number, int) or not full_name:
raise HTTPException(404, "thread has no pull request")
token = await _github_token_for_login(login)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
async with httpx.AsyncClient(headers=headers, timeout=_PROXY_REQUEST_TIMEOUT) as client:
pull_response = await client.get(f"{_GITHUB_API}/repos/{full_name}/pulls/{pr_number}")
if pull_response.status_code == 404:
raise HTTPException(404, "pull request not found")
if pull_response.status_code != 200:
raise HTTPException(502, f"github API error ({pull_response.status_code})")
pull = pull_response.json()
base_sha = pull.get("base", {}).get("sha")
head_sha = pull.get("head", {}).get("sha")
if not isinstance(base_sha, str) or not isinstance(head_sha, str):
raise HTTPException(502, "github API returned an unexpected pull request payload")
files_response = await client.get(
f"{_GITHUB_API}/repos/{full_name}/pulls/{pr_number}/files",
params={"per_page": 100},
)
if files_response.status_code != 200:
raise HTTPException(502, f"github API error ({files_response.status_code})")
raw_files = files_response.json()
if not isinstance(raw_files, list):
raise HTTPException(502, "github API returned an unexpected files payload")
truncated = len(raw_files) > _PR_DIFF_MAX_FILES
raw_files = raw_files[:_PR_DIFF_MAX_FILES]
semaphore = asyncio.Semaphore(_PR_DIFF_FETCH_CONCURRENCY)
async def build_entry(raw: dict[str, Any]) -> dict[str, Any] | None:
path = raw.get("filename")
if not isinstance(path, str):
return None
status = raw.get("status") if isinstance(raw.get("status"), str) else "modified"
previous = raw.get("previous_filename")
original_path = previous if isinstance(previous, str) else path
original: str | None = ""
modified: str | None = ""
if status != "added":
original = await _fetch_file_at_ref(
client, semaphore, full_name, original_path, base_sha
)
if status != "removed":
modified = await _fetch_file_at_ref(client, semaphore, full_name, path, head_sha)
return {
"path": path,
"previousPath": previous if isinstance(previous, str) else None,
"status": status,
"additions": raw.get("additions") if isinstance(raw.get("additions"), int) else 0,
"deletions": raw.get("deletions") if isinstance(raw.get("deletions"), int) else 0,
"originalContent": original,
"modifiedContent": modified,
# Binary or oversized blobs come back as None — the client
# renders a placeholder instead of file contents.
"unrenderable": original is None or modified is None,
}
entries = await asyncio.gather(*(build_entry(raw) for raw in raw_files))
return {
"prNumber": pr_number,
"baseSha": base_sha,
"headSha": head_sha,
"truncated": truncated,
"files": [entry for entry in entries if entry is not None],
}
async def proxy_dashboard_thread_stream_events(
thread_id: str,
login: str,

View file

@ -6,6 +6,7 @@ import {
useFileTreeSelection,
} from "@pierre/trees/react"
import {
ArrowSquareOutIcon,
ArrowsInIcon,
ArrowsOutIcon,
CaretDownIcon,
@ -14,7 +15,10 @@ import {
} from "@phosphor-icons/react"
import type { GitStatus, GitStatusEntry } from "@pierre/trees"
import type { AgentThread } from "@/lib/agents/types"
import type { AgentThread, Message } from "@/lib/agents/types"
import type { ThreadPrDiffFile } from "@/lib/agents/api"
import { useAgentThreadPrDiff } from "@/lib/agents/queries"
import { buttonVariants } from "@/components/ui/button"
import type { ChangedFileSummaryItem } from "@/components/agents/messages"
import { useDiffOptions } from "@/components/agents/utils/diffUtils"
import { summarizeChangedFiles } from "@/components/agents/ported"
@ -23,6 +27,7 @@ import { cn } from "@/lib/utils"
interface AgentGitPanelProps {
thread: AgentThread
messages: Array<Message>
}
interface PanelFile {
@ -33,6 +38,13 @@ interface PanelFile {
originalContent: string
modifiedContent: string
status: GitStatus
unrenderable?: boolean
}
function prFileStatus(file: ThreadPrDiffFile): GitStatus {
if (file.status === "added") return "added"
if (file.status === "removed") return "deleted"
return "modified"
}
function deriveStatus(file: ChangedFileSummaryItem): GitStatus {
@ -60,7 +72,6 @@ function commonDirPrefix(paths: Array<string>): string {
}
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
@ -73,11 +84,6 @@ function readStoredPanelWidth(): number {
return Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, parsed))
}
function readStoredPanelCollapsed(): boolean {
if (typeof window === "undefined") return true
return window.localStorage.getItem(PANEL_STORAGE_COLLAPSED) !== "0"
}
function PanelResizeHandle({
width,
onResize,
@ -154,18 +160,13 @@ function treeThemeStyle(): React.CSSProperties {
} as React.CSSProperties
}
export function AgentGitPanel({ thread }: AgentGitPanelProps) {
export function AgentGitPanel({ thread, messages }: AgentGitPanelProps) {
const [topTab, setTopTab] = useState<"git" | "desktop" | "terminal">("git")
const [tab, setTab] = useState<"diff" | "review" | "commits">("diff")
const [collapsed, setCollapsedState] = useState(() => readStoredPanelCollapsed())
const [collapsed, setCollapsed] = useState(true)
const [width, setWidthState] = useState(() => readStoredPanelWidth())
const [fullScreen, setFullScreen] = useState(false)
const setCollapsed = useCallback((next: boolean) => {
setCollapsedState(next)
window.localStorage.setItem(PANEL_STORAGE_COLLAPSED, next ? "1" : "0")
}, [])
const setWidth = useCallback((next: number) => {
const clamped = Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, next))
setWidthState(clamped)
@ -175,12 +176,39 @@ export function AgentGitPanel({ thread }: AgentGitPanelProps) {
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({})
const pr = thread.pr
// Always start collapsed; re-collapse when switching threads, and
// uncollapse when a PR lands mid-session.
const [prSeen, setPrSeen] = useState<{ threadId: string; hadPr: boolean }>(
() => ({ threadId: thread.id, hadPr: Boolean(pr) })
)
if (prSeen.threadId !== thread.id) {
setPrSeen({ threadId: thread.id, hadPr: Boolean(pr) })
setCollapsed(true)
} else if (pr && !prSeen.hadPr) {
setPrSeen({ threadId: thread.id, hadPr: true })
setCollapsed(false)
}
const prDiff = useAgentThreadPrDiff(thread.id, Boolean(pr))
const chunks = useMemo(
() => thread.messages.flatMap((message) => message.chunks),
[thread.messages]
() => messages.flatMap((message) => message.chunks),
[messages]
)
const files = useMemo<Array<PanelFile>>(() => {
if (prDiff.data) {
return prDiff.data.files.map((file) => ({
filePath: file.path,
treePath: file.path,
additions: file.additions,
deletions: file.deletions,
originalContent: file.originalContent ?? "",
modifiedContent: file.modifiedContent ?? "",
status: prFileStatus(file),
unrenderable: file.unrenderable,
}))
}
const summary = summarizeChangedFiles(chunks)
const prefix = commonDirPrefix(summary.map((file) => file.filePath))
return summary.map((file) => ({
@ -195,7 +223,7 @@ export function AgentGitPanel({ thread }: AgentGitPanelProps) {
modifiedContent: file.modifiedContent,
status: deriveStatus(file),
}))
}, [chunks])
}, [chunks, prDiff.data])
const totals = useMemo(
() =>
@ -318,6 +346,17 @@ export function AgentGitPanel({ thread }: AgentGitPanelProps) {
</span>
</div>
</div>
{pr.url && (
<a
href={pr.url}
target="_blank"
rel="noreferrer"
className={buttonVariants({ variant: "outline", size: "sm" })}
>
<ArrowSquareOutIcon className="size-3" />
View PR
</a>
)}
</div>
</div>
)}
@ -373,7 +412,11 @@ export function AgentGitPanel({ thread }: AgentGitPanelProps) {
</div>
) : (
<div className="p-6 text-center text-xs text-[var(--ui-text-dim)]">
{tab === "diff" ? "No diff available." : "Coming Soon"}
{tab !== "diff"
? "Coming Soon"
: prDiff.isLoading
? "Loading PR diff…"
: "No diff available."}
</div>
)}
</div>
@ -427,15 +470,20 @@ function FileDiffSection({
<span className="text-[var(--ui-danger)]">-{file.deletions}</span>
</span>
</button>
{open && (
<div className="max-h-[420px] overflow-auto bg-[var(--ui-panel)] p-2 font-mono text-[11px] leading-5">
<MultiFileDiff
oldFile={{ name: file.treePath, contents: file.originalContent }}
newFile={{ name: file.treePath, contents: file.modifiedContent }}
options={diffOptions}
/>
</div>
)}
{open &&
(file.unrenderable ? (
<div className="bg-[var(--ui-panel)] p-4 text-center text-xs text-[var(--ui-text-dim)]">
Binary or large file — diff not shown.
</div>
) : (
<div className="max-h-[420px] overflow-auto bg-[var(--ui-panel)] p-2 font-mono text-[11px] leading-5">
<MultiFileDiff
oldFile={{ name: file.treePath, contents: file.originalContent }}
newFile={{ name: file.treePath, contents: file.modifiedContent }}
options={diffOptions}
/>
</div>
))}
</div>
)
}

View file

@ -122,7 +122,7 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
</div>
)}
</div>
<AgentGitPanel thread={thread} />
<AgentGitPanel thread={thread} messages={baseMessages} />
</div>
)
}

View file

@ -38,6 +38,25 @@ export interface ScheduleUpdateRequest {
enabled?: boolean | null
}
export interface ThreadPrDiffFile {
path: string
previousPath: string | null
status: "added" | "removed" | "modified" | "renamed" | string
additions: number
deletions: number
originalContent: string | null
modifiedContent: string | null
unrenderable: boolean
}
export interface ThreadPrDiff {
prNumber: number
baseSha: string
headSha: string
truncated: boolean
files: Array<ThreadPrDiffFile>
}
const API_BASE = (import.meta.env.VITE_DASHBOARD_API_BASE_URL ?? "").replace(
/\/$/,
""
@ -122,6 +141,10 @@ export const agentsApi = {
agentsRequest<void>(`/threads/${encodeURIComponent(threadId)}`, {
method: "DELETE",
}),
getThreadPrDiff: (threadId: string) =>
agentsRequest<ThreadPrDiff>(
`/threads/${encodeURIComponent(threadId)}/pr-diff`
),
streamUrl: (threadId: string) =>
`${API_BASE}/dashboard/api/threads/${encodeURIComponent(threadId)}/stream`,
}

View file

@ -9,6 +9,7 @@ import type { AgentThread, Chunk, ImageChunk, Message } from "./types"
export const agentThreadKeys = {
all: ["agent-threads"] as const,
detail: (threadId: string) => ["agent-threads", threadId] as const,
prDiff: (threadId: string) => ["agent-threads", threadId, "pr-diff"] as const,
}
export const agentScheduleKeys = {
@ -53,6 +54,16 @@ export function useAgentThread(threadId: string) {
})
}
export function useAgentThreadPrDiff(threadId: string, enabled: boolean) {
return useQuery({
queryKey: agentThreadKeys.prDiff(threadId),
queryFn: () => agentsApi.getThreadPrDiff(threadId),
enabled,
staleTime: 30_000,
retry: false,
})
}
export function useAgentSchedules() {
return useQuery({
queryKey: agentScheduleKeys.all,