mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 10:23:14 +00:00
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:
parent
da20e57bcc
commit
8f983e9e40
6 changed files with 247 additions and 28 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AgentGitPanel thread={thread} />
|
||||
<AgentGitPanel thread={thread} messages={baseMessages} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue