diff --git a/src/main/lib/git/git-operations.ts b/src/main/lib/git/git-operations.ts index d961956dc..6fc7c69b9 100644 --- a/src/main/lib/git/git-operations.ts +++ b/src/main/lib/git/git-operations.ts @@ -71,27 +71,79 @@ export const createGitOperationsRouter = () => { z.object({ worktreePath: z.string(), branch: z.string(), + /** "auto-stash" stashes uncommitted changes and pops them on the + * new branch; "carry" attempts a plain checkout which git will + * allow only if there's no conflict; default aborts if dirty. */ + uncommittedStrategy: z + .enum(["abort", "carry", "stash"]) + .optional() + .default("abort"), }), ) - .mutation(async ({ input }): Promise<{ success: boolean }> => { - assertRegisteredWorktree(input.worktreePath); + .mutation( + async ({ + input, + }): Promise<{ success: boolean; stashPopFailed?: boolean }> => { + assertRegisteredWorktree(input.worktreePath); - return withGitLock(input.worktreePath, async () => { - // Check for uncommitted changes before checkout - if (await hasUncommittedChanges(input.worktreePath)) { - throw new Error( - "Cannot switch branches: you have uncommitted changes. Please commit or stash your changes first." - ); - } + return withGitLock(input.worktreePath, async () => { + const dirty = await hasUncommittedChanges(input.worktreePath); - const git = createGit(input.worktreePath); - await withLockRetry(input.worktreePath, () => - git.checkout(input.branch) - ); - invalidateGitStateCaches(input.worktreePath); - return { success: true }; - }); - }), + if (dirty && input.uncommittedStrategy === "abort") { + throw new Error( + "Cannot switch branches: you have uncommitted changes. Please commit or stash your changes first." + ); + } + + const git = createGit(input.worktreePath); + + if (dirty && input.uncommittedStrategy === "stash") { + await withLockRetry(input.worktreePath, () => + git.stash([ + "push", + "-u", + "-m", + `Auto-stash before switching to ${input.branch}`, + ]), + ); + } + + try { + await withLockRetry(input.worktreePath, () => + git.checkout(input.branch) + ); + } catch (checkoutError) { + // If we stashed, try to pop back so the user doesn't lose work + if (dirty && input.uncommittedStrategy === "stash") { + try { + await git.stash(["pop"]); + } catch { + const msg = + checkoutError instanceof Error + ? checkoutError.message + : "Checkout failed"; + throw new Error( + `${msg}. Your uncommitted changes are saved in git stash — run 'git stash pop' manually to restore them.`, + ); + } + } + throw checkoutError; + } + + let stashPopFailed = false; + if (dirty && input.uncommittedStrategy === "stash") { + try { + await git.stash(["pop"]); + } catch { + stashPopFailed = true; + } + } + + invalidateGitStateCaches(input.worktreePath); + return { success: true, stashPopFailed }; + }); + }, + ), getHistory: publicProcedure .input( diff --git a/src/main/lib/git/github/github.ts b/src/main/lib/git/github/github.ts index 5de4109b9..a223919b2 100644 --- a/src/main/lib/git/github/github.ts +++ b/src/main/lib/git/github/github.ts @@ -6,8 +6,10 @@ import { type CheckItem, type GHPRResponse, type GitHubStatus, + type PRComment, GHPRResponseSchema, GHRepoResponseSchema, + GHReviewCommentSchema, } from "./types"; const execFileAsync = promisify(execFile); @@ -16,6 +18,18 @@ const execFileAsync = promisify(execFile); const cache = new Map(); const CACHE_TTL_MS = 10_000; +/** + * Drop cached PR status for a worktree so the next fetch hits the real gh CLI. + * Call this after a mutation that changes PR state (title rename, merge, etc.). + */ +export function invalidateGitHubPRCache(worktreePath?: string): void { + if (worktreePath) { + cache.delete(worktreePath); + } else { + cache.clear(); + } +} + /** * Fetches GitHub PR status for a worktree using the `gh` CLI. * Returns null if `gh` is not installed, not authenticated, or on error. @@ -228,3 +242,148 @@ function computeChecksStatus( if (hasPending) return "pending"; return "success"; } + +// Cache for PR comments (30 second TTL — comments change less often than status) +const commentsCache = new Map< + string, + { data: PRComment[]; timestamp: number } +>(); +const COMMENTS_CACHE_TTL_MS = 30_000; + +/** + * Fetch both general (issue) and review (code-level) comments for the current + * branch's PR. Returns an empty array when there's no PR or gh can't reach it. + * Cached for 30 seconds per worktree. + */ +export async function fetchGitHubPRComments( + worktreePath: string, +): Promise { + const cached = commentsCache.get(worktreePath); + if (cached && Date.now() - cached.timestamp < COMMENTS_CACHE_TTL_MS) { + return cached.data; + } + + try { + const { stdout: branchOutput } = await execFileAsync( + "git", + ["rev-parse", "--abbrev-ref", "HEAD"], + { cwd: worktreePath }, + ); + const branchName = branchOutput.trim(); + if (!branchName) return []; + + let prNumber: number | null = null; + try { + const { stdout } = await execWithShellEnv( + "gh", + ["pr", "view", branchName, "--json", "number"], + { cwd: worktreePath }, + ); + const parsed = JSON.parse(stdout); + if (typeof parsed?.number === "number") { + prNumber = parsed.number; + } + } catch { + return []; + } + if (!prNumber) return []; + + const [issueStdout, reviewStdout] = await Promise.all([ + execWithShellEnv( + "gh", + ["pr", "view", String(prNumber), "--json", "comments"], + { cwd: worktreePath }, + ) + .then((r) => r.stdout) + .catch(() => null), + execWithShellEnv( + "gh", + [ + "api", + `repos/{owner}/{repo}/pulls/${prNumber}/comments`, + "--paginate", + ], + { cwd: worktreePath }, + ) + .then((r) => r.stdout) + .catch(() => null), + ]); + + const comments: PRComment[] = []; + + if (issueStdout) { + try { + const raw = JSON.parse(issueStdout); + const rawComments = Array.isArray(raw?.comments) ? raw.comments : []; + for (const c of rawComments) { + const login = c?.author?.login ?? "unknown"; + const createdAt = c?.createdAt ?? c?.created_at ?? null; + const body = typeof c?.body === "string" ? c.body : ""; + if (!createdAt) continue; + comments.push({ + id: typeof c?.id === "number" ? c.id : comments.length, + kind: "issue", + author: login, + avatarUrl: null, + createdAt, + body, + htmlUrl: c?.url ?? null, + }); + } + } catch (err) { + console.error("[GitHub] Failed to parse issue comments:", err); + } + } + + if (reviewStdout) { + try { + const raw = JSON.parse(reviewStdout); + const arr = Array.isArray(raw) ? raw : []; + for (const c of arr) { + const parsed = GHReviewCommentSchema.safeParse(c); + if (!parsed.success) continue; + const data = parsed.data; + comments.push({ + id: data.id, + kind: "review", + author: data.user?.login ?? "unknown", + avatarUrl: data.user?.avatar_url ?? null, + createdAt: data.created_at, + body: data.body ?? "", + htmlUrl: data.html_url ?? null, + path: data.path ?? null, + line: data.line ?? data.original_line ?? null, + diffHunk: data.diff_hunk ?? null, + }); + } + } catch (err) { + console.error("[GitHub] Failed to parse review comments:", err); + } + } + + comments.sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + + commentsCache.set(worktreePath, { + data: comments, + timestamp: Date.now(), + }); + return comments; + } catch (err) { + console.error("[GitHub] fetchGitHubPRComments failed:", err); + return []; + } +} + +/** + * Invalidate the PR comments cache for a worktree. + */ +export function invalidateGitHubPRCommentsCache(worktreePath?: string): void { + if (worktreePath) { + commentsCache.delete(worktreePath); + } else { + commentsCache.clear(); + } +} diff --git a/src/main/lib/git/github/index.ts b/src/main/lib/git/github/index.ts index 89b109a9c..c1d165175 100644 --- a/src/main/lib/git/github/index.ts +++ b/src/main/lib/git/github/index.ts @@ -1,2 +1,7 @@ -export { fetchGitHubPRStatus } from "./github"; -export type { CheckItem, GitHubStatus, MergeableStatus } from "./types"; +export { + fetchGitHubPRStatus, + fetchGitHubPRComments, + invalidateGitHubPRCache, + invalidateGitHubPRCommentsCache, +} from "./github"; +export type { CheckItem, GitHubStatus, MergeableStatus, PRComment } from "./types"; diff --git a/src/main/lib/git/github/types.ts b/src/main/lib/git/github/types.ts index 77c70d850..0329680cc 100644 --- a/src/main/lib/git/github/types.ts +++ b/src/main/lib/git/github/types.ts @@ -47,6 +47,45 @@ export const GHRepoResponseSchema = z.object({ url: z.string(), }); +/** Issue comment on a PR (from GET /repos/{owner}/{repo}/issues/{n}/comments) */ +export const GHIssueCommentSchema = z.object({ + id: z.number(), + body: z.string().nullable(), + created_at: z.string(), + updated_at: z.string().nullable().optional(), + html_url: z.string().nullable().optional(), + user: z + .object({ + login: z.string(), + avatar_url: z.string().nullable().optional(), + }) + .nullable() + .optional(), +}); + +/** Review comment on a PR (from GET /repos/{owner}/{repo}/pulls/{n}/comments) */ +export const GHReviewCommentSchema = GHIssueCommentSchema.extend({ + path: z.string().nullable().optional(), + line: z.number().nullable().optional(), + original_line: z.number().nullable().optional(), + diff_hunk: z.string().nullable().optional(), + position: z.number().nullable().optional(), +}); + +/** Unified comment type returned from the backend to the renderer */ +export interface PRComment { + id: number; + kind: "issue" | "review"; + author: string; + avatarUrl?: string | null; + createdAt: string; + body: string; + htmlUrl?: string | null; + path?: string | null; + line?: number | null; + diffHunk?: string | null; +} + export type GHPRResponse = z.infer; /** Single CI/CD check item */ diff --git a/src/main/lib/terminal/session.ts b/src/main/lib/terminal/session.ts index f5f057b7c..00751dede 100644 --- a/src/main/lib/terminal/session.ts +++ b/src/main/lib/terminal/session.ts @@ -130,6 +130,11 @@ export async function createSession( } = params const shell = useFallbackShell ? FALLBACK_SHELL : getDefaultShell() + if (!cwd) { + console.warn( + `[Terminal] No cwd provided for paneId=${paneId} — falling back to ${os.homedir()}. This usually means the workspace path wasn't ready when the terminal was created.`, + ) + } const workingDir = validateAndResolveCwd(cwd || os.homedir()) const terminalCols = cols || DEFAULT_COLS const terminalRows = rows || DEFAULT_ROWS diff --git a/src/main/lib/trpc/routers/chats.ts b/src/main/lib/trpc/routers/chats.ts index c3f19a396..f3aad2ad9 100644 --- a/src/main/lib/trpc/routers/chats.ts +++ b/src/main/lib/trpc/routers/chats.ts @@ -15,8 +15,10 @@ import { chats, getDatabase, projects, subChats } from "../../db" import { computeFileStatsFromMessages } from "../../file-stats" import { createWorktreeForChat, + fetchGitHubPRComments, fetchGitHubPRStatus, getWorktreeDiff, + invalidateGitHubPRCache, removeWorktree, sanitizeProjectName, } from "../../git" @@ -1587,7 +1589,11 @@ export const chatsRouter = router({ }), /** - * Get PR status from GitHub (via gh CLI) + * Get PR status from GitHub (via gh CLI). + * + * Back-fills `chat.prNumber` and `chat.prUrl` when the live fetch detects a + * PR — this keeps the sidebar workspace card (which reads from the DB) in + * sync without requiring callers to write those columns manually. */ getPrStatus: publicProcedure .input(z.object({ chatId: z.string() })) @@ -1603,7 +1609,24 @@ export const chatsRouter = router({ return null } - return await fetchGitHubPRStatus(chat.worktreePath) + const status = await fetchGitHubPRStatus(chat.worktreePath) + + // Back-fill DB so the sidebar badge can render from cached fields + const pr = status?.pr + const nextNumber = pr?.number ?? null + const nextUrl = pr?.url ?? null + if (nextNumber !== chat.prNumber || nextUrl !== chat.prUrl) { + try { + db.update(chats) + .set({ prNumber: nextNumber, prUrl: nextUrl }) + .where(eq(chats.id, input.chatId)) + .run() + } catch (err) { + console.error("[getPrStatus] Failed to back-fill PR fields:", err) + } + } + + return status }), /** @@ -1672,6 +1695,69 @@ export const chatsRouter = router({ } }), + /** + * Fetch issue + review comments for the current branch's PR. + */ + getPrComments: publicProcedure + .input(z.object({ chatId: z.string() })) + .query(async ({ input }) => { + const db = getDatabase() + const chat = db + .select() + .from(chats) + .where(eq(chats.id, input.chatId)) + .get() + + if (!chat?.worktreePath) return [] + return await fetchGitHubPRComments(chat.worktreePath) + }), + + /** + * Rename a PR title via `gh pr edit`. + * + * Caller passes the PR number explicitly so that switching branches + * between opening the dialog and saving can't rename the wrong PR. + * Falls back to the current-branch PR when `prNumber` is omitted. + */ + updatePrTitle: publicProcedure + .input( + z.object({ + chatId: z.string(), + title: z.string().trim().min(1).max(256), + prNumber: z.number().int().positive().optional(), + }), + ) + .mutation(async ({ input }) => { + const db = getDatabase() + const chat = db + .select() + .from(chats) + .where(eq(chats.id, input.chatId)) + .get() + + if (!chat?.worktreePath) { + throw new Error("No worktree path for this chat") + } + + const args = input.prNumber + ? ["pr", "edit", String(input.prNumber), "--title", input.title] + : ["pr", "edit", "--title", input.title] + + try { + await execWithShellEnv("gh", args, { cwd: chat.worktreePath }) + invalidateGitHubPRCache(chat.worktreePath) + return { success: true, title: input.title } + } catch (error) { + const errorMsg = + error instanceof Error ? error.message : "Failed to update PR title" + console.error("[updatePrTitle] Error:", error) + if (errorMsg.includes("no pull requests found")) { + throw new Error("No pull request exists for the current branch") + } + throw new Error(errorMsg) + } + }), + /** * Get file change stats for workspaces. * diff --git a/src/main/lib/trpc/routers/external.ts b/src/main/lib/trpc/routers/external.ts index 3a16b33db..b1b9318fb 100644 --- a/src/main/lib/trpc/routers/external.ts +++ b/src/main/lib/trpc/routers/external.ts @@ -1,5 +1,6 @@ import { clipboard, shell } from "electron"; -import { execFileSync, spawn } from "node:child_process"; +import { execFile, execFileSync, spawn } from "node:child_process"; +import { promisify } from "node:util"; import * as os from "node:os"; import * as path from "node:path"; import { z } from "zod"; @@ -9,6 +10,9 @@ import { externalAppSchema, type ExternalApp, } from "../../../../shared/external-apps"; +import { execWithShellEnv } from "../../git/shell-env"; + +const execFileAsync = promisify(execFile); function expandTilde(filePath: string): string { if (filePath.startsWith("~/") || filePath === "~") { @@ -17,29 +21,90 @@ function expandTilde(filePath: string): string { return filePath; } -function spawnAsync(command: string, args: string[]): Promise { +// CLI name per editor when one exists. Preferred over `open -a` because the +// `.app` bundle may be missing on systems installed via brew / standalone CLI. +const APP_CLI: Partial> = { + vscode: "code", + "vscode-insiders": "code-insiders", + cursor: "cursor", + windsurf: "windsurf", + zed: "zed", + sublime: "subl", + trae: "trae", + fleet: "fleet", + intellij: "idea", + webstorm: "webstorm", + pycharm: "pycharm", + phpstorm: "phpstorm", + rubymine: "rubymine", + goland: "goland", + clion: "clion", + rider: "rider", + datagrip: "datagrip", + appcode: "appcode", + rustrover: "rustrover", +}; + +function spawnDetached(command: string, args: string[]): Promise { return new Promise((resolve, reject) => { + let settled = false; const child = spawn(command, args, { detached: true, stdio: "ignore", }); - child.unref(); - child.on("error", reject); - // Resolve immediately — we just need to launch the app - resolve(); + child.on("error", (err) => { + if (settled) return; + settled = true; + reject(err); + }); + child.on("spawn", () => { + if (settled) return; + settled = true; + child.unref(); + resolve(); + }); }); } -function openPathInApp(app: ExternalApp, targetPath: string): Promise { +async function hasCommand(command: string): Promise { + try { + // execWithShellEnv lazily fixes process.env.PATH on ENOENT so homebrew/user- + // local CLIs work even when launched from Finder/Dock (minimal GUI PATH). + await execWithShellEnv("which", [command]); + return true; + } catch { + return false; + } +} + +async function openPathInApp( + app: ExternalApp, + targetPath: string, +): Promise { const expandedPath = expandTilde(targetPath); if (app === "finder") { shell.showItemInFolder(expandedPath); - return Promise.resolve(); + return; + } + + const cliCommand = APP_CLI[app]; + if (cliCommand && (await hasCommand(cliCommand))) { + try { + await spawnDetached(cliCommand, [expandedPath]); + return; + } catch (err) { + console.warn( + `[external] ${cliCommand} failed, falling back to 'open -a':`, + err, + ); + } } const meta = APP_META[app]; - return spawnAsync("open", ["-a", meta.macAppName, expandedPath]); + // `open -a` exits non-zero when the .app bundle isn't found — awaiting + // execFileAsync surfaces that as a thrown error instead of silent failure. + await execFileAsync("open", ["-a", meta.macAppName, expandedPath]); } /** diff --git a/src/renderer/features/agents/ui/git-activity-badges.tsx b/src/renderer/features/agents/ui/git-activity-badges.tsx index b3b108811..bf10d2d9e 100644 --- a/src/renderer/features/agents/ui/git-activity-badges.tsx +++ b/src/renderer/features/agents/ui/git-activity-badges.tsx @@ -1,7 +1,7 @@ "use client" import { memo, useCallback, useMemo, useState } from "react" -import { GitCommit, GitPullRequest } from "lucide-react" +import { GitBranch, GitCommit, GitPullRequest } from "lucide-react" import { useAtomValue, useSetAtom } from "jotai" import { AnimatePresence, motion } from "motion/react" import { @@ -220,6 +220,12 @@ export const GitActivityBadges = memo(function GitActivityBadges({ > {activity.title} + {activity.branch && ( + + + {activity.branch} + + )} )} diff --git a/src/renderer/features/agents/utils/git-activity.ts b/src/renderer/features/agents/utils/git-activity.ts index 10c0aedc6..65620556f 100644 --- a/src/renderer/features/agents/utils/git-activity.ts +++ b/src/renderer/features/agents/utils/git-activity.ts @@ -10,6 +10,7 @@ export interface GitPrInfo { title: string url: string number?: number + branch?: string } export type GitActivity = GitCommitInfo | GitPrInfo @@ -81,7 +82,15 @@ function extractPrInfo(command: string, stdout: string): GitPrInfo | null { const titleMatch = command.match(/--title\s+["']([^"']+)["']/) const title = titleMatch?.[1] || `PR #${number || ""}` - return { type: "pr", title, url, number } + // Extract branch: either an explicit --head flag, or the branch gh reports + // in its "Creating pull request for into " preamble. + const headFlagMatch = command.match(/--head\s+["']?([^\s"']+)["']?/) + const preambleMatch = stdout.match( + /Creating (?:draft )?pull request for ([^\s]+) into /, + ) + const branch = headFlagMatch?.[1] || preambleMatch?.[1] || undefined + + return { type: "pr", title, url, number, branch } } /** diff --git a/src/renderer/features/changes/components/branch-switcher/branch-switcher-popover.tsx b/src/renderer/features/changes/components/branch-switcher/branch-switcher-popover.tsx new file mode 100644 index 000000000..289f4a95c --- /dev/null +++ b/src/renderer/features/changes/components/branch-switcher/branch-switcher-popover.tsx @@ -0,0 +1,324 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { Check } from "lucide-react"; +import { LuGitBranch } from "react-icons/lu"; +import { HiChevronDown } from "react-icons/hi2"; +import { SearchIcon } from "../../../../components/ui/icons"; +import { Popover, PopoverContent, PopoverTrigger } from "../../../../components/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../../../../components/ui/tooltip"; +import { Button } from "../../../../components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../../../../components/ui/dialog"; +import { toast } from "sonner"; +import { trpc } from "../../../../lib/trpc"; +import { cn } from "../../../../lib/utils"; +import { formatTimeAgo } from "../../../../lib/utils/format-time-ago"; + +interface BranchEntry { + name: string; + type: "local" | "remote"; + isDefault: boolean; + committedAt: string | null; +} + +interface BranchSwitcherPopoverProps { + worktreePath: string; + currentBranch: string; + compact?: boolean; +} + +type PendingSwitch = { + branch: string; + dirty: boolean; +}; + +export function BranchSwitcherPopover({ + worktreePath, + currentBranch, + compact = false, +}: BranchSwitcherPopoverProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const [pending, setPending] = useState(null); + const listRef = useRef(null); + + const utils = trpc.useUtils(); + + const branchesQuery = trpc.changes.getBranches.useQuery( + { worktreePath }, + { enabled: !!worktreePath && open }, + ); + + const statusQuery = trpc.changes.getStatus.useQuery( + { worktreePath }, + { enabled: !!worktreePath, staleTime: 2000 }, + ); + + const checkoutMutation = trpc.changes.checkout.useMutation({ + onSuccess: (result, vars) => { + utils.changes.getBranches.invalidate({ worktreePath }); + utils.changes.getStatus.invalidate({ worktreePath }); + utils.changes.getGitHubStatus.invalidate({ worktreePath }); + utils.chats.getPrStatus.invalidate(); + if (result.stashPopFailed) { + toast.warning("Switched branch, but couldn't restore stashed changes", { + description: + "Your changes are saved in git stash. Run `git stash pop` manually to resolve the conflict.", + }); + } else { + toast.success(`Switched to ${vars.branch}`); + } + setPending(null); + }, + onError: (error) => { + toast.error("Failed to switch branch", { + description: error.message, + }); + setPending(null); + }, + }); + + const branches: BranchEntry[] = useMemo(() => { + if (!branchesQuery.data) return []; + const { local, remote, defaultBranch } = branchesQuery.data; + const result: BranchEntry[] = []; + for (const { branch, lastCommitDate } of local) { + result.push({ + name: branch, + type: "local", + isDefault: branch === defaultBranch, + committedAt: lastCommitDate ? new Date(lastCommitDate).toISOString() : null, + }); + } + for (const name of remote) { + result.push({ + name, + type: "remote", + isDefault: name === defaultBranch, + committedAt: null, + }); + } + return result.sort((a, b) => { + if (a.isDefault && !b.isDefault) return -1; + if (!a.isDefault && b.isDefault) return 1; + if (a.type !== b.type) return a.type === "local" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + }, [branchesQuery.data]); + + const filtered = useMemo(() => { + if (!search.trim()) return branches; + const q = search.toLowerCase(); + return branches.filter((b) => b.name.toLowerCase().includes(q)); + }, [branches, search]); + + const virtualizer = useVirtualizer({ + count: filtered.length, + getScrollElement: () => listRef.current, + estimateSize: () => 32, + overscan: 5, + enabled: open, + }); + + useEffect(() => { + if (open) { + const t = setTimeout(() => virtualizer.measure(), 0); + return () => clearTimeout(t); + } + }, [open, virtualizer]); + + const handleSelect = (branch: string) => { + if (branch === currentBranch) { + setOpen(false); + return; + } + setOpen(false); + setSearch(""); + + const status = statusQuery.data; + const dirty = !!status && ( + status.staged.length > 0 || + status.unstaged.length > 0 || + status.untracked.length > 0 + ); + + if (dirty) { + setPending({ branch, dirty: true }); + } else { + checkoutMutation.mutate({ worktreePath, branch, uncommittedStrategy: "abort" }); + } + }; + + const runSwitch = (strategy: "carry" | "stash") => { + if (!pending) return; + checkoutMutation.mutate({ + worktreePath, + branch: pending.branch, + uncommittedStrategy: strategy, + }); + }; + + return ( + <> + { + if (!next) setSearch(""); + setOpen(next); + }} + > + + + + + + + Switch branch + + +
+ + setSearch(e.target.value)} + className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + autoFocus + /> +
+ + {branchesQuery.isLoading ? ( +
+ Loading branches... +
+ ) : filtered.length === 0 ? ( +
+ No branches found. +
+ ) : ( +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const branch = filtered[virtualItem.index]!; + const isCurrent = branch.name === currentBranch && branch.type === "local"; + return ( + + ); + })} +
+
+ )} +
+
+ + { + if (!open) setPending(null); + }} + > + + + Uncommitted changes + + You have uncommitted changes in this worktree. How should they be handled when switching to{" "} + {pending?.branch}? + + + + + + + + + + + ); +} diff --git a/src/renderer/features/changes/components/changes-panel-header/changes-panel-header.tsx b/src/renderer/features/changes/components/changes-panel-header/changes-panel-header.tsx index e2be67e7c..3ae971e1b 100644 --- a/src/renderer/features/changes/components/changes-panel-header/changes-panel-header.tsx +++ b/src/renderer/features/changes/components/changes-panel-header/changes-panel-header.tsx @@ -1,19 +1,12 @@ import { Button } from "../../../../components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "../../../../components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../../../components/ui/tooltip"; import { useEffect, useRef, useState } from "react"; -import { HiArrowPath, HiChevronDown } from "react-icons/hi2"; -import { LuGitBranch, LuGitPullRequest } from "react-icons/lu"; +import { HiArrowPath } from "react-icons/hi2"; import { trpc } from "../../../../lib/trpc"; import { cn } from "../../../../lib/utils"; import { usePRStatus } from "../../../../hooks/usePRStatus"; import { PRIcon } from "../pr-icon"; +import { BranchSwitcherPopover } from "../branch-switcher/branch-switcher-popover"; type LayoutMode = "compact" | "standard" | "wide" | "full"; @@ -46,7 +39,7 @@ export function ChangesPanelHeader({ const utils = trpc.useUtils(); - const { data: branchData, refetch: refetchBranches } = trpc.changes.getBranches.useQuery( + const { refetch: refetchBranches } = trpc.changes.getBranches.useQuery( { worktreePath }, { enabled: !!worktreePath }, ); @@ -55,14 +48,7 @@ export function ChangesPanelHeader({ onSuccess: () => { setLastFetchTime(new Date()); refetchBranches(); - }, - }); - - const checkoutMutation = trpc.changes.checkout.useMutation({ - onSuccess: () => { - refetchBranches(); - utils.changes.getGitHubStatus.invalidate({ worktreePath }); - utils.chats.getPrStatus.invalidate(); + utils.changes.getStatus.invalidate({ worktreePath }); }, }); @@ -97,18 +83,12 @@ export function ChangesPanelHeader({ ); }; - const handleBranchSelect = (branch: string) => { - if (branch === currentBranch) return; - checkoutMutation.mutate({ worktreePath, branch }); - }; - useEffect(() => { return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }; }, []); - const branches = branchData?.local ?? []; const isCompact = layoutMode === "compact"; return ( @@ -119,59 +99,11 @@ export function ChangesPanelHeader({ )} > {/* Branch selector */} - - - - - - - - Switch branch - - - {branches.map((branchInfo) => ( - handleBranchSelect(branchInfo.branch)} - className={cn( - "text-xs", - branchInfo.branch === currentBranch && "bg-accent", - )} - > - - {branchInfo.branch} - {branchInfo.branch === branchData?.defaultBranch && ( - - default - - )} - - ))} - {branches.length > 0 && } - { - // TODO: Implement create branch dialog - }} - className="text-xs" - > - - Create new branch... - - - + {/* Right side: PR status + Fetch */}
diff --git a/src/renderer/features/changes/components/history-view/commit-diff-split.tsx b/src/renderer/features/changes/components/history-view/commit-diff-split.tsx new file mode 100644 index 000000000..c6d6f9817 --- /dev/null +++ b/src/renderer/features/changes/components/history-view/commit-diff-split.tsx @@ -0,0 +1,259 @@ +"use client" + +import { memo, useCallback, useEffect, useRef, useState } from "react" +import { useAtom } from "jotai" +import { atomWithStorage } from "jotai/utils" +import { FileText } from "lucide-react" +import { IconSpinner } from "@/components/ui/icons" +import { cn } from "@/lib/utils" +import { trpc } from "@/lib/trpc" +import type { ChangedFile } from "@/../shared/changes-types" +import { getStatusIndicator } from "../../utils/status" + +// Persist the left-column width across sessions +const commitDiffSplitWidthAtom = atomWithStorage( + "changes:commitDiffSplitWidth", + 280, + undefined, + { getOnInit: true }, +) + +interface CommitDiffSplitProps { + worktreePath: string + commitHash: string + files: ChangedFile[] + selectedFilePath?: string | null + onFileSelect?: (file: ChangedFile) => void +} + +const MIN_LEFT = 180 +const MIN_RIGHT = 240 + +export const CommitDiffSplit = memo(function CommitDiffSplit({ + worktreePath, + commitHash, + files, + selectedFilePath, + onFileSelect, +}: CommitDiffSplitProps) { + const containerRef = useRef(null) + const [leftWidth, setLeftWidth] = useAtom(commitDiffSplitWidthAtom) + const [isDragging, setIsDragging] = useState(false) + + const handleFileClick = useCallback( + (file: ChangedFile) => { + onFileSelect?.(file) + }, + [onFileSelect], + ) + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + setIsDragging(true) + }, + [], + ) + + useEffect(() => { + if (!isDragging) return + + const onMouseMove = (e: MouseEvent) => { + const container = containerRef.current + if (!container) return + const rect = container.getBoundingClientRect() + let next = e.clientX - rect.left + const maxLeft = rect.width - MIN_RIGHT + if (next < MIN_LEFT) next = MIN_LEFT + if (next > maxLeft) next = maxLeft + setLeftWidth(next) + } + + const onMouseUp = () => setIsDragging(false) + + window.addEventListener("mousemove", onMouseMove) + window.addEventListener("mouseup", onMouseUp) + document.body.style.cursor = "col-resize" + document.body.style.userSelect = "none" + + return () => { + window.removeEventListener("mousemove", onMouseMove) + window.removeEventListener("mouseup", onMouseUp) + document.body.style.cursor = "" + document.body.style.userSelect = "" + } + }, [isDragging, setLeftWidth]) + + return ( +
+ {/* Left: file list */} +
+ {files.length === 0 ? ( +
+ No files in this commit. +
+ ) : ( + files.map((file) => ( + handleFileClick(file)} + /> + )) + )} +
+ + {/* Resize handle */} +
+ + {/* Right: diff */} +
+ {selectedFilePath ? ( + + ) : ( +
+ Select a file to view its diff. +
+ )} +
+
+ ) +}) + +const CommitFileRow = memo(function CommitFileRow({ + file, + isSelected, + onClick, +}: { + file: ChangedFile + isSelected: boolean + onClick: () => void +}) { + const fileName = file.path.split("/").pop() || file.path + const dirPath = file.path.includes("/") + ? file.path.substring(0, file.path.lastIndexOf("/")) + : "" + + return ( + + ) +}) + +const CommitFileDiff = memo(function CommitFileDiff({ + worktreePath, + commitHash, + filePath, +}: { + worktreePath: string + commitHash: string + filePath: string +}) { + const { data, isLoading, error } = trpc.changes.getCommitFileDiff.useQuery( + { worktreePath, commitHash, filePath }, + { enabled: !!worktreePath && !!commitHash && !!filePath, staleTime: 60_000 }, + ) + + if (isLoading && !data) { + return ( +
+ +
+ ) + } + if (error) { + return ( +
+ Failed to load diff: {error.message} +
+ ) + } + if (!data || data.trim() === "") { + return ( +
+ No diff available for this file. +
+ ) + } + + return ( +
+
+ + {filePath} +
+
+        {data.split("\n").map((line, i) => {
+          let toneClass = ""
+          if (line.startsWith("+") && !line.startsWith("+++")) {
+            toneClass = "text-emerald-700 dark:text-emerald-400 bg-emerald-500/5"
+          } else if (line.startsWith("-") && !line.startsWith("---")) {
+            toneClass = "text-red-700 dark:text-red-400 bg-red-500/5"
+          } else if (line.startsWith("@@")) {
+            toneClass = "text-sky-700 dark:text-sky-400"
+          } else if (
+            line.startsWith("diff ") ||
+            line.startsWith("+++") ||
+            line.startsWith("---") ||
+            line.startsWith("index ")
+          ) {
+            toneClass = "text-muted-foreground"
+          }
+          return (
+            
+ {line || "\u00A0"} +
+ ) + })} +
+
+ ) +}) diff --git a/src/renderer/features/changes/components/history-view/history-view.tsx b/src/renderer/features/changes/components/history-view/history-view.tsx index 66f9f3661..40a9388cc 100644 --- a/src/renderer/features/changes/components/history-view/history-view.tsx +++ b/src/renderer/features/changes/components/history-view/history-view.tsx @@ -1,9 +1,8 @@ import { memo, useMemo, useCallback, useEffect } from "react"; import { trpc } from "../../../../lib/trpc"; import { formatRelativeDate } from "../../utils/date"; -import { FileText, ArrowUp } from "lucide-react"; +import { ArrowUp } from "lucide-react"; import { cn } from "../../../../lib/utils"; -import { getStatusIndicator } from "../../utils/status"; import { Button } from "../../../../components/ui/button"; import type { ChangedFile } from "../../../../../shared/changes-types"; import { @@ -15,6 +14,7 @@ import { import { toast } from "sonner"; import { useAtomValue } from "jotai"; import { selectedProjectAtom } from "../../../agents/atoms"; +import { CommitDiffSplit } from "./commit-diff-split"; export interface CommitInfo { hash: string; @@ -135,7 +135,7 @@ export const HistoryView = memo(function HistoryView({ } return ( -
+
{/* Worktree not registered warning */} {isWorktreeRegistered === false && worktreePath && (
@@ -143,16 +143,42 @@ export const HistoryView = memo(function HistoryView({
)} - {/* Commits list - only commits, files are shown in right panel */} - {commits.map((commit, index) => ( - handleCommitClick(commit)} - /> - ))} + {/* Commits list — fixed ~40% of the pane so the diff split has room. */} +
+ {commits.map((commit, index) => ( + handleCommitClick(commit)} + /> + ))} +
+ + {/* Two-column file list + diff for the selected commit */} + {selectedCommitHash && ( + isLoadingFiles && !commitFiles ? ( +
+ Loading files… +
+ ) : filesError ? ( +
+ Failed to load files: {filesError.message} +
+ ) : ( + handleFileClick(file)} + /> + ) + )}
); }); @@ -245,41 +271,3 @@ const HistoryCommitItem = memo(function HistoryCommitItem({ ); }); -const CommitFileItem = memo(function CommitFileItem({ - file, - isSelected, - onClick, -}: { - file: ChangedFile; - isSelected: boolean; - onClick: () => void; -}) { - const fileName = file.path.split("/").pop() || file.path; - const dirPath = file.path.includes("/") - ? file.path.substring(0, file.path.lastIndexOf("/")) - : ""; - - return ( -
- -
- {dirPath && ( - - {dirPath}/ - - )} - - {fileName} - -
-
{getStatusIndicator(file.status)}
-
- ); -}); diff --git a/src/renderer/features/details-sidebar/atoms/index.ts b/src/renderer/features/details-sidebar/atoms/index.ts index 787e05627..415aa2706 100644 --- a/src/renderer/features/details-sidebar/atoms/index.ts +++ b/src/renderer/features/details-sidebar/atoms/index.ts @@ -2,14 +2,21 @@ import { atom } from "jotai" import { atomFamily, atomWithStorage } from "jotai/utils" import { atomWithWindowStorage } from "../../../lib/window-storage" import type { LucideIcon } from "lucide-react" -import { Box, FileText, Terminal, FileDiff, ListTodo } from "lucide-react" +import { + Box, + FileText, + Terminal, + FileDiff, + ListTodo, + GitPullRequest, +} from "lucide-react" import { OriginalMCPIcon } from "../../../components/ui/icons" // ============================================================================ // Widget System Types & Registry // ============================================================================ -export type WidgetId = "info" | "todo" | "plan" | "terminal" | "diff" | "mcp" +export type WidgetId = "info" | "todo" | "plan" | "terminal" | "diff" | "mcp" | "pr" export interface WidgetConfig { id: WidgetId @@ -21,6 +28,7 @@ export interface WidgetConfig { export const WIDGET_REGISTRY: WidgetConfig[] = [ { id: "info", label: "Workspace", icon: Box, canExpand: false, defaultVisible: true }, + { id: "pr", label: "Pull Request", icon: GitPullRequest, canExpand: false, defaultVisible: false }, { id: "todo", label: "To-dos", icon: ListTodo, canExpand: false, defaultVisible: true }, { id: "plan", label: "Plan", icon: FileText, canExpand: true, defaultVisible: true }, { id: "terminal", label: "Terminal", icon: Terminal, canExpand: true, defaultVisible: false }, diff --git a/src/renderer/features/details-sidebar/details-sidebar.tsx b/src/renderer/features/details-sidebar/details-sidebar.tsx index a3e1ccdf8..ecae42d69 100644 --- a/src/renderer/features/details-sidebar/details-sidebar.tsx +++ b/src/renderer/features/details-sidebar/details-sidebar.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useAtom, useAtomValue, useSetAtom } from "jotai" -import { ArrowUpRight, TerminalSquare, Box, ListTodo } from "lucide-react" +import { ArrowUpRight, TerminalSquare, Box, ListTodo, GitPullRequest } from "lucide-react" import { ResizableSidebar } from "@/components/ui/resizable-sidebar" import { Button } from "@/components/ui/button" import { @@ -39,6 +39,7 @@ import { PlanWidget } from "./sections/plan-widget" import { TerminalWidget } from "./sections/terminal-widget" import { ChangesWidget } from "./sections/changes-widget" import { McpWidget } from "./sections/mcp-widget" +import { PrWidget } from "./sections/pr-widget" import { FilesTab, type FilesTabHandle } from "./sections/files-tab" import type { ParsedDiffFile } from "./types" import { fileViewerOpenAtomFamily, type AgentMode } from "../agents/atoms" @@ -65,6 +66,8 @@ function getWidgetIcon(widgetId: WidgetId) { return DiffIcon case "mcp": return OriginalMCPIcon + case "pr": + return GitPullRequest default: return Box } @@ -501,6 +504,15 @@ export function DetailsSidebar({ /> ) + case "pr": + // Only show for local chats with a worktree + if (!worktreePath) return null + return ( + + + + ) + case "mcp": return ( Changes - {currentBranch && ( + {currentBranch && worktreePath ? ( + + on + + + ) : currentBranch ? ( on {currentBranch} - )} + ) : null}
{/* Stats in header - total lines changed */} diff --git a/src/renderer/features/details-sidebar/sections/info-section.tsx b/src/renderer/features/details-sidebar/sections/info-section.tsx index 240992d5c..0fd74c30d 100644 --- a/src/renderer/features/details-sidebar/sections/info-section.tsx +++ b/src/renderer/features/details-sidebar/sections/info-section.tsx @@ -2,12 +2,14 @@ import { memo, useState, useCallback, useEffect } from "react" import { useAtomValue } from "jotai" +import { Pencil } from "lucide-react" import { GitBranchFilledIcon, FolderFilledIcon, GitPullRequestFilledIcon, ExternalLinkIcon, } from "@/components/ui/icons" +import { RenamePrTitleDialog } from "./rename-pr-title-dialog" import { Kbd } from "@/components/ui/kbd" import { Tooltip, @@ -19,6 +21,7 @@ import { preferredEditorAtom } from "@/lib/atoms" import { useResolvedHotkeyDisplay } from "@/lib/hotkeys" import { APP_META } from "../../../../shared/external-apps" import { EDITOR_ICONS } from "@/lib/editor-icons" +import { toast } from "sonner" interface InfoSectionProps { chatId: string @@ -41,6 +44,7 @@ function PropertyRow({ onClick, copyable, tooltip, + badge, }: { icon: React.ComponentType<{ className?: string }> label: string @@ -50,6 +54,8 @@ function PropertyRow({ copyable?: boolean /** Tooltip to show on hover (for clickable items) */ tooltip?: string + /** Optional trailing element rendered next to the value (e.g. branch pill on PR row) */ + badge?: React.ReactNode }) { const [showCopied, setShowCopied] = useState(false) @@ -80,6 +86,28 @@ function PropertyRow({ ) + const wrappedValue = copyable ? ( + + + {valueEl} + + + {showCopied ? "Copied" : "Click to copy"} + + + ) : tooltip ? ( + + + {valueEl} + + + {tooltip} + + + ) : ( + valueEl + ) + return (
{/* Label column - fixed width */} @@ -88,28 +116,9 @@ function PropertyRow({ {label}
{/* Value column - flexible */} -
- {copyable ? ( - - - {valueEl} - - - {showCopied ? "Copied" : "Click to copy"} - - - ) : tooltip ? ( - - - {valueEl} - - - {tooltip} - - - ) : ( - valueEl - )} +
+
{wrappedValue}
+ {badge}
) @@ -129,13 +138,22 @@ export const InfoSection = memo(function InfoSection({ // Extract folder name from path const folderName = worktreePath?.split("/").pop() || "Unknown" + const [isRenamePrOpen, setIsRenamePrOpen] = useState(false) + // Preferred editor from settings const preferredEditor = useAtomValue(preferredEditorAtom) const editorMeta = APP_META[preferredEditor] // Mutations const openInFinderMutation = trpc.external.openInFinder.useMutation() - const openInAppMutation = trpc.external.openInApp.useMutation() + const openInAppMutation = trpc.external.openInApp.useMutation({ + onError: (error, vars) => { + const appLabel = APP_META[vars.app]?.label ?? vars.app + toast.error(`Couldn't open ${appLabel}`, { + description: error.message || "Make sure the app is installed and its CLI is on your PATH.", + }) + }, + }) // Check if this is a remote sandbox chat (no local worktree) const isRemoteChat = !worktreePath && !!remoteInfo @@ -171,7 +189,10 @@ export const InfoSection = memo(function InfoSection({ } } - const isWorktree = !!worktreePath && worktreePath.includes(".21st/worktrees") + // Show the "Open in editor" row for any local chat with a repo path, + // whether that path is a worktree (~/.21st/worktrees/...) or the project + // folder itself (project-mode chats). + const canOpenInEditor = !!worktreePath const openInEditorHotkey = useResolvedHotkeyDisplay("open-in-editor") const handleOpenInEditor = useCallback(() => { @@ -182,11 +203,11 @@ export const InfoSection = memo(function InfoSection({ // Listen for ⌘O hotkey event useEffect(() => { - if (!isWorktree) return + if (!canOpenInEditor) return const handler = () => handleOpenInEditor() window.addEventListener("open-in-editor", handler) return () => window.removeEventListener("open-in-editor", handler) - }, [isWorktree, handleOpenInEditor]) + }, [canOpenInEditor, handleOpenInEditor]) const handleOpenPr = () => { if (pr?.url) { @@ -274,6 +295,49 @@ export const InfoSection = memo(function InfoSection({ title={pr.title} onClick={handleOpenPr} tooltip="Open in GitHub" + badge={ +
+ {branchName && ( + + + + + {branchName} + + + + PR branch: {branchName} + + + )} + + + + + + Rename PR title + + +
+ } + /> + )} + {pr && ( + )} {/* Path - only for local chats */} @@ -287,8 +351,8 @@ export const InfoSection = memo(function InfoSection({ tooltip="Open in Finder" /> )} - {/* Open in Editor - only for actual git worktrees (under ~/.21st/worktrees/) */} - {isWorktree && ( + {/* Open in Editor — any local chat with a repo path (project or worktree) */} + {canOpenInEditor && (
diff --git a/src/renderer/features/details-sidebar/sections/pr-comments-section.tsx b/src/renderer/features/details-sidebar/sections/pr-comments-section.tsx new file mode 100644 index 000000000..068a0f531 --- /dev/null +++ b/src/renderer/features/details-sidebar/sections/pr-comments-section.tsx @@ -0,0 +1,142 @@ +"use client" + +import { trpc } from "@/lib/trpc" +import { IconSpinner } from "@/components/ui/icons" +import { Button } from "@/components/ui/button" +import { Copy } from "lucide-react" +import { toast } from "sonner" + +interface PrCommentsListProps { + chatId: string +} + +function relativeTime(iso: string): string { + const diff = Date.now() - new Date(iso).getTime() + const minutes = Math.floor(diff / 60_000) + if (minutes < 1) return "just now" + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + if (days < 30) return `${days}d ago` + return new Date(iso).toLocaleDateString() +} + +function buildCopyText(c: { + author: string + createdAt: string + body: string + path?: string | null + diffHunk?: string | null +}): string { + const header = `${c.author} · ${new Date(c.createdAt).toLocaleString()}${ + c.path ? `\n${c.path}` : "" + }` + const hunk = c.diffHunk ? `\n\n${c.diffHunk}` : "" + return `${header}\n\n${c.body}${hunk}` +} + +export function PrCommentsList({ chatId }: PrCommentsListProps) { + const { data, isLoading, isError, error } = trpc.chats.getPrComments.useQuery( + { chatId }, + { refetchInterval: 60_000, enabled: !!chatId }, + ) + + if (isLoading) { + return ( +
+ + Loading comments… +
+ ) + } + + if (isError) { + return ( +
+ Couldn't load comments: {error?.message} +
+ ) + } + + const comments = data ?? [] + if (comments.length === 0) { + return ( +
+ No comments yet. +
+ ) + } + + const copyAll = async () => { + const text = comments.map(buildCopyText).join("\n\n---\n\n") + await navigator.clipboard.writeText(text) + toast.success(`Copied ${comments.length} comment${comments.length === 1 ? "" : "s"}`) + } + + const copyOne = async (c: (typeof comments)[number]) => { + await navigator.clipboard.writeText(buildCopyText(c)) + toast.success("Comment copied") + } + + return ( +
+
+ + {comments.length} comment{comments.length === 1 ? "" : "s"} + + +
+
    + {comments.map((c) => ( +
  • +
    +
    + + {c.author} + + · + {relativeTime(c.createdAt)} + {c.kind === "review" && ( + + review + + )} +
    + +
    + {c.path && ( +
    + {c.path} + {c.line ? `:${c.line}` : ""} +
    + )} + {c.diffHunk && ( +
    +                {c.diffHunk}
    +              
    + )} +
    + {c.body} +
    +
  • + ))} +
+
+ ) +} diff --git a/src/renderer/features/details-sidebar/sections/pr-widget.tsx b/src/renderer/features/details-sidebar/sections/pr-widget.tsx new file mode 100644 index 000000000..6f1d3c7d3 --- /dev/null +++ b/src/renderer/features/details-sidebar/sections/pr-widget.tsx @@ -0,0 +1,195 @@ +"use client" + +import { memo, useState } from "react" +import { + Check, + CircleDashed, + ExternalLink, + MessageSquare, + TriangleAlert, + X, +} from "lucide-react" +import { trpc } from "@/lib/trpc" +import { cn } from "@/lib/utils" +import { IconSpinner } from "@/components/ui/icons" +import { Button } from "@/components/ui/button" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { PRIcon } from "@/features/changes/components/pr-icon" +import { RenamePrTitleDialog } from "./rename-pr-title-dialog" +import { PrCommentsList } from "./pr-comments-section" + +interface PrWidgetProps { + chatId: string +} + +type ReviewDecision = "approved" | "changes_requested" | "pending" + +function reviewLabel(decision?: ReviewDecision | null): string | null { + if (!decision) return null + if (decision === "approved") return "Approved" + if (decision === "changes_requested") return "Changes requested" + return "Review pending" +} + +function reviewTone(decision?: ReviewDecision | null): string { + if (decision === "approved") return "text-emerald-600 dark:text-emerald-400" + if (decision === "changes_requested") return "text-amber-600 dark:text-amber-400" + return "text-muted-foreground" +} + +function stateLabel(state: string, isDraft?: boolean): string { + if (state === "merged") return "Merged" + if (state === "closed") return "Closed" + if (isDraft || state === "draft") return "Draft" + return "Open" +} + +export const PrWidget = memo(function PrWidget({ chatId }: PrWidgetProps) { + const { data: status, isLoading } = trpc.chats.getPrStatus.useQuery( + { chatId }, + { refetchInterval: 30000, enabled: !!chatId }, + ) + + const [isRenameOpen, setIsRenameOpen] = useState(false) + const [showComments, setShowComments] = useState(false) + + if (isLoading && !status) { + return ( +
+ + Loading PR status… +
+ ) + } + + const pr = status?.pr + if (!pr) { + return ( +
+ No pull request for this branch yet. +
+ ) + } + + const openPr = () => { + window.desktopApi.openExternal(pr.url) + } + + const checks = pr.checks ?? [] + const successCount = checks.filter((c) => c.status === "success").length + const failureCount = checks.filter((c) => c.status === "failure").length + const pendingCount = checks.filter((c) => c.status === "pending").length + + return ( +
+
+ {/* Title row */} +
+ +
+
+ #{pr.number} + · + {stateLabel(pr.state)} +
+ +
+ + + + + + Open on GitHub + + +
+ + {/* Review + checks row */} +
+ {reviewLabel(pr.reviewDecision) && ( + + {pr.reviewDecision === "approved" ? ( + + ) : pr.reviewDecision === "changes_requested" ? ( + + ) : ( + + )} + {reviewLabel(pr.reviewDecision)} + + )} + {checks.length > 0 && ( + + {successCount > 0 && ( + + + {successCount} + + )} + {failureCount > 0 && ( + + + {failureCount} + + )} + {pendingCount > 0 && ( + + + {pendingCount} + + )} + + )} + {(pr.additions !== undefined || pr.deletions !== undefined) && ( + + + +{pr.additions ?? 0} + {" "} + + −{pr.deletions ?? 0} + + + )} +
+ + {/* Comments toggle */} + +
+ + {showComments && } + + +
+ ) +}) diff --git a/src/renderer/features/details-sidebar/sections/rename-pr-title-dialog.tsx b/src/renderer/features/details-sidebar/sections/rename-pr-title-dialog.tsx new file mode 100644 index 000000000..37c758050 --- /dev/null +++ b/src/renderer/features/details-sidebar/sections/rename-pr-title-dialog.tsx @@ -0,0 +1,94 @@ +import { useEffect, useState } from "react" +import { toast } from "sonner" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { trpc } from "@/lib/trpc" + +interface RenamePrTitleDialogProps { + chatId: string + open: boolean + initialTitle: string + prNumber: number + onOpenChange: (open: boolean) => void +} + +export function RenamePrTitleDialog({ + chatId, + open, + initialTitle, + prNumber, + onOpenChange, +}: RenamePrTitleDialogProps) { + const [title, setTitle] = useState(initialTitle) + const utils = trpc.useUtils() + + useEffect(() => { + if (open) setTitle(initialTitle) + }, [open, initialTitle]) + + const mutation = trpc.chats.updatePrTitle.useMutation({ + onSuccess: () => { + utils.chats.getPrStatus.invalidate({ chatId }) + toast.success(`Renamed PR #${prNumber}`) + onOpenChange(false) + }, + onError: (error) => { + toast.error("Couldn't rename PR", { description: error.message }) + }, + }) + + const trimmed = title.trim() + const canSave = + trimmed.length > 0 && trimmed !== initialTitle.trim() && !mutation.isPending + + const handleSave = () => { + if (!canSave) return + mutation.mutate({ chatId, title: trimmed, prNumber }) + } + + return ( + + + + Rename PR #{prNumber} + + Update the title of this pull request on GitHub. + + + setTitle(e.target.value)} + placeholder="PR title" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + handleSave() + } + }} + disabled={mutation.isPending} + /> + + + + + + + ) +} diff --git a/src/renderer/features/sidebar/agents-sidebar.tsx b/src/renderer/features/sidebar/agents-sidebar.tsx index eb9ce8cc8..8463071d8 100644 --- a/src/renderer/features/sidebar/agents-sidebar.tsx +++ b/src/renderer/features/sidebar/agents-sidebar.tsx @@ -99,6 +99,7 @@ import { KeyboardIcon, TicketIcon, CloudIcon, + GitPullRequestFilledIcon, } from "../../components/ui/icons" import { Logo } from "../../components/ui/logo" import { Input } from "../../components/ui/input" @@ -439,6 +440,7 @@ const AgentChatItem = React.memo(function AgentChatItem({ gitOwner, gitProvider, stats, + prNumber, selectedChatIdsSize, canShowPinOption, areAllSelectedPinned, @@ -487,6 +489,7 @@ const AgentChatItem = React.memo(function AgentChatItem({ gitOwner: string | null | undefined gitProvider: string | null | undefined stats: { fileCount: number; additions: number; deletions: number } | undefined + prNumber: number | null selectedChatIdsSize: number canShowPinOption: boolean areAllSelectedPinned: boolean @@ -673,6 +676,12 @@ const AgentChatItem = React.memo(function AgentChatItem({ )} {displayText}
+ {prNumber != null && ( + + + {prNumber} + + )} {stats && (stats.additions > 0 || stats.deletions > 0) && ( <> @@ -1015,6 +1024,7 @@ const ChatListSection = React.memo(function ChatListSection({ gitOwner={gitOwner} gitProvider={gitProvider} stats={stats ?? undefined} + prNumber={chat.prNumber} selectedChatIdsSize={selectedChatIds.size} canShowPinOption={canShowPinOption} areAllSelectedPinned={areAllSelectedPinned} diff --git a/src/renderer/features/terminal/terminal.tsx b/src/renderer/features/terminal/terminal.tsx index 630778018..193b05d5d 100644 --- a/src/renderer/features/terminal/terminal.tsx +++ b/src/renderer/features/terminal/terminal.tsx @@ -137,6 +137,15 @@ export function Terminal({ const container = containerRef.current if (!container) return + // Defer terminal creation until a valid cwd is available. Without this, a + // transient mount with empty cwd creates a session that silently falls back + // to $HOME on the main side, and the session is then cached forever. + const startupCwd = initialCwd || cwd + if (!startupCwd) { + console.warn("[Terminal:useEffect] Skipping mount — no cwd yet") + return + } + console.log("[Terminal:useEffect] MOUNT - paneId:", paneId) console.log( "[Terminal:useEffect] Container rect:", @@ -250,7 +259,7 @@ export function Terminal({ scopeKey, cols: xterm.cols, rows: xterm.rows, - cwd: initialCwd || cwd, + cwd: startupCwd, initialCommands, }, {