| ... | ... | @@ -22,6 +22,7 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 22 | 22 | import { readNativeApi } from "../nativeApi"; |
| 23 | 23 | import { toastManager } from "./ui/toast"; |
| 24 | 24 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; |
| 25 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 25 | 26 | import { |
| 26 | 27 | SidebarContent, |
| 27 | 28 | SidebarFooter, |
| ... | ... | @@ -67,9 +68,11 @@ interface TerminalStatusIndicator { |
| 67 | 68 | interface PrStatusIndicator { |
| 68 | 69 | label: "PR open" | "PR closed" | "PR merged"; |
| 69 | 70 | colorClass: string; |
| 71 | tooltip: string; |
| 72 | url: string; |
| 70 | 73 | } |
| 71 | 74 | |
| 72 | | type ThreadPrState = NonNullable<GitStatusResult["pr"]>["state"] | null; |
| 75 | type ThreadPr = GitStatusResult["pr"]; |
| 73 | 76 | |
| 74 | 77 | function hasUnseenCompletion(thread: Thread): boolean { |
| 75 | 78 | if (!thread.latestTurn?.completedAt) return false; |
| ... | ... | @@ -133,23 +136,31 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null |
| 133 | 136 | }; |
| 134 | 137 | } |
| 135 | 138 | |
| 136 | | function prStatusIndicator(prState: ThreadPrState): PrStatusIndicator | null { |
| 137 | | if (prState === "open") { |
| 139 | function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { |
| 140 | if (!pr) return null; |
| 141 | |
| 142 | if (pr.state === "open") { |
| 138 | 143 | return { |
| 139 | 144 | label: "PR open", |
| 140 | 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 | 151 | return { |
| 145 | 152 | label: "PR closed", |
| 146 | 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 | 159 | return { |
| 151 | 160 | label: "PR merged", |
| 152 | 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 | 166 | return null; |
| ... | ... | @@ -272,7 +283,7 @@ export default function Sidebar() { |
| 272 | 283 | refetchInterval: 60_000, |
| 273 | 284 | })), |
| 274 | 285 | }); |
| 275 | | const prStateByThreadId = useMemo(() => { |
| 286 | const prByThreadId = useMemo(() => { |
| 276 | 287 | const statusByCwd = new Map<string, GitStatusResult>(); |
| 277 | 288 | for (let index = 0; index < threadGitStatusCwds.length; index += 1) { |
| 278 | 289 | const cwd = threadGitStatusCwds[index]; |
| ... | ... | @@ -283,16 +294,38 @@ export default function Sidebar() { |
| 283 | 294 | } |
| 284 | 295 | } |
| 285 | 296 | |
| 286 | | const map = new Map<ThreadId, ThreadPrState>(); |
| 297 | const map = new Map<ThreadId, ThreadPr>(); |
| 287 | 298 | for (const target of threadGitTargets) { |
| 288 | 299 | const status = target.cwd ? statusByCwd.get(target.cwd) : undefined; |
| 289 | 300 | const branchMatches = |
| 290 | 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 | 304 | return map; |
| 294 | 305 | }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); |
| 295 | 306 | |
| 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 | 329 | const handleNewThread = useCallback( |
| 297 | 330 | ( |
| 298 | 331 | projectId: ProjectId, |
| ... | ... | @@ -714,13 +747,13 @@ export default function Sidebar() { |
| 714 | 747 | thread, |
| 715 | 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 | 751 | const terminalStatus = terminalStatusIndicator(thread); |
| 719 | 752 | |
| 720 | 753 | return ( |
| 721 | 754 | <SidebarMenuSubItem key={thread.id} className="w-full"> |
| 722 | 755 | <SidebarMenuSubButton |
| 723 | | render={<button type="button" />} |
| 756 | render={<div role="button" tabIndex={0} />} |
| 724 | 757 | size="sm" |
| 725 | 758 | isActive={isActive} |
| 726 | 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 | 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 | 778 | onContextMenu={(event) => { |
| 738 | 779 | event.preventDefault(); |
| 739 | 780 | void handleThreadContextMenu(thread.id, { |
| ... | ... | @@ -761,14 +802,24 @@ export default function Sidebar() { |
| 761 | 802 | </div> |
| 762 | 803 | <div className="ml-auto flex shrink-0 items-center gap-1.5"> |
| 763 | 804 | {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> |
| 772 | 823 | )} |
| 773 | 824 | {terminalStatus && ( |
| 774 | 825 | <span |