authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-28 16:14:26-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-28 16:24:03-08:00
log483702df77c6231f4f0755b23a316c12630bb36a
treea5588b11b1f182131c0700e646472f98a2e896da
parent206eb6a785283f6d8f79d122d5f69ee37aef660d

Add clickable PR status tooltip in sidebar threads

- Show PR number/title in a tooltip on each thread PR icon - Open PR links externally from the sidebar and show error toasts on failure - Improve thread row keyboard handling after switching to a non-button render Co-authored-by: codex <codex@users.noreply.github.com>

1 files changed, 69 insertions(+), 18 deletions(-)

apps/web/src/components/Sidebar.tsx+69-18
...@@ -22,6 +22,7 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery";...@@ -22,6 +22,7 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery";
22import { readNativeApi } from "../nativeApi";22import { readNativeApi } from "../nativeApi";
23import { toastManager } from "./ui/toast";23import { toastManager } from "./ui/toast";
24import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";24import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
25import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
25import {26import {
26 SidebarContent,27 SidebarContent,
27 SidebarFooter,28 SidebarFooter,
...@@ -67,9 +68,11 @@ interface TerminalStatusIndicator {...@@ -67,9 +68,11 @@ interface TerminalStatusIndicator {
67interface PrStatusIndicator {68interface PrStatusIndicator {
68 label: "PR open" | "PR closed" | "PR merged";69 label: "PR open" | "PR closed" | "PR merged";
69 colorClass: string;70 colorClass: string;
71 tooltip: string;
72 url: string;
70}73}
7174
72type ThreadPrState = NonNullable<GitStatusResult["pr"]>["state"] | null;75type ThreadPr = GitStatusResult["pr"];
7376
74function hasUnseenCompletion(thread: Thread): boolean {77function hasUnseenCompletion(thread: Thread): boolean {
75 if (!thread.latestTurn?.completedAt) return false;78 if (!thread.latestTurn?.completedAt) return false;
...@@ -133,23 +136,31 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null...@@ -133,23 +136,31 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null
133 };136 };
134}137}
135138
136function prStatusIndicator(prState: ThreadPrState): PrStatusIndicator | null {139function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null {
137 if (prState === "open") {140 if (!pr) return null;
141
142 if (pr.state === "open") {
138 return {143 return {
139 label: "PR open",144 label: "PR open",
140 colorClass: "text-emerald-600 dark:text-emerald-300/90",145 colorClass: "text-emerald-600 dark:text-emerald-300/90",
146 tooltip: `#${pr.number} PR open: ${pr.title}`,
147 url: pr.url,
141 };148 };
142 }149 }
143 if (prState === "closed") {150 if (pr.state === "closed") {
144 return {151 return {
145 label: "PR closed",152 label: "PR closed",
146 colorClass: "text-zinc-500 dark:text-zinc-400/80",153 colorClass: "text-zinc-500 dark:text-zinc-400/80",
154 tooltip: `#${pr.number} PR closed: ${pr.title}`,
155 url: pr.url,
147 };156 };
148 }157 }
149 if (prState === "merged") {158 if (pr.state === "merged") {
150 return {159 return {
151 label: "PR merged",160 label: "PR merged",
152 colorClass: "text-violet-600 dark:text-violet-300/90",161 colorClass: "text-violet-600 dark:text-violet-300/90",
162 tooltip: `#${pr.number} PR merged: ${pr.title}`,
163 url: pr.url,
153 };164 };
154 }165 }
155 return null;166 return null;
...@@ -272,7 +283,7 @@ export default function Sidebar() {...@@ -272,7 +283,7 @@ export default function Sidebar() {
272 refetchInterval: 60_000,283 refetchInterval: 60_000,
273 })),284 })),
274 });285 });
275 const prStateByThreadId = useMemo(() => {286 const prByThreadId = useMemo(() => {
276 const statusByCwd = new Map<string, GitStatusResult>();287 const statusByCwd = new Map<string, GitStatusResult>();
277 for (let index = 0; index < threadGitStatusCwds.length; index += 1) {288 for (let index = 0; index < threadGitStatusCwds.length; index += 1) {
278 const cwd = threadGitStatusCwds[index];289 const cwd = threadGitStatusCwds[index];
...@@ -283,16 +294,38 @@ export default function Sidebar() {...@@ -283,16 +294,38 @@ export default function Sidebar() {
283 }294 }
284 }295 }
285296
286 const map = new Map<ThreadId, ThreadPrState>();297 const map = new Map<ThreadId, ThreadPr>();
287 for (const target of threadGitTargets) {298 for (const target of threadGitTargets) {
288 const status = target.cwd ? statusByCwd.get(target.cwd) : undefined;299 const status = target.cwd ? statusByCwd.get(target.cwd) : undefined;
289 const branchMatches =300 const branchMatches =
290 target.branch !== null && status?.branch !== null && status?.branch === target.branch;301 target.branch !== null && status?.branch !== null && status?.branch === target.branch;
291 map.set(target.threadId, branchMatches ? status?.pr?.state ?? null : null);302 map.set(target.threadId, branchMatches ? status?.pr ?? null : null);
292 }303 }
293 return map;304 return map;
294 }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]);305 }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]);
295306
307 const openPrLink = useCallback((event: React.MouseEvent<HTMLElement>, prUrl: string) => {
308 event.preventDefault();
309 event.stopPropagation();
310
311 const api = readNativeApi();
312 if (!api) {
313 toastManager.add({
314 type: "error",
315 title: "Link opening is unavailable.",
316 });
317 return;
318 }
319
320 void api.shell.openExternal(prUrl).catch((error) => {
321 toastManager.add({
322 type: "error",
323 title: "Unable to open PR link",
324 description: error instanceof Error ? error.message : "An error occurred.",
325 });
326 });
327 }, []);
328
296 const handleNewThread = useCallback(329 const handleNewThread = useCallback(
297 (330 (
298 projectId: ProjectId,331 projectId: ProjectId,
...@@ -714,13 +747,13 @@ export default function Sidebar() {...@@ -714,13 +747,13 @@ export default function Sidebar() {
714 thread,747 thread,
715 pendingApprovalByThreadId.get(thread.id) === true,748 pendingApprovalByThreadId.get(thread.id) === true,
716 );749 );
717 const prStatus = prStatusIndicator(prStateByThreadId.get(thread.id) ?? null);750 const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
718 const terminalStatus = terminalStatusIndicator(thread);751 const terminalStatus = terminalStatusIndicator(thread);
719752
720 return (753 return (
721 <SidebarMenuSubItem key={thread.id} className="w-full">754 <SidebarMenuSubItem key={thread.id} className="w-full">
722 <SidebarMenuSubButton755 <SidebarMenuSubButton
723 render={<button type="button" />}756 render={<div role="button" tabIndex={0} />}
724 size="sm"757 size="sm"
725 isActive={isActive}758 isActive={isActive}
726 className={`h-7 w-full translate-x-0 justify-start px-2 text-left hover:bg-accent hover:text-foreground ${759 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() {...@@ -734,6 +767,14 @@ export default function Sidebar() {
734 params: { threadId: thread.id },767 params: { threadId: thread.id },
735 });768 });
736 }}769 }}
770 onKeyDown={(event) => {
771 if (event.key !== "Enter" && event.key !== " ") return;
772 event.preventDefault();
773 void navigate({
774 to: "/$threadId",
775 params: { threadId: thread.id },
776 });
777 }}
737 onContextMenu={(event) => {778 onContextMenu={(event) => {
738 event.preventDefault();779 event.preventDefault();
739 void handleThreadContextMenu(thread.id, {780 void handleThreadContextMenu(thread.id, {
...@@ -761,14 +802,24 @@ export default function Sidebar() {...@@ -761,14 +802,24 @@ export default function Sidebar() {
761 </div>802 </div>
762 <div className="ml-auto flex shrink-0 items-center gap-1.5">803 <div className="ml-auto flex shrink-0 items-center gap-1.5">
763 {prStatus && (804 {prStatus && (
764 <span805 <Tooltip>
765 role="img"806 <TooltipTrigger
766 aria-label={prStatus.label}807 render={
767 title={prStatus.label}808 <button
768 className={`inline-flex items-center justify-center ${prStatus.colorClass}`}809 type="button"
769 >810 aria-label={prStatus.tooltip}
770 <GitPullRequestIcon className="size-3" />811 title={prStatus.tooltip}
771 </span>812 className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`}
813 onClick={(event) => {
814 openPrLink(event, prStatus.url);
815 }}
816 >
817 <GitPullRequestIcon className="size-3" />
818 </button>
819 }
820 />
821 <TooltipPopup side="top">{prStatus.tooltip}</TooltipPopup>
822 </Tooltip>
772 )}823 )}
773 {terminalStatus && (824 {terminalStatus && (
774 <span825 <span