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";
2222import { readNativeApi } from "../nativeApi";
2323import { toastManager } from "./ui/toast";
2424import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
25import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
2526import {
2627 SidebarContent,
2728 SidebarFooter,
......@@ -67,9 +68,11 @@ interface TerminalStatusIndicator {
6768interface PrStatusIndicator {
6869 label: "PR open" | "PR closed" | "PR merged";
6970 colorClass: string;
71 tooltip: string;
72 url: string;
7073}
7174
72type ThreadPrState = NonNullable<GitStatusResult["pr"]>["state"] | null;
75type ThreadPr = GitStatusResult["pr"];
7376
7477function hasUnseenCompletion(thread: Thread): boolean {
7578 if (!thread.latestTurn?.completedAt) return false;
......@@ -133,23 +136,31 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null
133136 };
134137}
135138
136function prStatusIndicator(prState: ThreadPrState): PrStatusIndicator | null {
137 if (prState === "open") {
139function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null {
140 if (!pr) return null;
141
142 if (pr.state === "open") {
138143 return {
139144 label: "PR open",
140145 colorClass: "text-emerald-600 dark:text-emerald-300/90",
146 tooltip: `#${pr.number} PR open: ${pr.title}`,
147 url: pr.url,
141148 };
142149 }
143 if (prState === "closed") {
150 if (pr.state === "closed") {
144151 return {
145152 label: "PR closed",
146153 colorClass: "text-zinc-500 dark:text-zinc-400/80",
154 tooltip: `#${pr.number} PR closed: ${pr.title}`,
155 url: pr.url,
147156 };
148157 }
149 if (prState === "merged") {
158 if (pr.state === "merged") {
150159 return {
151160 label: "PR merged",
152161 colorClass: "text-violet-600 dark:text-violet-300/90",
162 tooltip: `#${pr.number} PR merged: ${pr.title}`,
163 url: pr.url,
153164 };
154165 }
155166 return null;
......@@ -272,7 +283,7 @@ export default function Sidebar() {
272283 refetchInterval: 60_000,
273284 })),
274285 });
275 const prStateByThreadId = useMemo(() => {
286 const prByThreadId = useMemo(() => {
276287 const statusByCwd = new Map<string, GitStatusResult>();
277288 for (let index = 0; index < threadGitStatusCwds.length; index += 1) {
278289 const cwd = threadGitStatusCwds[index];
......@@ -283,16 +294,38 @@ export default function Sidebar() {
283294 }
284295 }
285296
286 const map = new Map<ThreadId, ThreadPrState>();
297 const map = new Map<ThreadId, ThreadPr>();
287298 for (const target of threadGitTargets) {
288299 const status = target.cwd ? statusByCwd.get(target.cwd) : undefined;
289300 const branchMatches =
290301 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);
292303 }
293304 return map;
294305 }, [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
296329 const handleNewThread = useCallback(
297330 (
298331 projectId: ProjectId,
......@@ -714,13 +747,13 @@ export default function Sidebar() {
714747 thread,
715748 pendingApprovalByThreadId.get(thread.id) === true,
716749 );
717 const prStatus = prStatusIndicator(prStateByThreadId.get(thread.id) ?? null);
750 const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
718751 const terminalStatus = terminalStatusIndicator(thread);
719752
720753 return (
721754 <SidebarMenuSubItem key={thread.id} className="w-full">
722755 <SidebarMenuSubButton
723 render={<button type="button" />}
756 render={<div role="button" tabIndex={0} />}
724757 size="sm"
725758 isActive={isActive}
726759 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() {
734767 params: { threadId: thread.id },
735768 });
736769 }}
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 }}
737778 onContextMenu={(event) => {
738779 event.preventDefault();
739780 void handleThreadContextMenu(thread.id, {
......@@ -761,14 +802,24 @@ export default function Sidebar() {
761802 </div>
762803 <div className="ml-auto flex shrink-0 items-center gap-1.5">
763804 {prStatus && (
764 <span
765 role="img"
766 aria-label={prStatus.label}
767 title={prStatus.label}
768 className={`inline-flex items-center justify-center ${prStatus.colorClass}`}
769 >
770 <GitPullRequestIcon className="size-3" />
771 </span>
805 <Tooltip>
806 <TooltipTrigger
807 render={
808 <button
809 type="button"
810 aria-label={prStatus.tooltip}
811 title={prStatus.tooltip}
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>
772823 )}
773824 {terminalStatus && (
774825 <span