diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index de40306687cd7c390ddd145b9b51a1f84df9de35..6fef417db8cd1eb4f550b8e65cd1859d6ba7e533 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -22,6 +22,7 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; import { toastManager } from "./ui/toast"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, SidebarFooter, @@ -67,9 +68,11 @@ interface TerminalStatusIndicator { interface PrStatusIndicator { label: "PR open" | "PR closed" | "PR merged"; colorClass: string; + tooltip: string; + url: string; } -type ThreadPrState = NonNullable["state"] | null; +type ThreadPr = GitStatusResult["pr"]; function hasUnseenCompletion(thread: Thread): boolean { if (!thread.latestTurn?.completedAt) return false; @@ -133,23 +136,31 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null }; } -function prStatusIndicator(prState: ThreadPrState): PrStatusIndicator | null { - if (prState === "open") { +function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { + if (!pr) return null; + + if (pr.state === "open") { return { label: "PR open", colorClass: "text-emerald-600 dark:text-emerald-300/90", + tooltip: `#${pr.number} PR open: ${pr.title}`, + url: pr.url, }; } - if (prState === "closed") { + if (pr.state === "closed") { return { label: "PR closed", colorClass: "text-zinc-500 dark:text-zinc-400/80", + tooltip: `#${pr.number} PR closed: ${pr.title}`, + url: pr.url, }; } - if (prState === "merged") { + if (pr.state === "merged") { return { label: "PR merged", colorClass: "text-violet-600 dark:text-violet-300/90", + tooltip: `#${pr.number} PR merged: ${pr.title}`, + url: pr.url, }; } return null; @@ -272,7 +283,7 @@ export default function Sidebar() { refetchInterval: 60_000, })), }); - const prStateByThreadId = useMemo(() => { + const prByThreadId = useMemo(() => { const statusByCwd = new Map(); for (let index = 0; index < threadGitStatusCwds.length; index += 1) { const cwd = threadGitStatusCwds[index]; @@ -283,16 +294,38 @@ export default function Sidebar() { } } - const map = new Map(); + const map = new Map(); for (const target of threadGitTargets) { const status = target.cwd ? statusByCwd.get(target.cwd) : undefined; const branchMatches = target.branch !== null && status?.branch !== null && status?.branch === target.branch; - map.set(target.threadId, branchMatches ? status?.pr?.state ?? null : null); + map.set(target.threadId, branchMatches ? status?.pr ?? null : null); } return map; }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); + const openPrLink = useCallback((event: React.MouseEvent, prUrl: string) => { + event.preventDefault(); + event.stopPropagation(); + + const api = readNativeApi(); + if (!api) { + toastManager.add({ + type: "error", + title: "Link opening is unavailable.", + }); + return; + } + + void api.shell.openExternal(prUrl).catch((error) => { + toastManager.add({ + type: "error", + title: "Unable to open PR link", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }); + }, []); + const handleNewThread = useCallback( ( projectId: ProjectId, @@ -714,13 +747,13 @@ export default function Sidebar() { thread, pendingApprovalByThreadId.get(thread.id) === true, ); - const prStatus = prStatusIndicator(prStateByThreadId.get(thread.id) ?? null); + const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); const terminalStatus = terminalStatusIndicator(thread); return ( } + render={
} size="sm" isActive={isActive} className={`h-7 w-full translate-x-0 justify-start px-2 text-left hover:bg-accent hover:text-foreground ${ @@ -734,6 +767,14 @@ export default function Sidebar() { params: { threadId: thread.id }, }); }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + void navigate({ + to: "/$threadId", + params: { threadId: thread.id }, + }); + }} onContextMenu={(event) => { event.preventDefault(); void handleThreadContextMenu(thread.id, { @@ -761,14 +802,24 @@ export default function Sidebar() {
{prStatus && ( - - - + + { + openPrLink(event, prStatus.url); + }} + > + + + } + /> + {prStatus.tooltip} + )} {terminalStatus && (