diff --git a/agent/dashboard/routes.py b/agent/dashboard/routes.py index 3537fcaf..2f78bcbe 100644 --- a/agent/dashboard/routes.py +++ b/agent/dashboard/routes.py @@ -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, diff --git a/agent/dashboard/thread_api.py b/agent/dashboard/thread_api.py index ab8b583c..a6ef69c7 100644 --- a/agent/dashboard/thread_api.py +++ b/agent/dashboard/thread_api.py @@ -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, diff --git a/ui/src/components/agents/AgentGitPanel.tsx b/ui/src/components/agents/AgentGitPanel.tsx index 09c0fc7d..050ce3dd 100644 --- a/ui/src/components/agents/AgentGitPanel.tsx +++ b/ui/src/components/agents/AgentGitPanel.tsx @@ -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 } 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 { } 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>({}) 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>(() => { + 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) { + {pr.url && ( + + + View PR + + )} )} @@ -373,7 +412,11 @@ export function AgentGitPanel({ thread }: AgentGitPanelProps) { ) : (
- {tab === "diff" ? "No diff available." : "Coming Soon"} + {tab !== "diff" + ? "Coming Soon" + : prDiff.isLoading + ? "Loading PR diff…" + : "No diff available."}
)} @@ -427,15 +470,20 @@ function FileDiffSection({ -{file.deletions} - {open && ( -
- -
- )} + {open && + (file.unrenderable ? ( +
+ Binary or large file — diff not shown. +
+ ) : ( +
+ +
+ ))} ) } diff --git a/ui/src/components/agents/AgentThreadView.tsx b/ui/src/components/agents/AgentThreadView.tsx index 22a94e6d..7780adc4 100644 --- a/ui/src/components/agents/AgentThreadView.tsx +++ b/ui/src/components/agents/AgentThreadView.tsx @@ -122,7 +122,7 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) { )} - + ) } diff --git a/ui/src/lib/agents/api.ts b/ui/src/lib/agents/api.ts index 0cb6a712..71899115 100644 --- a/ui/src/lib/agents/api.ts +++ b/ui/src/lib/agents/api.ts @@ -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 +} + const API_BASE = (import.meta.env.VITE_DASHBOARD_API_BASE_URL ?? "").replace( /\/$/, "" @@ -122,6 +141,10 @@ export const agentsApi = { agentsRequest(`/threads/${encodeURIComponent(threadId)}`, { method: "DELETE", }), + getThreadPrDiff: (threadId: string) => + agentsRequest( + `/threads/${encodeURIComponent(threadId)}/pr-diff` + ), streamUrl: (threadId: string) => `${API_BASE}/dashboard/api/threads/${encodeURIComponent(threadId)}/stream`, } diff --git a/ui/src/lib/agents/queries.ts b/ui/src/lib/agents/queries.ts index c0eb72c4..f21d401e 100644 --- a/ui/src/lib/agents/queries.ts +++ b/ui/src/lib/agents/queries.ts @@ -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,