| ... | @@ -22,6 +22,7 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | ... | @@ -22,6 +22,7 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 22 | import { readNativeApi } from "../nativeApi"; | 22 | import { readNativeApi } from "../nativeApi"; |
| 23 | import { toastManager } from "./ui/toast"; | 23 | import { toastManager } from "./ui/toast"; |
| 24 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; | 24 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; |
| | 25 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 25 | import { | 26 | import { |
| 26 | SidebarContent, | 27 | SidebarContent, |
| 27 | SidebarFooter, | 28 | SidebarFooter, |
| ... | @@ -67,9 +68,11 @@ interface TerminalStatusIndicator { | ... | @@ -67,9 +68,11 @@ interface TerminalStatusIndicator { |
| 67 | interface PrStatusIndicator { | 68 | interface 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 | } |
| 71 | | 74 | |
| 72 | type ThreadPrState = NonNullable<GitStatusResult["pr"]>["state"] | null; | 75 | type ThreadPr = GitStatusResult["pr"]; |
| 73 | | 76 | |
| 74 | function hasUnseenCompletion(thread: Thread): boolean { | 77 | function 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 | } |
| 135 | | 138 | |
| 136 | function prStatusIndicator(prState: ThreadPrState): PrStatusIndicator | null { | 139 | function 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 | } |
| 285 | | 296 | |
| 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]); |
| 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 | 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); |
| 719 | | 752 | |
| 720 | return ( | 753 | return ( |
| 721 | <SidebarMenuSubItem key={thread.id} className="w-full"> | 754 | <SidebarMenuSubItem key={thread.id} className="w-full"> |
| 722 | <SidebarMenuSubButton | 755 | <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 | <span | 805 | <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 | <span | 825 | <span |