authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-09-16 08:58:34-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-16 12:58:34-03:00
loge8ca3a82806dff84438d5aba66b693468f7ca426
tree720c1d38dceaef9b1049052c32e220395fa42a52
parentf45c6b4a755905485431872f4a4188de3ed05974
signature Signed by PGP key B5690EEEBB952194

refactor(web): centralize pull request icon state presentation (#11144)


26 files changed, 628 insertions(+), 307 deletions(-)

apps/web/src/components/CommandPalette.tsx+3-3
...@@ -46,7 +46,6 @@ import {...@@ -46,7 +46,6 @@ import {
46 FileSearchIcon,46 FileSearchIcon,
47 FolderIcon,47 FolderIcon,
48 FolderPlusIcon,48 FolderPlusIcon,
49 GitPullRequestArrowIcon,
50 LinkIcon,49 LinkIcon,
51 MessageSquareIcon,50 MessageSquareIcon,
52 PaletteIcon,51 PaletteIcon,
...@@ -181,6 +180,7 @@ import {...@@ -181,6 +180,7 @@ import {
181 buildSidebarProjectSnapshots,180 buildSidebarProjectSnapshots,
182} from "../sidebarProjectGrouping";181} from "../sidebarProjectGrouping";
183import type { Project } from "../types";182import type { Project } from "../types";
183import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
184184
185const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = [];185const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = [];
186186
...@@ -1712,7 +1712,7 @@ function OpenCommandPaletteDialog(props: {...@@ -1712,7 +1712,7 @@ function OpenCommandPaletteDialog(props: {
1712 value: "action:link-pull-request",1712 value: "action:link-pull-request",
1713 searchTerms: ["link", "pull request", "pr", "attach", "stack"],1713 searchTerms: ["link", "pull request", "pr", "attach", "stack"],
1714 title: "Link pull request to thread",1714 title: "Link pull request to thread",
1715 icon: <GitPullRequestArrowIcon className={ITEM_ICON_CLASS} />,1715 icon: <PullRequestGlyph.link className={ITEM_ICON_CLASS} />,
1716 run: async () => {1716 run: async () => {
1717 openLinkPullRequestDialog(threadRef);1717 openLinkPullRequestDialog(threadRef);
1718 },1718 },
...@@ -1724,7 +1724,7 @@ function OpenCommandPaletteDialog(props: {...@@ -1724,7 +1724,7 @@ function OpenCommandPaletteDialog(props: {
1724 searchTerms: ["pull requests", "linked", "stack", "prs"],1724 searchTerms: ["pull requests", "linked", "stack", "prs"],
1725 title: "Show linked pull requests",1725 title: "Show linked pull requests",
1726 disabled: visibleThreadPullRequests(activeThread.pullRequests).length === 0,1726 disabled: visibleThreadPullRequests(activeThread.pullRequests).length === 0,
1727 icon: <GitPullRequestArrowIcon className={ITEM_ICON_CLASS} />,1727 icon: <PullRequestGlyph.link className={ITEM_ICON_CLASS} />,
1728 run: async () => {1728 run: async () => {
1729 useRightPanelStore.getState().open(threadRef, "pull-requests");1729 useRightPanelStore.getState().open(threadRef, "pull-requests");
1730 },1730 },
apps/web/src/components/LegacySidebar.tsx+2-2
...@@ -1,5 +1,4 @@...@@ -1,5 +1,4 @@
1import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";1import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
2import { GitPullRequestIcon } from "lucide-react";
3import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";2import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";
4import { Spinner } from "~/components/ui/spinner";3import { Spinner } from "~/components/ui/spinner";
5import {4import {
...@@ -214,6 +213,7 @@ import {...@@ -214,6 +213,7 @@ import {
214 type SidebarProjectGroupMember,213 type SidebarProjectGroupMember,
215 type SidebarProjectSnapshot,214 type SidebarProjectSnapshot,
216} from "../sidebarProjectGrouping";215} from "../sidebarProjectGrouping";
216import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
217const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {217const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
218 updated_at: "Last user message",218 updated_at: "Last user message",
219 created_at: "Created at",219 created_at: "Created at",
...@@ -757,7 +757,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -757,7 +757,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
757 className="text-muted-foreground"757 className="text-muted-foreground"
758 aria-label={`PR #${currentLinkedPr.number}, status pending`}758 aria-label={`PR #${currentLinkedPr.number}, status pending`}
759 >759 >
760 <GitPullRequestIcon className="size-3" />760 <PullRequestGlyph.pullRequest className="size-3" />
761 </a>761 </a>
762 ) : null}762 ) : null}
763 {threadStatus && <ThreadStatusLabel status={threadStatus} />}763 {threadStatus && <ThreadStatusLabel status={threadStatus} />}
apps/web/src/components/RightPanelTabs.tsx+10-10
...@@ -21,8 +21,6 @@ import {...@@ -21,8 +21,6 @@ import {
21 ChevronRight,21 ChevronRight,
22 FileDiff,22 FileDiff,
23 Files,23 Files,
24 GitPullRequest,
25 GitPullRequestArrow,
26 Globe2,24 Globe2,
27 Plus,25 Plus,
28 TerminalSquare,26 TerminalSquare,
...@@ -73,6 +71,7 @@ import { FaviconImage } from "./preview/PreviewFaviconIcon";...@@ -73,6 +71,7 @@ import { FaviconImage } from "./preview/PreviewFaviconIcon";
73import { previewBridge } from "./preview/previewBridge";71import { previewBridge } from "./preview/previewBridge";
74import { PierreEntryIcon } from "./chat/PierreEntryIcon";72import { PierreEntryIcon } from "./chat/PierreEntryIcon";
75import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation";73import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation";
74import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
7675
77interface RightPanelTabsProps {76interface RightPanelTabsProps {
78 mode: PreviewPanelMode;77 mode: PreviewPanelMode;
...@@ -373,7 +372,7 @@ function RightPanelEmptyState(props: {...@@ -373,7 +372,7 @@ function RightPanelEmptyState(props: {
373 },372 },
374 {373 {
375 label: "Pull request",374 label: "Pull request",
376 icon: GitPullRequest,375 icon: PullRequestGlyph.pullRequest,
377 shortcut: "P",376 shortcut: "P",
378 available: props.pullRequestAvailable,377 available: props.pullRequestAvailable,
379 disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequest,378 disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequest,
...@@ -382,7 +381,7 @@ function RightPanelEmptyState(props: {...@@ -382,7 +381,7 @@ function RightPanelEmptyState(props: {
382 },381 },
383 {382 {
384 label: "Linked pull requests",383 label: "Linked pull requests",
385 icon: GitPullRequestArrow,384 icon: PullRequestGlyph.link,
386 shortcut: "L",385 shortcut: "L",
387 available: props.pullRequestsAvailable,386 available: props.pullRequestsAvailable,
388 disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests,387 disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests,
...@@ -712,7 +711,7 @@ function SurfaceIcon({...@@ -712,7 +711,7 @@ function SurfaceIcon({
712 />711 />
713 );712 );
714 case "pull-requests":713 case "pull-requests":
715 return <GitPullRequestArrow className="size-3 shrink-0" />;714 return <PullRequestGlyph.link className="size-3 shrink-0" />;
716 case "agents":715 case "agents":
717 return <Bot className="size-3 shrink-0" />;716 return <Bot className="size-3 shrink-0" />;
718 case "device":717 case "device":
...@@ -801,8 +800,9 @@ function PullRequestSurfaceIcon({...@@ -801,8 +800,9 @@ function PullRequestSurfaceIcon({
801 },800 },
802 }),801 }),
803 ).data;802 ).data;
804 // Only state and draft reach the tab. A list seed cannot know mergeability, so feeding the803 // The compact tab intentionally shows lifecycle and draft state only. Conflict warnings have
805 // full detail would flip an open tab to the conflict glyph the moment its read lands.804 // their own presentation on surfaces that have mergeability, while this tab stays stable as
805 // detail data arrives.
806 const status =806 const status =
807 linkedSnapshot !== null807 linkedSnapshot !== null
808 ? linkedSnapshot808 ? linkedSnapshot
...@@ -810,7 +810,7 @@ function PullRequestSurfaceIcon({...@@ -810,7 +810,7 @@ function PullRequestSurfaceIcon({
810 ? (seed ?? null)810 ? (seed ?? null)
811 : { state: detail.state, isDraft: detail.isDraft };811 : { state: detail.state, isDraft: detail.isDraft };
812 if (status === null) {812 if (status === null) {
813 return <GitPullRequest className="size-3 shrink-0 text-muted-foreground" />;813 return <PullRequestGlyph.pullRequest className="size-3 shrink-0 text-muted-foreground" />;
814 }814 }
815 const presentation = resolvePullRequestState({ state: status.state, isDraft: status.isDraft });815 const presentation = resolvePullRequestState({ state: status.state, isDraft: status.isDraft });
816 return <presentation.Icon className={cn("size-3 shrink-0", presentation.toneClassName)} />;816 return <presentation.Icon className={cn("size-3 shrink-0", presentation.toneClassName)} />;
...@@ -895,7 +895,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -895,7 +895,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
895 },895 },
896 {896 {
897 label: "Pull request",897 label: "Pull request",
898 icon: GitPullRequest,898 icon: PullRequestGlyph.pullRequest,
899 shortcut: "P",899 shortcut: "P",
900 available: props.pullRequestAvailable,900 available: props.pullRequestAvailable,
901 disabledReason: SURFACE_DISABLED_REASONS.pullRequest,901 disabledReason: SURFACE_DISABLED_REASONS.pullRequest,
...@@ -903,7 +903,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -903,7 +903,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
903 },903 },
904 {904 {
905 label: "Linked pull requests",905 label: "Linked pull requests",
906 icon: GitPullRequestArrow,906 icon: PullRequestGlyph.link,
907 shortcut: "L",907 shortcut: "L",
908 available: props.pullRequestsAvailable,908 available: props.pullRequestsAvailable,
909 disabledReason: SURFACE_DISABLED_REASONS.pullRequests,909 disabledReason: SURFACE_DISABLED_REASONS.pullRequests,
apps/web/src/components/ThreadStatusIndicators.test.ts+130-22
...@@ -1,25 +1,20 @@...@@ -1,25 +1,20 @@
1import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts";1import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";2import { describe, expect, it } from "@effect/vitest";
3import {
4 GitMergeIcon,
5 GitPullRequestClosedIcon,
6 GitPullRequestDraftIcon,
7 GitPullRequestIcon,
8} from "lucide-react";
93
10import {4import {
11 ChangeRequestStatusIcon,5 ChangeRequestStatusIcon,
12 prStatusIndicator,6 prStatusIndicator,
13 settledPrHoverColorClass,7 resolveThreadPullRequestBadgePresentation,
14} from "./ThreadStatusIndicators";8} from "./ThreadStatusIndicators";
15import { newestPullRequestSummary } from "../state/pullRequests";9import { newestPullRequestSummary } from "../state/pullRequests";
10import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
1611
17describe("ChangeRequestStatusIcon", () => {12describe("ChangeRequestStatusIcon", () => {
18 it.each([13 it.each([
19 ["open", "open", false, GitPullRequestIcon],14 ["open", "open", false, PullRequestGlyph.pullRequest],
20 ["draft", "open", true, GitPullRequestDraftIcon],15 ["draft", "open", true, PullRequestGlyph.draft],
21 ["closed", "closed", false, GitPullRequestClosedIcon],16 ["closed", "closed", false, PullRequestGlyph.closed],
22 ["merged", "merged", false, GitMergeIcon],17 ["merged", "merged", false, PullRequestGlyph.merged],
23 ] as const)("uses the %s pull request glyph", (_label, state, isDraft, expectedIcon) => {18 ] as const)("uses the %s pull request glyph", (_label, state, isDraft, expectedIcon) => {
24 expect(ChangeRequestStatusIcon({ state, isDraft }).type).toBe(expectedIcon);19 expect(ChangeRequestStatusIcon({ state, isDraft }).type).toBe(expectedIcon);
25 });20 });
...@@ -119,18 +114,131 @@ describe("prStatusIndicator", () => {...@@ -119,18 +114,131 @@ describe("prStatusIndicator", () => {
119 });114 });
120});115});
121116
122describe("settledPrHoverColorClass", () => {117describe("resolveThreadPullRequestBadgePresentation", () => {
123 it.each([118 const url = "https://github.com/pingdotgg/t3code/pull/42";
124 ["open", "text-emerald-600"],119
125 ["merged", "text-violet-600"],120 it("returns the pending pull-request badge when no snapshot is available", () => {
126 ["closed", "text-red-600"],121 expect(
127 ] as const)("restores the %s pull request color on row hover", (state, colorClass) => {122 resolveThreadPullRequestBadgePresentation({
128 expect(settledPrHoverColorClass(state)).toContain(`group-hover/sidebar-row:${colorClass}`);123 badge: null,
124 number: 42,
125 url,
126 status: null,
127 }),
128 ).toEqual({
129 Icon: PullRequestGlyph.pullRequest,
130 toneClassName: "text-muted-foreground",
131 label: "PR #42, status pending",
132 text: 42,
133 });
129 });134 });
130135
131 it("keeps draft pull requests gray on row hover", () => {136 it.each([
132 expect(settledPrHoverColorClass("open", true)).toContain(137 [
133 "group-hover/sidebar-row:text-zinc-500",138 "open",
134 );139 { state: "open", isDraft: false },
140 PullRequestGlyph.pullRequest,
141 "text-emerald-600 dark:text-emerald-300/90",
142 "PR #42 - Open: PR branch",
143 ],
144 [
145 "draft",
146 { state: "open", isDraft: true },
147 PullRequestGlyph.draft,
148 "text-zinc-500 dark:text-zinc-400/80",
149 "PR #42 - Draft: PR branch",
150 ],
151 [
152 "closed",
153 { state: "closed", isDraft: false },
154 PullRequestGlyph.closed,
155 "text-red-600 dark:text-red-300/90",
156 "PR #42 - Closed: PR branch",
157 ],
158 [
159 "merged",
160 { state: "merged", isDraft: false },
161 PullRequestGlyph.merged,
162 "text-violet-600 dark:text-violet-300/90",
163 "PR #42 - Merged: PR branch",
164 ],
165 ] as const)(
166 "keeps the %s state for one linked pull request",
167 (_state, prOverrides, expectedIcon, expectedToneClassName, expectedLabel) => {
168 const fixture = status().pr;
169 if (!fixture) throw new Error("Expected pull request fixture");
170 const prStatus = prStatusIndicator({ ...fixture, ...prOverrides }, undefined);
171 if (!prStatus) throw new Error("Expected pull request status");
172
173 expect(
174 resolveThreadPullRequestBadgePresentation({
175 badge: { kind: "pull-request", others: 0, state: "open" },
176 number: fixture.number,
177 url: fixture.url,
178 status: prStatus,
179 }),
180 ).toEqual({
181 Icon: expectedIcon,
182 toneClassName: expectedToneClassName,
183 label: expectedLabel,
184 text: fixture.number,
185 });
186 },
187 );
188
189 it.each([
190 ["open", "text-emerald-600 dark:text-emerald-300/90"],
191 ["draft", "text-zinc-500 dark:text-zinc-400/80"],
192 ["merged", "text-violet-600 dark:text-violet-300/90"],
193 ] as const)(
194 "uses a layers badge with the %s stack tone without a link identity",
195 (state, expectedToneClassName) => {
196 expect(
197 resolveThreadPullRequestBadgePresentation({
198 badge: { kind: "stack", layers: 3, state },
199 status: null,
200 }),
201 ).toEqual({
202 Icon: PullRequestGlyph.stack,
203 toneClassName: expectedToneClassName,
204 label: `Stack of 3 pull requests, ${state}`,
205 text: 3,
206 });
207 },
208 );
209
210 it.each([
211 ["open", PullRequestGlyph.pullRequest, "text-emerald-600 dark:text-emerald-300/90"],
212 ["draft", PullRequestGlyph.draft, "text-zinc-500 dark:text-zinc-400/80"],
213 ["merged", PullRequestGlyph.merged, "text-violet-600 dark:text-violet-300/90"],
214 ] as const)(
215 "draws the count of unrelated linked pull requests with their %s aggregate state",
216 (state, expectedIcon, expectedToneClassName) => {
217 const fixture = status().pr;
218 if (!fixture) throw new Error("Expected pull request fixture");
219 const closedStatus = prStatusIndicator(
220 { ...fixture, state: "closed", isDraft: false },
221 undefined,
222 );
223 if (!closedStatus) throw new Error("Expected pull request status");
224
225 expect(
226 resolveThreadPullRequestBadgePresentation({
227 badge: { kind: "pull-request", others: 2, state },
228 number: fixture.number,
229 url: fixture.url,
230 status: closedStatus,
231 }),
232 ).toEqual({
233 Icon: expectedIcon,
234 toneClassName: expectedToneClassName,
235 label: `PR #42 - Closed: PR branch, and 2 more linked; overall ${state}`,
236 text: "+3",
237 });
238 },
239 );
240
241 it("omits the control when neither a stack nor a linked identity can be shown", () => {
242 expect(resolveThreadPullRequestBadgePresentation({ badge: null, status: null })).toBeNull();
135 });243 });
136});244});
apps/web/src/components/ThreadStatusIndicators.tsx+76-99
...@@ -14,7 +14,7 @@ import {...@@ -14,7 +14,7 @@ import {
14 visibleThreadPullRequests,14 visibleThreadPullRequests,
15 type ThreadPullRequestBadge,15 type ThreadPullRequestBadge,
16} from "@t3tools/shared/threadPullRequests";16} from "@t3tools/shared/threadPullRequests";
17import { FolderGit2Icon, GitPullRequestArrowIcon, LayersIcon, TerminalIcon } from "lucide-react";17import { FolderGit2Icon, TerminalIcon } from "lucide-react";
18import { useMemo, type MouseEvent } from "react";18import { useMemo, type MouseEvent } from "react";
19import { buttonVariants, InlineButton } from "./ui/button";19import { buttonVariants, InlineButton } from "./ui/button";
20import { cn } from "../lib/utils";20import { cn } from "../lib/utils";
...@@ -31,11 +31,17 @@ import type { SidebarThreadSummary } from "../types";...@@ -31,11 +31,17 @@ import type { SidebarThreadSummary } from "../types";
31import { formatWorktreePathForDisplay } from "../worktreeCleanup";31import { formatWorktreePathForDisplay } from "../worktreeCleanup";
32import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";32import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
33import { pullRequestListLines } from "./pullRequest/pullRequestListLines";33import { pullRequestListLines } from "./pullRequest/pullRequestListLines";
34import {
35 PULL_REQUEST_STATE_PRESENTATION,
36 PullRequestGlyph,
37 type PullRequestGlyphIcon,
38} from "./pullRequest/pullRequestIcons";
34import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation";39import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation";
3540
36export interface PrStatusIndicator {41export interface PrStatusIndicator {
37 label: string;42 label: string;
38 colorClass: string;43 colorClass: string;
44 Icon: PullRequestGlyphIcon;
39 tooltip: string;45 tooltip: string;
40 tooltipLead: string;46 tooltipLead: string;
41 tooltipTitle: string;47 tooltipTitle: string;
...@@ -125,16 +131,55 @@ export {...@@ -125,16 +131,55 @@ export {
125 type ThreadPullRequestBadge,131 type ThreadPullRequestBadge,
126} from "@t3tools/shared/threadPullRequests";132} from "@t3tools/shared/threadPullRequests";
127133
128/** The glyph a row's badge wears: the layers icon for a stack, the pull-request one otherwise. */134export interface ThreadPullRequestBadgePresentation {
129function ThreadPullRequestBadgeIcon({135 readonly Icon: PullRequestGlyphIcon;
130 icon,136 readonly toneClassName: string;
131 className,137 readonly label: string;
138 readonly text: string | number;
139}
140
141/** Resolve the complete badge appearance before rendering it in the sidebar or composer. */
142export function resolveThreadPullRequestBadgePresentation({
143 badge,
144 number,
145 url,
146 status,
132}: {147}: {
133 icon: "stack" | "pull-request";148 readonly badge: ThreadPullRequestBadge | null;
134 className?: string | undefined;149 readonly number?: number | undefined;
135}) {150 readonly url?: string | undefined;
136 const Icon = icon === "stack" ? LayersIcon : GitPullRequestArrowIcon;151 readonly status: PrStatusIndicator | null;
137 return <Icon aria-hidden className={cn("size-3 shrink-0", className)} />;152}): ThreadPullRequestBadgePresentation | null {
153 // The badge already folds every visible link into one state, draft included, so both the
154 // stack and the linked count index the shared table directly rather than the single-PR resolver.
155 if (badge?.kind === "stack") {
156 const aggregate = PULL_REQUEST_STATE_PRESENTATION[badge.state];
157 return {
158 Icon: PullRequestGlyph.stack,
159 toneClassName: aggregate.toneClassName,
160 label: `Stack of ${badge.layers} pull requests, ${aggregate.label.toLowerCase()}`,
161 text: badge.layers,
162 };
163 }
164 if (number === undefined || url === undefined) return null;
165
166 const tooltip = status?.tooltip ?? `PR #${number}, status pending`;
167 if (badge?.kind === "pull-request" && badge.others > 0) {
168 // Unrelated links fold into one state, so a count of merged PRs reads as merged.
169 const aggregate = PULL_REQUEST_STATE_PRESENTATION[badge.state];
170 return {
171 Icon: aggregate.Icon,
172 toneClassName: aggregate.toneClassName,
173 label: `${tooltip}, and ${badge.others} more linked; overall ${aggregate.label.toLowerCase()}`,
174 text: `+${badge.others + 1}`,
175 };
176 }
177 return {
178 Icon: status?.Icon ?? PullRequestGlyph.pullRequest,
179 toneClassName: status?.colorClass ?? "text-muted-foreground",
180 label: tooltip,
181 text: number,
182 };
138}183}
139184
140/** The complete linked-PR control shared by the sidebar and composer footer. */185/** The complete linked-PR control shared by the sidebar and composer footer. */
...@@ -155,16 +200,9 @@ export function ThreadPullRequestBadgeControl({...@@ -155,16 +200,9 @@ export function ThreadPullRequestBadgeControl({
155 onOpenStack: () => void;200 onOpenStack: () => void;
156 onOpenPullRequest: (event: MouseEvent<HTMLAnchorElement>) => void;201 onOpenPullRequest: (event: MouseEvent<HTMLAnchorElement>) => void;
157}) {202}) {
203 const presentation = resolveThreadPullRequestBadgePresentation({ badge, number, url, status });
204 if (presentation === null) return null;
158 const isStack = badge?.kind === "stack";205 const isStack = badge?.kind === "stack";
159 const linkedCount = badge?.kind === "pull-request" && badge.others > 0 ? badge.others + 1 : null;
160 if (!isStack && (number === undefined || url === undefined)) return null;
161 const label = isStack
162 ? `Stack of ${badge.layers} pull requests, ${badge.state}`
163 : `${status?.tooltip ?? `PR #${number}, status pending`}${
164 badge?.kind === "pull-request" && badge.others > 0
165 ? `, and ${badge.others} more linked; overall ${badge.state}`
166 : ""
167 }`;
168 const className = cn(206 const className = cn(
169 variant === "ghost"207 variant === "ghost"
170 ? buttonVariants({ variant: "ghost", size: "xs" })208 ? buttonVariants({ variant: "ghost", size: "xs" })
...@@ -172,14 +210,12 @@ export function ThreadPullRequestBadgeControl({...@@ -172,14 +210,12 @@ export function ThreadPullRequestBadgeControl({
172 "text-xs tabular-nums",210 "text-xs tabular-nums",
173 variant === "ghost" &&211 variant === "ghost" &&
174 "font-normal text-xs! active:scale-100 [--control-icon-color:currentColor]",212 "font-normal text-xs! active:scale-100 [--control-icon-color:currentColor]",
175 badge !== null && (isStack || linkedCount !== null)213 presentation.toneClassName,
176 ? PR_STATE_COLOR_CLASS[badge.state]
177 : (status?.colorClass ?? "text-muted-foreground"),
178 );214 );
179 const content = (215 const content = (
180 <>216 <>
181 <ThreadPullRequestBadgeIcon icon={badge?.kind ?? "pull-request"} />217 <presentation.Icon aria-hidden className="size-3 shrink-0" />
182 {isStack ? badge.layers : linkedCount !== null ? `+${linkedCount}` : number}218 {presentation.text}
183 </>219 </>
184 );220 );
185 return (221 return (
...@@ -189,7 +225,7 @@ export function ThreadPullRequestBadgeControl({...@@ -189,7 +225,7 @@ export function ThreadPullRequestBadgeControl({
189 isStack ? (225 isStack ? (
190 <InlineButton226 <InlineButton
191 className={className}227 className={className}
192 aria-label={label}228 aria-label={presentation.label}
193 onPointerDown={(event) => event.stopPropagation()}229 onPointerDown={(event) => event.stopPropagation()}
194 onClick={(event) => {230 onClick={(event) => {
195 event.preventDefault();231 event.preventDefault();
...@@ -203,7 +239,7 @@ export function ThreadPullRequestBadgeControl({...@@ -203,7 +239,7 @@ export function ThreadPullRequestBadgeControl({
203 target="_blank"239 target="_blank"
204 rel="noopener noreferrer"240 rel="noopener noreferrer"
205 className={className}241 className={className}
206 aria-label={label}242 aria-label={presentation.label}
207 onPointerDown={(event) => event.stopPropagation()}243 onPointerDown={(event) => event.stopPropagation()}
208 onClick={onOpenPullRequest}244 onClick={onOpenPullRequest}
209 />245 />
...@@ -212,7 +248,7 @@ export function ThreadPullRequestBadgeControl({...@@ -212,7 +248,7 @@ export function ThreadPullRequestBadgeControl({
212 >248 >
213 {content}249 {content}
214 </TooltipTrigger>250 </TooltipTrigger>
215 <TooltipPopup side="top">{label}</TooltipPopup>251 <TooltipPopup side="top">{presentation.label}</TooltipPopup>
216 </Tooltip>252 </Tooltip>
217 );253 );
218}254}
...@@ -254,7 +290,7 @@ export function ThreadPullRequestsMiniList({...@@ -254,7 +290,7 @@ export function ThreadPullRequestsMiniList({
254 className={cn("size-3 shrink-0", presentation.toneClassName)}290 className={cn("size-3 shrink-0", presentation.toneClassName)}
255 />291 />
256 ) : (292 ) : (
257 <GitPullRequestArrowIcon293 <PullRequestGlyph.pullRequest
258 aria-hidden294 aria-hidden
259 className="size-3 shrink-0 stroke-muted-foreground"295 className="size-3 shrink-0 stroke-muted-foreground"
260 />296 />
...@@ -275,83 +311,24 @@ export function ThreadPullRequestsMiniList({...@@ -275,83 +311,24 @@ export function ThreadPullRequestsMiniList({
275 );311 );
276}312}
277313
278/** The ink each pull-request state wears in the sidebar, shared by the number and stack badges. */
279const PR_STATE_COLOR_CLASS: Record<ThreadPullRequestBadge["state"], string> = {
280 open: "text-emerald-600 dark:text-emerald-300/90",
281 merged: "text-violet-600 dark:text-violet-300/90",
282 closed: "text-red-600 dark:text-red-300/90",
283 draft: "text-zinc-500 dark:text-zinc-400/80",
284};
285
286export function settledPrHoverColorClass(
287 state: NonNullable<ThreadPr>["state"],
288 isDraft = false,
289): string {
290 switch (state) {
291 case "open":
292 if (isDraft) {
293 return "group-hover/sidebar-row:text-zinc-500 dark:group-hover/sidebar-row:text-zinc-400/80";
294 }
295 return "group-hover/sidebar-row:text-emerald-600 dark:group-hover/sidebar-row:text-emerald-300/90";
296 case "merged":
297 return "group-hover/sidebar-row:text-violet-600 dark:group-hover/sidebar-row:text-violet-300/90";
298 case "closed":
299 return "group-hover/sidebar-row:text-red-600 dark:group-hover/sidebar-row:text-red-300/90";
300 }
301}
302
303export function prStatusIndicator(314export function prStatusIndicator(
304 pr: ThreadPr,315 pr: ThreadPr,
305 provider: VcsStatusResult["sourceControlProvider"] | null | undefined,316 provider: VcsStatusResult["sourceControlProvider"] | null | undefined,
306): PrStatusIndicator | null {317): PrStatusIndicator | null {
307 function formatPrState(pr: NonNullable<ThreadPr>): string {
308 if (pr.state === "open" && pr.isDraft === true) return "Draft";
309 return pr.state.charAt(0).toUpperCase() + pr.state.slice(1);
310 }
311
312 function formatPrStatusLead(pr: NonNullable<ThreadPr>, changeRequestShortName: string): string {
313 return `${changeRequestShortName} #${pr.number} - ${formatPrState(pr)}`;
314 }
315 if (!pr) return null;318 if (!pr) return null;
316 const presentation = resolveChangeRequestPresentation(provider);319 const presentation = resolveChangeRequestPresentation(provider);
320 const state = resolvePullRequestState({ state: pr.state, isDraft: pr.isDraft === true });
317321
318 const tooltipLead = formatPrStatusLead(pr, presentation.shortName);322 const tooltipLead = `${presentation.shortName} #${pr.number} - ${state.label}`;
319 const tooltip = `${tooltipLead}: ${pr.title}`;323 return {
320324 label: `${presentation.shortName} ${state.label.toLowerCase()}`,
321 if (pr.state === "open") {325 colorClass: state.toneClassName,
322 const isDraft = pr.isDraft === true;326 Icon: state.Icon,
323 return {327 tooltip: `${tooltipLead}: ${pr.title}`,
324 label: `${presentation.shortName} ${isDraft ? "draft" : "open"}`,328 tooltipLead,
325 colorClass: isDraft329 tooltipTitle: pr.title,
326 ? "text-zinc-500 dark:text-zinc-400/80"330 url: pr.url,
327 : "text-emerald-600 dark:text-emerald-300/90",331 };
328 tooltip,
329 tooltipLead,
330 tooltipTitle: pr.title,
331 url: pr.url,
332 };
333 }
334 if (pr.state === "closed") {
335 return {
336 label: `${presentation.shortName} closed`,
337 colorClass: "text-red-600 dark:text-red-300/90",
338 tooltip,
339 tooltipLead,
340 tooltipTitle: pr.title,
341 url: pr.url,
342 };
343 }
344 if (pr.state === "merged") {
345 return {
346 label: `${presentation.shortName} merged`,
347 colorClass: "text-violet-600 dark:text-violet-300/90",
348 tooltip,
349 tooltipLead,
350 tooltipTitle: pr.title,
351 url: pr.url,
352 };
353 }
354 return null;
355}332}
356333
357export function ChangeRequestStatusIcon({334export function ChangeRequestStatusIcon({
...@@ -529,7 +506,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar...@@ -529,7 +506,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar
529 </Tooltip>506 </Tooltip>
530 ) : null}507 ) : null}
531 {pendingLink ? (508 {pendingLink ? (
532 <GitPullRequestArrowIcon509 <PullRequestGlyph.pullRequest
533 className="size-3 text-muted-foreground"510 className="size-3 text-muted-foreground"
534 aria-label={`PR #${pendingLink.number}, status pending`}511 aria-label={`PR #${pendingLink.number}, status pending`}
535 />512 />
apps/web/src/components/chat/MessagesTimeline.tsx+3-3
...@@ -112,7 +112,6 @@ import {...@@ -112,7 +112,6 @@ import {
112 CircleAlertIcon,112 CircleAlertIcon,
113 DownloadIcon,113 DownloadIcon,
114 EyeIcon,114 EyeIcon,
115 GitPullRequestIcon,
116 GlobeIcon,115 GlobeIcon,
117 HammerIcon,116 HammerIcon,
118 MessageCircleIcon,117 MessageCircleIcon,
...@@ -250,6 +249,7 @@ import {...@@ -250,6 +249,7 @@ import {
250 formatReviewCommentFence,249 formatReviewCommentFence,
251 type ReviewCommentContext,250 type ReviewCommentContext,
252} from "../../reviewCommentContext";251} from "../../reviewCommentContext";
252import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
253253
254// ---------------------------------------------------------------------------254// ---------------------------------------------------------------------------
255// Context — shared state consumed by every row component via Context.255// Context — shared state consumed by every row component via Context.
...@@ -3326,7 +3326,7 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry...@@ -3326,7 +3326,7 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry
3326 <UserMessageContextChip3326 <UserMessageContextChip
3327 icon={3327 icon={
3328 isPullRequest ? (3328 isPullRequest ? (
3329 <GitPullRequestIcon3329 <PullRequestGlyph.pullRequest
3330 className={cn(3330 className={cn(
3331 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,3331 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
3332 CONTEXT_INLINE_CHIP_ICON_TONE_CLASS_NAMES["pull-request"],3332 CONTEXT_INLINE_CHIP_ICON_TONE_CLASS_NAMES["pull-request"],
...@@ -3877,7 +3877,7 @@ function ToolActivityImageIcon(props: {...@@ -3877,7 +3877,7 @@ function ToolActivityImageIcon(props: {
3877function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) {3877function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) {
3878 switch (name) {3878 switch (name) {
3879 case "pull-request":3879 case "pull-request":
3880 return <GitPullRequestIcon className={className} aria-hidden />;3880 return <PullRequestGlyph.pullRequest className={className} aria-hidden />;
3881 case "bot":3881 case "bot":
3882 return <BotIcon className={className} aria-hidden />;3882 return <BotIcon className={className} aria-hidden />;
3883 case "brain":3883 case "brain":
apps/web/src/components/composerContextPresentation.tsx+3-2
...@@ -3,7 +3,7 @@ import { ReadOnlySourcePreview } from "./files/AttachmentFilePreview";...@@ -3,7 +3,7 @@ import { ReadOnlySourcePreview } from "./files/AttachmentFilePreview";
3import type { PreviewAnnotationPayload } from "@t3tools/contracts";3import type { PreviewAnnotationPayload } from "@t3tools/contracts";
4import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";4import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
5import { videoMimeType } from "@t3tools/shared/video";5import { videoMimeType } from "@t3tools/shared/video";
6import { GitPullRequestIcon, MessageCircleIcon, MousePointerClickIcon } from "lucide-react";6import { MessageCircleIcon, MousePointerClickIcon } from "lucide-react";
7import { createContext, type MouseEvent, type ReactElement, type ReactNode, use } from "react";7import { createContext, type MouseEvent, type ReactElement, type ReactNode, use } from "react";
88
9import type { ComposerFileAttachment, ComposerImageAttachment } from "~/composerDraftStore";9import type { ComposerFileAttachment, ComposerImageAttachment } from "~/composerDraftStore";
...@@ -14,6 +14,7 @@ import {...@@ -14,6 +14,7 @@ import {
14 type AttachmentUploadState,14 type AttachmentUploadState,
15} from "~/lib/attachmentUploadState";15} from "~/lib/attachmentUploadState";
16import { cn } from "~/lib/utils";16import { cn } from "~/lib/utils";
17import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
17import {18import {
18 fileContextReference,19 fileContextReference,
19 imageContextReference,20 imageContextReference,
...@@ -392,7 +393,7 @@ const composerContextPresentationRegistry = createContextPresentationRegistry<...@@ -392,7 +393,7 @@ const composerContextPresentationRegistry = createContextPresentationRegistry<
392 <ContextChip393 <ContextChip
393 icon={394 icon={
394 isPullRequest ? (395 isPullRequest ? (
395 <GitPullRequestIcon396 <PullRequestGlyph.pullRequest
396 className={cn(397 className={cn(
397 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,398 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
398 CONTEXT_INLINE_CHIP_ICON_TONE_CLASS_NAMES["pull-request"],399 CONTEXT_INLINE_CHIP_ICON_TONE_CLASS_NAMES["pull-request"],
apps/web/src/components/contextChipParts.tsx+5-2
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import type { PullRequestContextMetadata } from "@t3tools/contracts";1import type { PullRequestContextMetadata } from "@t3tools/contracts";
2import { CircleDashedIcon, FilmIcon, GitPullRequestIcon, ImageIcon } from "lucide-react";2import { CircleDashedIcon, FilmIcon, ImageIcon } from "lucide-react";
3import {3import {
4 useState,4 useState,
5 type ComponentProps,5 type ComponentProps,
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9} from "react";9} from "react";
1010
11import { cn } from "~/lib/utils";11import { cn } from "~/lib/utils";
12import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
12import { PierreEntryIcon } from "./chat/PierreEntryIcon";13import { PierreEntryIcon } from "./chat/PierreEntryIcon";
13import {14import {
14 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,15 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
...@@ -134,7 +135,9 @@ export function PullRequestChip(props: {...@@ -134,7 +135,9 @@ export function PullRequestChip(props: {
134 data-markdown-copy={props.copyMarkdown}135 data-markdown-copy={props.copyMarkdown}
135 onClick={(event) => props.onOpen(event, props.metadata.url)}136 onClick={(event) => props.onOpen(event, props.metadata.url)}
136 >137 >
137 <GitPullRequestIcon className={cn(COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, "size-3.5")} />138 <PullRequestGlyph.pullRequest
139 className={cn(COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, "size-3.5")}
140 />
138 <span className={props.labelClassName}>{props.label}</span>141 <span className={props.labelClassName}>{props.label}</span>
139 </Button>142 </Button>
140 }143 }
apps/web/src/components/pullRequest/PullRequestCommentComposer.tsx+4-9
...@@ -1,11 +1,5 @@...@@ -1,11 +1,5 @@
1import type { EnvironmentId, PullRequestDetailView, PullRequestRef } from "@t3tools/contracts";1import type { EnvironmentId, PullRequestDetailView, PullRequestRef } from "@t3tools/contracts";
2import {2import { MessageSquareIcon, SendIcon, XIcon } from "lucide-react";
3 GitPullRequestClosedIcon,
4 MessageSquareIcon,
5 RotateCcwIcon,
6 SendIcon,
7 XIcon,
8} from "lucide-react";
9import { useRef, useState } from "react";3import { useRef, useState } from "react";
104
11import { useAtomCommand } from "~/state/use-atom-command";5import { useAtomCommand } from "~/state/use-atom-command";
...@@ -15,6 +9,7 @@ import { Button } from "../ui/button";...@@ -15,6 +9,7 @@ import { Button } from "../ui/button";
15import { Popover, PopoverClose, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover";9import { Popover, PopoverClose, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover";
16import { Textarea } from "../ui/textarea";10import { Textarea } from "../ui/textarea";
17import { toastManager } from "../ui/toast";11import { toastManager } from "../ui/toast";
12import { PullRequestGlyph } from "./pullRequestIcons";
1813
19export function PullRequestCommentComposer({14export function PullRequestCommentComposer({
20 environmentId,15 environmentId,
...@@ -146,9 +141,9 @@ export function PullRequestCommentComposer({...@@ -146,9 +141,9 @@ export function PullRequestCommentComposer({
146 onClick={() => void submit(followUpAction)}141 onClick={() => void submit(followUpAction)}
147 >142 >
148 {followUpAction === "close" ? (143 {followUpAction === "close" ? (
149 <GitPullRequestClosedIcon className="size-3.5" />144 <PullRequestGlyph.closed className="size-3.5" />
150 ) : (145 ) : (
151 <RotateCcwIcon className="size-3.5" />146 <PullRequestGlyph.reopen className="size-3.5" />
152 )}147 )}
153 {submitting === followUpAction148 {submitting === followUpAction
154 ? followUpAction === "close"149 ? followUpAction === "close"
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+18-22
...@@ -29,12 +29,7 @@ import {...@@ -29,12 +29,7 @@ import {
29 FolderGit2Icon,29 FolderGit2Icon,
30 GitBranchIcon,30 GitBranchIcon,
31 GitCommitHorizontalIcon,31 GitCommitHorizontalIcon,
32 GitMergeIcon,
33 GitPullRequestClosedIcon,
34 GitPullRequestDraftIcon,
35 GitPullRequestIcon,
36 HammerIcon,32 HammerIcon,
37 LayersIcon,
38 MessageCircleQuestionIcon,33 MessageCircleQuestionIcon,
39 MessageSquareIcon,34 MessageSquareIcon,
40 LinkIcon,35 LinkIcon,
...@@ -176,6 +171,7 @@ import {...@@ -176,6 +171,7 @@ import {
176 resolvePullRequestState,171 resolvePullRequestState,
177 summarizePullRequestChecks,172 summarizePullRequestChecks,
178} from "./pullRequestPresentation";173} from "./pullRequestPresentation";
174import { PullRequestGlyph } from "./pullRequestIcons";
179175
180type DetailTab = "summary" | "timeline" | "code";176type DetailTab = "summary" | "timeline" | "code";
181177
...@@ -455,7 +451,7 @@ function PullRequestBaseFreshnessWarning({...@@ -455,7 +451,7 @@ function PullRequestBaseFreshnessWarning({
455 disabled={pending}451 disabled={pending}
456 onClick={() => onUpdate(method)}452 onClick={() => onUpdate(method)}
457 >453 >
458 <GitMergeIcon aria-hidden className="size-3" />454 <PullRequestGlyph.merged aria-hidden className="size-3" />
459 {method === "rebase" ? "Update with rebase" : "Update branch"}455 {method === "rebase" ? "Update with rebase" : "Update branch"}
460 </Button>456 </Button>
461 ))}457 ))}
...@@ -1803,7 +1799,7 @@ export function PullRequestDetailPanel({...@@ -1803,7 +1799,7 @@ export function PullRequestDetailPanel({
1803 role="img"1799 role="img"
1804 aria-label={armedAutoMergeLabel}1800 aria-label={armedAutoMergeLabel}
1805 >1801 >
1806 <GitMergeIcon aria-hidden className="size-3.5" />1802 <PullRequestGlyph.merged aria-hidden className="size-3.5" />
1807 <span className="@max-[30rem]/pr-header:hidden">{armedAutoMergeLabel}</span>1803 <span className="@max-[30rem]/pr-header:hidden">{armedAutoMergeLabel}</span>
1808 </Badge>1804 </Badge>
1809 }1805 }
...@@ -1828,7 +1824,7 @@ export function PullRequestDetailPanel({...@@ -1828,7 +1824,7 @@ export function PullRequestDetailPanel({
1828 handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"1824 handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"
1829 }1825 }
1830 >1826 >
1831 <TriangleAlertIcon aria-hidden className="size-3.5" />1827 <PullRequestGlyph.conflicting aria-hidden className="size-3.5" />
1832 <span className="@max-[30rem]/pr-header:hidden">1828 <span className="@max-[30rem]/pr-header:hidden">
1833 {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"}1829 {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"}
1834 </span>1830 </span>
...@@ -1852,7 +1848,7 @@ export function PullRequestDetailPanel({...@@ -1852,7 +1848,7 @@ export function PullRequestDetailPanel({
1852 onClick={() => void perform("ready")}1848 onClick={() => void perform("ready")}
1853 aria-label="Ready for review"1849 aria-label="Ready for review"
1854 >1850 >
1855 <GitPullRequestIcon aria-hidden className="size-3.5" />1851 <PullRequestGlyph.pullRequest aria-hidden className="size-3.5" />
1856 <span className="@max-[30rem]/pr-header:hidden">Ready for review</span>1852 <span className="@max-[30rem]/pr-header:hidden">Ready for review</span>
1857 </Button>1853 </Button>
1858 </span>1854 </span>
...@@ -1878,7 +1874,7 @@ export function PullRequestDetailPanel({...@@ -1878,7 +1874,7 @@ export function PullRequestDetailPanel({
1878 : pendingAutoMergeLabel1874 : pendingAutoMergeLabel
1879 }1875 }
1880 >1876 >
1881 <GitMergeIcon aria-hidden className="size-3.5" />1877 <PullRequestGlyph.merged aria-hidden className="size-3.5" />
1882 <span className="@max-[30rem]/pr-header:hidden">1878 <span className="@max-[30rem]/pr-header:hidden">
1883 {pendingAction === "enable-auto-merge"1879 {pendingAction === "enable-auto-merge"
1884 ? "Enabling..."1880 ? "Enabling..."
...@@ -1902,7 +1898,7 @@ export function PullRequestDetailPanel({...@@ -1902,7 +1898,7 @@ export function PullRequestDetailPanel({
1902 role="img"1898 role="img"
1903 aria-label={armedAutoMergeLabel}1899 aria-label={armedAutoMergeLabel}
1904 >1900 >
1905 <GitMergeIcon aria-hidden className="size-3.5" />1901 <PullRequestGlyph.merged aria-hidden className="size-3.5" />
1906 <span className="@max-[30rem]/pr-header:hidden">{armedAutoMergeLabel}</span>1902 <span className="@max-[30rem]/pr-header:hidden">{armedAutoMergeLabel}</span>
1907 </Badge>1903 </Badge>
1908 }1904 }
...@@ -1926,7 +1922,7 @@ export function PullRequestDetailPanel({...@@ -1926,7 +1922,7 @@ export function PullRequestDetailPanel({
1926 pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel1922 pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel
1927 }1923 }
1928 >1924 >
1929 <GitMergeIcon aria-hidden className="size-3.5" />1925 <PullRequestGlyph.merged aria-hidden className="size-3.5" />
1930 <span className="@max-[30rem]/pr-header:hidden">1926 <span className="@max-[30rem]/pr-header:hidden">
1931 {pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel}1927 {pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel}
1932 </span>1928 </span>
...@@ -2031,9 +2027,9 @@ export function PullRequestDetailPanel({...@@ -2031,9 +2027,9 @@ export function PullRequestDetailPanel({
2031 onClick={() => void perform(detail.isDraft ? "ready" : "draft")}2027 onClick={() => void perform(detail.isDraft ? "ready" : "draft")}
2032 >2028 >
2033 {detail.isDraft ? (2029 {detail.isDraft ? (
2034 <GitPullRequestIcon className="size-3.5" />2030 <PullRequestGlyph.pullRequest className="size-3.5" />
2035 ) : (2031 ) : (
2036 <GitPullRequestDraftIcon className="size-3.5" />2032 <PullRequestGlyph.draft className="size-3.5" />
2037 )}2033 )}
2038 {detail.isDraft ? "Ready for review" : "Convert to draft"}2034 {detail.isDraft ? "Ready for review" : "Convert to draft"}
2039 </MenuItem>2035 </MenuItem>
...@@ -2043,7 +2039,7 @@ export function PullRequestDetailPanel({...@@ -2043,7 +2039,7 @@ export function PullRequestDetailPanel({
2043 disabled={actionPending}2039 disabled={actionPending}
2044 onClick={() => setConfirmation({ open: true, action: "merge" })}2040 onClick={() => setConfirmation({ open: true, action: "merge" })}
2045 >2041 >
2046 <GitMergeIcon className="size-3.5" />2042 <PullRequestGlyph.merged className="size-3.5" />
2047 Merge now2043 Merge now
2048 </MenuItem>2044 </MenuItem>
2049 ) : null}2045 ) : null}
...@@ -2055,7 +2051,7 @@ export function PullRequestDetailPanel({...@@ -2055,7 +2051,7 @@ export function PullRequestDetailPanel({
2055 disabled={actionPending}2051 disabled={actionPending}
2056 onClick={() => void perform("disable-auto-merge")}2052 onClick={() => void perform("disable-auto-merge")}
2057 >2053 >
2058 <GitMergeIcon className="size-3.5" />2054 <PullRequestGlyph.merged className="size-3.5" />
2059 Disable auto-merge2055 Disable auto-merge
2060 </MenuItem>2056 </MenuItem>
2061 ) : showsAutoMerge ? (2057 ) : showsAutoMerge ? (
...@@ -2065,7 +2061,7 @@ export function PullRequestDetailPanel({...@@ -2065,7 +2061,7 @@ export function PullRequestDetailPanel({
2065 setConfirmation({ open: true, action: "enable-auto-merge" })2061 setConfirmation({ open: true, action: "enable-auto-merge" })
2066 }2062 }
2067 >2063 >
2068 <GitMergeIcon className="size-3.5" />2064 <PullRequestGlyph.merged className="size-3.5" />
2069 Enable auto-merge2065 Enable auto-merge
2070 </MenuItem>2066 </MenuItem>
2071 ) : null}2067 ) : null}
...@@ -2100,7 +2096,7 @@ export function PullRequestDetailPanel({...@@ -2100,7 +2096,7 @@ export function PullRequestDetailPanel({
2100 {/* The radio item lays its children out as one block, so the2096 {/* The radio item lays its children out as one block, so the
2101 icon and the label need their own row to share a line. */}2097 icon and the label need their own row to share a line. */}
2102 <span className="flex min-w-0 items-center gap-2">2098 <span className="flex min-w-0 items-center gap-2">
2103 <GitMergeIcon className="size-3.5" />2099 <PullRequestGlyph.merged className="size-3.5" />
2104 <span>{PULL_REQUEST_MERGE_METHOD_LABELS[method]}</span>2100 <span>{PULL_REQUEST_MERGE_METHOD_LABELS[method]}</span>
2105 </span>2101 </span>
2106 </MenuRadioItem>2102 </MenuRadioItem>
...@@ -2143,7 +2139,7 @@ export function PullRequestDetailPanel({...@@ -2143,7 +2139,7 @@ export function PullRequestDetailPanel({
2143 disabled={actionPending}2139 disabled={actionPending}
2144 onClick={() => setConfirmation({ open: true, action: "close" })}2140 onClick={() => setConfirmation({ open: true, action: "close" })}
2145 >2141 >
2146 <GitPullRequestClosedIcon className="size-3.5" />2142 <PullRequestGlyph.closed className="size-3.5" />
2147 Close pull request2143 Close pull request
2148 </MenuItem>2144 </MenuItem>
2149 </>2145 </>
...@@ -2151,7 +2147,7 @@ export function PullRequestDetailPanel({...@@ -2151,7 +2147,7 @@ export function PullRequestDetailPanel({
2151 <>2147 <>
2152 <MenuSeparator />2148 <MenuSeparator />
2153 <MenuItem disabled={actionPending} onClick={() => void perform("reopen")}>2149 <MenuItem disabled={actionPending} onClick={() => void perform("reopen")}>
2154 <GitPullRequestIcon className="size-3.5" />2150 <PullRequestGlyph.reopen className="size-3.5" />
2155 Reopen pull request2151 Reopen pull request
2156 </MenuItem>2152 </MenuItem>
2157 </>2153 </>
...@@ -2220,7 +2216,7 @@ export function PullRequestDetailPanel({...@@ -2220,7 +2216,7 @@ export function PullRequestDetailPanel({
2220 render={2216 render={
2221 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">2217 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
2222 {isStackedPullRequest ? (2218 {isStackedPullRequest ? (
2223 <LayersIcon2219 <PullRequestGlyph.stack
2224 aria-label="Stacked pull request"2220 aria-label="Stacked pull request"
2225 className="size-3 shrink-0"2221 className="size-3 shrink-0"
2226 />2222 />
...@@ -2404,7 +2400,7 @@ export function PullRequestDetailPanel({...@@ -2404,7 +2400,7 @@ export function PullRequestDetailPanel({
2404 render={2400 render={
2405 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">2401 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
2406 {isStackedPullRequest ? (2402 {isStackedPullRequest ? (
2407 <LayersIcon2403 <PullRequestGlyph.stack
2408 aria-label="Stacked pull request"2404 aria-label="Stacked pull request"
2409 className="size-3 shrink-0"2405 className="size-3 shrink-0"
2410 />2406 />
apps/web/src/components/pullRequest/PullRequestListFilters.tsx+2-2
...@@ -15,7 +15,6 @@ import {...@@ -15,7 +15,6 @@ import {
15 CircleXIcon,15 CircleXIcon,
16 EyeOffIcon,16 EyeOffIcon,
17 FolderGit2Icon,17 FolderGit2Icon,
18 GitPullRequestDraftIcon,
19 LayersIcon,18 LayersIcon,
20 ListFilterIcon,19 ListFilterIcon,
21 SearchIcon,20 SearchIcon,
...@@ -51,6 +50,7 @@ import {...@@ -51,6 +50,7 @@ import {
51 type PullRequestLabelFacet,50 type PullRequestLabelFacet,
52} from "./pullRequestList.logic";51} from "./pullRequestList.logic";
53import { PullRequestActorAvatar } from "./pullRequestPresentation";52import { PullRequestActorAvatar } from "./pullRequestPresentation";
53import { PullRequestGlyph } from "./pullRequestIcons";
5454
55export interface PullRequestFilterOption<Value extends string> {55export interface PullRequestFilterOption<Value extends string> {
56 readonly value: Value;56 readonly value: Value;
...@@ -143,7 +143,7 @@ export const pullRequestProjectKey = (project: {...@@ -143,7 +143,7 @@ export const pullRequestProjectKey = (project: {
143143
144const DRAFT_OPTIONS = [144const DRAFT_OPTIONS = [
145 { value: UNFILTERED_VALUE, label: "All", Icon: LayersIcon },145 { value: UNFILTERED_VALUE, label: "All", Icon: LayersIcon },
146 { value: "only", label: "Drafts only", Icon: GitPullRequestDraftIcon },146 { value: "only", label: "Drafts only", Icon: PullRequestGlyph.draft },
147 { value: "hide", label: "Hide drafts", Icon: EyeOffIcon },147 { value: "hide", label: "Hide drafts", Icon: EyeOffIcon },
148] as const satisfies ReadonlyArray<PullRequestFilterOption<string>>;148] as const satisfies ReadonlyArray<PullRequestFilterOption<string>>;
149149
apps/web/src/components/pullRequest/PullRequestRow.tsx+18-6
...@@ -12,6 +12,7 @@ import { pullRequestLabelColor, type EnvironmentPullRequestEntry } from "./pullR...@@ -12,6 +12,7 @@ import { pullRequestLabelColor, type EnvironmentPullRequestEntry } from "./pullR
12import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu";12import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu";
13import {13import {
14 PullRequestActorLabel,14 PullRequestActorLabel,
15 PullRequestConflictGlyph,
15 PullRequestDiffStat,16 PullRequestDiffStat,
16 PullRequestMetaLine,17 PullRequestMetaLine,
17 PullRequestApprovalGlyph,18 PullRequestApprovalGlyph,
...@@ -112,12 +113,23 @@ function PullRequestRowImpl({...@@ -112,12 +113,23 @@ function PullRequestRowImpl({
112 selected ? "bg-accent" : "hover:bg-accent/60",113 selected ? "bg-accent" : "hover:bg-accent/60",
113 )}114 )}
114 >115 >
115 <PullRequestStateGlyph116 {/* The conflict warning rides the corner of the lifecycle glyph, over the arrow's
116 state={entry.state}117 merge circle, so the leading slot stays one icon wide and titles line up whether or
117 isDraft={entry.isDraft}118 not a row is blocked. The background fill cuts it out of the glyph beneath. */}
118 mergeability={entry.mergeability}119 <span className="relative inline-flex shrink-0">
119 baseBranch={entry.baseBranch}120 <PullRequestStateGlyph state={entry.state} isDraft={entry.isDraft} />
120 />121 {/* The wrapper takes the offset, not the icon, so the tooltip trigger inside keeps the
122 badge's size and anchors the popup to it. */}
123 <span className="absolute -right-1 -bottom-1 inline-flex">
124 <PullRequestConflictGlyph
125 state={entry.state}
126 isDraft={entry.isDraft}
127 mergeability={entry.mergeability}
128 baseBranch={entry.baseBranch}
129 className="size-3 fill-background [stroke-width:2.5]"
130 />
131 </span>
132 </span>
121 <span className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1.5">133 <span className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1.5">
122 <span className="col-start-1 row-start-1 block truncate text-sm font-medium text-foreground">134 <span className="col-start-1 row-start-1 block truncate text-sm font-medium text-foreground">
123 {entry.title}135 {entry.title}
apps/web/src/components/pullRequest/PullRequestStackMenu.tsx+6-4
...@@ -6,7 +6,7 @@ import type {...@@ -6,7 +6,7 @@ import type {
6 PullRequestMergeMethod,6 PullRequestMergeMethod,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";8import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
9import { GitMergeIcon, LayersIcon, RefreshCwIcon, TriangleAlertIcon } from "lucide-react";9import { RefreshCwIcon, TriangleAlertIcon } from "lucide-react";
10import { useState } from "react";10import { useState } from "react";
11import { useAtomCommand } from "~/state/use-atom-command";11import { useAtomCommand } from "~/state/use-atom-command";
12import { pullRequestEnvironment } from "~/state/pullRequests";12import { pullRequestEnvironment } from "~/state/pullRequests";
...@@ -25,6 +25,7 @@ import { toastManager } from "../ui/toast";...@@ -25,6 +25,7 @@ import { toastManager } from "../ui/toast";
25import { PullRequestStackLayers } from "./PullRequestStackLayers";25import { PullRequestStackLayers } from "./PullRequestStackLayers";
26import { PullRequestStackHeader } from "./PullRequestStackHeader";26import { PullRequestStackHeader } from "./PullRequestStackHeader";
27import { PullRequestStackLayerContent } from "./PullRequestStackLayerContent";27import { PullRequestStackLayerContent } from "./PullRequestStackLayerContent";
28import { PullRequestGlyph } from "./pullRequestIcons";
2829
29export function PullRequestStackMenu({30export function PullRequestStackMenu({
30 stack,31 stack,
...@@ -133,7 +134,8 @@ export function PullRequestStackMenu({...@@ -133,7 +134,8 @@ export function PullRequestStackMenu({
133 />134 />
134 }135 }
135 >136 >
136 <LayersIcon aria-hidden className="size-3.5" /> {position}/{stack.layers.length}137 <PullRequestGlyph.stack aria-hidden className="size-3.5" /> {position}/
138 {stack.layers.length}
137 {onRetry ? <TriangleAlertIcon aria-hidden className="size-3 text-warning" /> : null}139 {onRetry ? <TriangleAlertIcon aria-hidden className="size-3 text-warning" /> : null}
138 </MenuTrigger>140 </MenuTrigger>
139 }141 }
...@@ -166,7 +168,7 @@ export function PullRequestStackMenu({...@@ -166,7 +168,7 @@ export function PullRequestStackMenu({
166 <MenuSeparator />168 <MenuSeparator />
167 {canMerge ? (169 {canMerge ? (
168 <MenuItem disabled={mergeDisabled} onClick={() => setConfirmation("merge")}>170 <MenuItem disabled={mergeDisabled} onClick={() => setConfirmation("merge")}>
169 <GitMergeIcon aria-hidden />171 <PullRequestGlyph.merged aria-hidden />
170 Merge stack ({mergeLayers.length})172 Merge stack ({mergeLayers.length})
171 </MenuItem>173 </MenuItem>
172 ) : null}174 ) : null}
...@@ -199,7 +201,7 @@ export function PullRequestStackMenu({...@@ -199,7 +201,7 @@ export function PullRequestStackMenu({
199 disabled={mergeDisabled}201 disabled={mergeDisabled}
200 onClick={() => setConfirmation("merge")}202 onClick={() => setConfirmation("merge")}
201 >203 >
202 <GitMergeIcon aria-hidden className="size-3.5" />204 <PullRequestGlyph.merged aria-hidden className="size-3.5" />
203 Merge stack205 Merge stack
204 </Button>206 </Button>
205 </span>207 </span>
apps/web/src/components/pullRequest/PullRequestStackPopover.tsx+2-2
...@@ -1,11 +1,11 @@...@@ -1,11 +1,11 @@
1import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";1import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
2import type { EnvironmentId, PullRequestRef, PullRequestStackMembership } from "@t3tools/contracts";2import type { EnvironmentId, PullRequestRef, PullRequestStackMembership } from "@t3tools/contracts";
3import { LayersIcon } from "lucide-react";
4import { useState } from "react";3import { useState } from "react";
5import { usePullRequestStack } from "~/state/usePullRequestStack";4import { usePullRequestStack } from "~/state/usePullRequestStack";
6import { Menu, MenuTrigger, MenuPopup, MenuGroup, MenuGroupLabel, MenuItem } from "../ui/menu";5import { Menu, MenuTrigger, MenuPopup, MenuGroup, MenuGroupLabel, MenuItem } from "../ui/menu";
7import { PullRequestStackLayers } from "./PullRequestStackLayers";6import { PullRequestStackLayers } from "./PullRequestStackLayers";
8import { PullRequestStackHeader } from "./PullRequestStackHeader";7import { PullRequestStackHeader } from "./PullRequestStackHeader";
8import { PullRequestGlyph } from "./pullRequestIcons";
99
10/** Mounted only while the menu is open, so list rows do not each fetch a stack. */10/** Mounted only while the menu is open, so list rows do not each fetch a stack. */
11function StackBody({11function StackBody({
...@@ -74,7 +74,7 @@ export function PullRequestStackPopover({...@@ -74,7 +74,7 @@ export function PullRequestStackPopover({
74 onClick={(event) => event.stopPropagation()}74 onClick={(event) => event.stopPropagation()}
75 onKeyDown={(event) => event.stopPropagation()}75 onKeyDown={(event) => event.stopPropagation()}
76 >76 >
77 <LayersIcon aria-hidden className="size-3" />77 <PullRequestGlyph.stack aria-hidden className="size-3" />
78 {membership.position}/{membership.size}78 {membership.position}/{membership.size}
79 </MenuTrigger>79 </MenuTrigger>
80 }80 }
apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx+4-3
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";1import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
2import { scopeThreadRef } from "@t3tools/client-runtime/environment";2import { scopeThreadRef } from "@t3tools/client-runtime/environment";
3import type { EnvironmentId, PullRequestRef, ScopedThreadRef, ThreadId } from "@t3tools/contracts";3import type { EnvironmentId, PullRequestRef, ScopedThreadRef, ThreadId } from "@t3tools/contracts";
4import { CheckIcon, LinkIcon, MessageSquareIcon, UnlinkIcon } from "lucide-react";4import { CheckIcon, MessageSquareIcon } from "lucide-react";
5import { useState } from "react";5import { useState } from "react";
6import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility";6import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility";
7import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";7import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";
...@@ -18,6 +18,7 @@ import { Command, CommandInput, CommandItem, CommandList } from "../ui/command";...@@ -18,6 +18,7 @@ import { Command, CommandInput, CommandItem, CommandList } from "../ui/command";
18import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";18import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";
19import { MenuItem } from "../ui/menu";19import { MenuItem } from "../ui/menu";
20import { toastManager } from "../ui/toast";20import { toastManager } from "../ui/toast";
21import { PullRequestGlyph } from "./pullRequestIcons";
2122
22interface PullRequestThreadLinksProps {23interface PullRequestThreadLinksProps {
23 environmentId: EnvironmentId;24 environmentId: EnvironmentId;
...@@ -144,9 +145,9 @@ function EnabledPullRequestThreadLinks({...@@ -144,9 +145,9 @@ function EnabledPullRequestThreadLinks({
144 }}145 }}
145 >146 >
146 {linkedHere ? (147 {linkedHere ? (
147 <UnlinkIcon aria-hidden className="size-3.5" />148 <PullRequestGlyph.unlink aria-hidden className="size-3.5" />
148 ) : (149 ) : (
149 <LinkIcon aria-hidden className="size-3.5" />150 <PullRequestGlyph.link aria-hidden className="size-3.5" />
150 )}151 )}
151 {linkedHere152 {linkedHere
152 ? "Unlink from this thread"153 ? "Unlink from this thread"
apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx+5-7
...@@ -11,9 +11,6 @@ import {...@@ -11,9 +11,6 @@ import {
11 ExternalLinkIcon,11 ExternalLinkIcon,
12 FileCode2Icon,12 FileCode2Icon,
13 GitCommitHorizontalIcon,13 GitCommitHorizontalIcon,
14 GitMergeIcon,
15 GitPullRequestClosedIcon,
16 GitPullRequestIcon,
17 MessageSquareIcon,14 MessageSquareIcon,
18 PencilIcon,15 PencilIcon,
19} from "lucide-react";16} from "lucide-react";
...@@ -51,6 +48,7 @@ import {...@@ -51,6 +48,7 @@ import {
51 pullRequestReviewOutcomeStaleLabel,48 pullRequestReviewOutcomeStaleLabel,
52 pullRequestReviewOutcomeToneClassName,49 pullRequestReviewOutcomeToneClassName,
53} from "./pullRequestPresentation";50} from "./pullRequestPresentation";
51import { PullRequestGlyph } from "./pullRequestIcons";
5452
55/** What every comment on the timeline needs to react; only the subject differs between them. */53/** What every comment on the timeline needs to react; only the subject differs between them. */
56interface ReactionSurface {54interface ReactionSurface {
...@@ -419,16 +417,16 @@ function LifecycleEvent({ event }: { event: PullRequestTimelineEvent }) {...@@ -419,16 +417,16 @@ function LifecycleEvent({ event }: { event: PullRequestTimelineEvent }) {
419 const presentation =417 const presentation =
420 event.kind === "opened"418 event.kind === "opened"
421 ? {419 ? {
422 icon: <GitPullRequestIcon className="size-3.5" />,420 icon: <PullRequestGlyph.pullRequest className="size-3.5" />,
423 label: "Pull request opened",421 label: "Pull request opened",
424 }422 }
425 : event.kind === "merged"423 : event.kind === "merged"
426 ? {424 ? {
427 icon: <GitMergeIcon className="size-3.5" />,425 icon: <PullRequestGlyph.merged className="size-3.5" />,
428 label: "Pull request merged",426 label: "Pull request merged",
429 }427 }
430 : {428 : {
431 icon: <GitPullRequestClosedIcon className="size-3.5" />,429 icon: <PullRequestGlyph.closed className="size-3.5" />,
432 label: "Pull request closed",430 label: "Pull request closed",
433 };431 };
434432
...@@ -629,7 +627,7 @@ export function PullRequestTimelineTab({...@@ -629,7 +627,7 @@ export function PullRequestTimelineTab({
629627
630 {events.length === 0 ? (628 {events.length === 0 ? (
631 <div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">629 <div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
632 <GitPullRequestIcon className="mb-2 size-5" />630 <PullRequestGlyph.pullRequest className="mb-2 size-5" />
633 <p className="text-xs">No activity yet.</p>631 <p className="text-xs">No activity yet.</p>
634 </div>632 </div>
635 ) : null}633 ) : null}
apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx+3-2
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { RefreshIcon } from "~/components/ui/refresh-icon";1import { RefreshIcon } from "~/components/ui/refresh-icon";
2import { ExternalLinkIcon, GitPullRequestIcon } from "lucide-react";2import { ExternalLinkIcon } from "lucide-react";
33
4import { Button } from "../ui/button";4import { Button } from "../ui/button";
5import {5import {
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10 EmptyMedia,10 EmptyMedia,
11 EmptyTitle,11 EmptyTitle,
12} from "../ui/empty";12} from "../ui/empty";
13import { PullRequestGlyph } from "./pullRequestIcons";
1314
14export function PullRequestsUnavailableState({15export function PullRequestsUnavailableState({
15 title = "Could not load pull requests",16 title = "Could not load pull requests",
...@@ -27,7 +28,7 @@ export function PullRequestsUnavailableState({...@@ -27,7 +28,7 @@ export function PullRequestsUnavailableState({
27 return (28 return (
28 <Empty className="min-h-0 justify-center-safe overflow-y-auto px-4 py-16 md:px-4 [&>*]:shrink-0">29 <Empty className="min-h-0 justify-center-safe overflow-y-auto px-4 py-16 md:px-4 [&>*]:shrink-0">
29 <EmptyMedia variant="icon">30 <EmptyMedia variant="icon">
30 <GitPullRequestIcon />31 <PullRequestGlyph.pullRequest />
31 </EmptyMedia>32 </EmptyMedia>
32 <EmptyHeader>33 <EmptyHeader>
33 <EmptyTitle>{title}</EmptyTitle>34 <EmptyTitle>{title}</EmptyTitle>
apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx+24-20
...@@ -3,13 +3,7 @@ import {...@@ -3,13 +3,7 @@ import {
3 resolveThreadPullRequestChains,3 resolveThreadPullRequestChains,
4 visibleThreadPullRequests,4 visibleThreadPullRequests,
5} from "@t3tools/shared/threadPullRequests";5} from "@t3tools/shared/threadPullRequests";
6import {6import { ArrowUpRightIcon, LinkIcon, MoreHorizontalIcon, PlusIcon } from "lucide-react";
7 GitPullRequestArrow,
8 LayersIcon,
9 LinkIcon,
10 MoreHorizontalIcon,
11 PlusIcon,
12} from "lucide-react";
13import { useCallback, useMemo } from "react";7import { useCallback, useMemo } from "react";
148
15import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";9import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
...@@ -28,11 +22,13 @@ import { openLinkPullRequestDialog } from "./LinkPullRequestDialog";...@@ -28,11 +22,13 @@ import { openLinkPullRequestDialog } from "./LinkPullRequestDialog";
28import { pullRequestListLines, type PullRequestListLine } from "./pullRequestListLines";22import { pullRequestListLines, type PullRequestListLine } from "./pullRequestListLines";
29import {23import {
30 PullRequestActorAvatar,24 PullRequestActorAvatar,
25 PullRequestConflictGlyph,
31 PullRequestDiffStat,26 PullRequestDiffStat,
32 PullRequestApprovalGlyph,27 PullRequestApprovalGlyph,
33 PullRequestStateGlyph,28 PullRequestStateGlyph,
34 pullRequestChecksStatePresentation,29 pullRequestChecksStatePresentation,
35} from "./pullRequestPresentation";30} from "./pullRequestPresentation";
31import { PullRequestGlyph } from "./pullRequestIcons";
3632
37const SOURCE_LABELS: Record<ThreadPullRequestLink["source"], string> = {33const SOURCE_LABELS: Record<ThreadPullRequestLink["source"], string> = {
38 manual: "Linked by you",34 manual: "Linked by you",
...@@ -84,12 +80,20 @@ function LinkRow({...@@ -84,12 +80,20 @@ function LinkRow({
84 >80 >
85 {depth > 0 ? <span aria-hidden className="-ml-2 h-6 w-px shrink-0 bg-border/70" /> : null}81 {depth > 0 ? <span aria-hidden className="-ml-2 h-6 w-px shrink-0 bg-border/70" /> : null}
86 {snapshot === null ? (82 {snapshot === null ? (
87 <GitPullRequestArrow83 <PullRequestGlyph.pullRequest
88 aria-label="Waiting for host state"84 aria-label="Waiting for host state"
89 className="size-4 shrink-0 text-muted-foreground"85 className="size-4 shrink-0 text-muted-foreground"
90 />86 />
91 ) : (87 ) : (
92 <PullRequestStateGlyph state={snapshot.state} isDraft={snapshot.isDraft} />88 <span className="flex shrink-0 items-center gap-1">
89 <PullRequestStateGlyph state={snapshot.state} isDraft={snapshot.isDraft} />
90 <PullRequestConflictGlyph
91 state={snapshot.state}
92 isDraft={snapshot.isDraft}
93 baseBranch={snapshot.baseBranch}
94 {...(snapshot.mergeability ? { mergeability: snapshot.mergeability } : {})}
95 />
96 </span>
93 )}97 )}
94 <a98 <a
95 href={link.url}99 href={link.url}
...@@ -125,9 +129,6 @@ function LinkRow({...@@ -125,9 +129,6 @@ function LinkRow({
125 <span className="text-amber-600/90 dark:text-amber-400/80">Changes requested</span>129 <span className="text-amber-600/90 dark:text-amber-400/80">Changes requested</span>
126 )130 )
127 ) : null}131 ) : null}
128 {snapshot?.state === "open" && snapshot.mergeability === "conflicting" ? (
129 <span className="text-destructive">Conflicts</span>
130 ) : null}
131 {snapshot?.checksState ? <ChecksGlyph state={snapshot.checksState} /> : null}132 {snapshot?.checksState ? <ChecksGlyph state={snapshot.checksState} /> : null}
132 <PullRequestDiffStat133 <PullRequestDiffStat
133 additions={snapshot?.additions ?? 0}134 additions={snapshot?.additions ?? 0}
...@@ -144,11 +145,7 @@ function LinkRow({...@@ -144,11 +145,7 @@ function LinkRow({
144 <span className="inline-flex shrink-0 items-center gap-0.5 text-foreground/70" />145 <span className="inline-flex shrink-0 items-center gap-0.5 text-foreground/70" />
145 }146 }
146 >147 >
147 {stack.kind === "native" ? (148 <PullRequestGlyph.stack aria-hidden className="size-3" />
148 <LayersIcon aria-hidden className="size-3" />
149 ) : (
150 <GitPullRequestArrow aria-hidden className="size-3" />
151 )}
152 {stack.size}149 {stack.size}
153 </TooltipTrigger>150 </TooltipTrigger>
154 <TooltipPopup>151 <TooltipPopup>
...@@ -190,9 +187,16 @@ function LinkRow({...@@ -190,9 +187,16 @@ function LinkRow({
190 }187 }
191 />188 />
192 <MenuPopup align="end" side="bottom">189 <MenuPopup align="end" side="bottom">
193 <MenuItem onClick={() => void writeTextToClipboard(link.url, "link")}>Copy link</MenuItem>190 <MenuItem onClick={() => void writeTextToClipboard(link.url, "link")}>
194 <MenuItem onClick={(event) => openPrLink(event, link.url, threadRef)}>Open</MenuItem>191 <LinkIcon className="size-3.5" />
192 Copy link
193 </MenuItem>
194 <MenuItem onClick={(event) => openPrLink(event, link.url, threadRef)}>
195 <ArrowUpRightIcon className="size-3.5" />
196 Open
197 </MenuItem>
195 <MenuItem onClick={() => onUnlink(link)}>198 <MenuItem onClick={() => onUnlink(link)}>
199 <PullRequestGlyph.unlink className="size-3.5" />
196 {link.source === "stack" ? "Dismiss from thread" : "Unlink from thread"}200 {link.source === "stack" ? "Dismiss from thread" : "Unlink from thread"}
197 </MenuItem>201 </MenuItem>
198 </MenuPopup>202 </MenuPopup>
...@@ -250,7 +254,7 @@ function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread...@@ -250,7 +254,7 @@ function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread
250 if (links.length === 0) {254 if (links.length === 0) {
251 return (255 return (
252 <div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">256 <div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">
253 <LinkIcon aria-hidden className="size-6 text-muted-foreground/60" />257 <PullRequestGlyph.link aria-hidden className="size-6 text-muted-foreground/60" />
254 <p className="text-sm font-medium">No linked pull requests</p>258 <p className="text-sm font-medium">No linked pull requests</p>
255 <p className="max-w-60 text-xs text-muted-foreground">259 <p className="max-w-60 text-xs text-muted-foreground">
256 Pull requests the agent opens from this thread land here. Link one yourself from a URL or260 Pull requests the agent opens from this thread land here. Link one yourself from a URL or
apps/web/src/components/pullRequest/pullRequestIcons.tsx created+54
...@@ -0,0 +1,54 @@
1import {
2 GitMergeIcon,
3 GitPullRequestArrowIcon,
4 GitPullRequestClosedIcon,
5 GitPullRequestDraftIcon,
6 LayersIcon,
7 Link2Icon,
8 Unlink2Icon,
9 TriangleAlertIcon,
10} from "lucide-react";
11import type { PullRequestState } from "@t3tools/contracts";
12
13export const PullRequestGlyph = {
14 pullRequest: GitPullRequestArrowIcon,
15 reopen: GitPullRequestArrowIcon,
16 draft: GitPullRequestDraftIcon,
17 closed: GitPullRequestClosedIcon,
18 merged: GitMergeIcon,
19 conflicting: TriangleAlertIcon,
20 stack: LayersIcon,
21 link: Link2Icon,
22 unlink: Unlink2Icon,
23} as const;
24
25export type PullRequestGlyphIcon = (typeof PullRequestGlyph)[keyof typeof PullRequestGlyph];
26
27export interface PullRequestStatePresentation {
28 readonly label: string;
29 readonly toneClassName: string;
30 readonly Icon: PullRequestGlyphIcon;
31}
32
33export const PULL_REQUEST_STATE_PRESENTATION = {
34 open: {
35 label: "Open",
36 toneClassName: "text-emerald-600 dark:text-emerald-300/90",
37 Icon: PullRequestGlyph.pullRequest,
38 },
39 draft: {
40 label: "Draft",
41 toneClassName: "text-zinc-500 dark:text-zinc-400/80",
42 Icon: PullRequestGlyph.draft,
43 },
44 closed: {
45 label: "Closed",
46 toneClassName: "text-red-600 dark:text-red-300/90",
47 Icon: PullRequestGlyph.closed,
48 },
49 merged: {
50 label: "Merged",
51 toneClassName: "text-violet-600 dark:text-violet-300/90",
52 Icon: PullRequestGlyph.merged,
53 },
54} as const satisfies Record<PullRequestState | "draft", PullRequestStatePresentation>;
apps/web/src/components/pullRequest/pullRequestPresentation.test.ts created+139
...@@ -0,0 +1,139 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { resolvePullRequestConflict, resolvePullRequestState } from "./pullRequestPresentation";
4import { PullRequestGlyph } from "./pullRequestIcons";
5
6describe("resolvePullRequestState", () => {
7 it.each([
8 [
9 "open",
10 { state: "open", isDraft: false },
11 PullRequestGlyph.pullRequest,
12 "Open",
13 "text-emerald-600 dark:text-emerald-300/90",
14 ],
15 [
16 "draft",
17 { state: "open", isDraft: true },
18 PullRequestGlyph.draft,
19 "Draft",
20 "text-zinc-500 dark:text-zinc-400/80",
21 ],
22 [
23 "closed",
24 { state: "closed", isDraft: false },
25 PullRequestGlyph.closed,
26 "Closed",
27 "text-red-600 dark:text-red-300/90",
28 ],
29 [
30 "merged",
31 { state: "merged", isDraft: false },
32 PullRequestGlyph.merged,
33 "Merged",
34 "text-violet-600 dark:text-violet-300/90",
35 ],
36 ] as const)(
37 "resolves the %s lifecycle presentation",
38 (_name, input, Icon, label, toneClassName) => {
39 expect(resolvePullRequestState(input)).toEqual({
40 Icon,
41 label,
42 toneClassName,
43 });
44 },
45 );
46
47 it("keeps a merged pull request merged when stale draft metadata is also present", () => {
48 expect(resolvePullRequestState({ state: "merged", isDraft: true })).toMatchObject({
49 Icon: PullRequestGlyph.merged,
50 label: "Merged",
51 });
52 });
53
54 it("keeps a closed pull request closed when stale draft metadata is also present", () => {
55 expect(resolvePullRequestState({ state: "closed", isDraft: true })).toMatchObject({
56 Icon: PullRequestGlyph.closed,
57 label: "Closed",
58 });
59 });
60
61 it("keeps lifecycle and conflict presentation independent for an open conflicting pull request", () => {
62 const input = {
63 state: "open" as const,
64 isDraft: false,
65 mergeability: "conflicting" as const,
66 baseBranch: "main",
67 };
68
69 expect(resolvePullRequestState(input)).toMatchObject({
70 Icon: PullRequestGlyph.pullRequest,
71 label: "Open",
72 });
73 expect(resolvePullRequestConflict(input)).toMatchObject({
74 Icon: PullRequestGlyph.conflicting,
75 label: "Conflicts with main",
76 toneClassName: "text-destructive",
77 });
78 });
79});
80
81describe("resolvePullRequestConflict", () => {
82 it.each([
83 ["closed", { state: "closed", isDraft: false }],
84 ["merged", { state: "merged", isDraft: false }],
85 ["closed draft", { state: "closed", isDraft: true }],
86 ["merged draft", { state: "merged", isDraft: true }],
87 ["open draft", { state: "open", isDraft: true }],
88 ] as const)("does not report a conflict for %s", (_name, input) => {
89 expect(
90 resolvePullRequestConflict({
91 ...input,
92 mergeability: "conflicting",
93 baseBranch: "main",
94 }),
95 ).toBeNull();
96 });
97
98 it.each([
99 ["omitted", undefined],
100 ["unknown", "unknown"],
101 ["mergeable", "mergeable"],
102 ] as const)("does not report an open conflict when mergeability is %s", (_name, mergeability) => {
103 const input = {
104 state: "open" as const,
105 isDraft: false,
106 ...(mergeability === undefined ? {} : { mergeability }),
107 };
108 expect(resolvePullRequestConflict(input)).toBeNull();
109 });
110
111 it("reports a known conflict with its base branch", () => {
112 expect(
113 resolvePullRequestConflict({
114 state: "open",
115 isDraft: false,
116 mergeability: "conflicting",
117 baseBranch: "main",
118 }),
119 ).toEqual({
120 Icon: PullRequestGlyph.conflicting,
121 label: "Conflicts with main",
122 toneClassName: "text-destructive",
123 });
124 });
125
126 it("reports a known conflict without inventing a base branch", () => {
127 expect(
128 resolvePullRequestConflict({
129 state: "open",
130 isDraft: false,
131 mergeability: "conflicting",
132 }),
133 ).toEqual({
134 Icon: PullRequestGlyph.conflicting,
135 label: "Has conflicts",
136 toneClassName: "text-destructive",
137 });
138 });
139});
apps/web/src/components/pullRequest/pullRequestPresentation.tsx+60-54
...@@ -12,11 +12,6 @@ import {...@@ -12,11 +12,6 @@ import {
12 CircleDashedIcon,12 CircleDashedIcon,
13 CircleDotIcon,13 CircleDotIcon,
14 CircleXIcon,14 CircleXIcon,
15 GitMergeIcon,
16 GitPullRequestClosedIcon,
17 GitPullRequestDraftIcon,
18 GitPullRequestIcon,
19 TriangleAlertIcon,
20 UserCheckIcon,15 UserCheckIcon,
21} from "lucide-react";16} from "lucide-react";
22import { Children, isValidElement, type ReactNode } from "react";17import { Children, isValidElement, type ReactNode } from "react";
...@@ -26,12 +21,12 @@ import { cn } from "~/lib/utils";...@@ -26,12 +21,12 @@ import { cn } from "~/lib/utils";
26import { Badge } from "../ui/badge";21import { Badge } from "../ui/badge";
27import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";22import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
28import type { PullRequestReviewOutcome } from "./pullRequestDetail.logic";23import type { PullRequestReviewOutcome } from "./pullRequestDetail.logic";
2924import {
30interface StatePresentation {25 PULL_REQUEST_STATE_PRESENTATION,
31 readonly label: string;26 PullRequestGlyph,
32 readonly toneClassName: string;27 type PullRequestStatePresentation,
33 readonly Icon: typeof GitPullRequestIcon;28 type PullRequestGlyphIcon,
34}29} from "./pullRequestIcons";
3530
36export function PullRequestApprovalGlyph() {31export function PullRequestApprovalGlyph() {
37 return (32 return (
...@@ -49,57 +44,69 @@ export function PullRequestApprovalGlyph() {...@@ -49,57 +44,69 @@ export function PullRequestApprovalGlyph() {
49}44}
5045
51/**46/**
52 * How a pull request's state reads on this page. Open, closed, merged, and draft use the same47 * How a pull request's state reads anywhere it appears: the thread badge, the right-panel tab,
53 * ink as the thread badge in `ThreadStatusIndicators`, so one pull request cannot look like two48 * the list, and the detail header all resolve through here so one pull request cannot look like
54 * different things in two places.49 * two different things in two places.
55 *50 *
56 * Draft outranks conflicts: a draft is not heading for a merge yet, so conflicts only surface51 * Closed and merged take precedence over a stale draft flag.
57 * once it is real work.
58 */52 */
59export function resolvePullRequestState(input: {53export function resolvePullRequestState(input: {
60 readonly state: PullRequestState;54 readonly state: PullRequestState;
61 readonly isDraft: boolean;55 readonly isDraft: boolean;
56}): PullRequestStatePresentation {
57 const key = input.state === "open" && input.isDraft ? "draft" : input.state;
58 return PULL_REQUEST_STATE_PRESENTATION[key];
59}
60
61export interface PullRequestConflictPresentation {
62 readonly label: string;
63 readonly toneClassName: string;
64 readonly Icon: PullRequestGlyphIcon;
65}
66
67export function resolvePullRequestConflict(input: {
68 readonly state: PullRequestState;
69 readonly isDraft: boolean;
62 readonly mergeability?: PullRequestMergeability;70 readonly mergeability?: PullRequestMergeability;
63 readonly baseBranch?: string;71 readonly baseBranch?: string;
64}): StatePresentation {72}): PullRequestConflictPresentation | null {
65 if (input.state === "merged") {73 if (input.state !== "open" || input.isDraft || input.mergeability !== "conflicting") {
66 return {74 return null;
67 label: "Merged",
68 toneClassName: "text-violet-600 dark:text-violet-300/90",
69 Icon: GitMergeIcon,
70 };
71 }
72 if (input.state === "closed") {
73 return {
74 label: "Closed",
75 toneClassName: "text-red-600 dark:text-red-300/90",
76 Icon: GitPullRequestClosedIcon,
77 };
78 }
79 if (input.isDraft) {
80 return {
81 label: "Draft",
82 toneClassName: "text-zinc-500 dark:text-zinc-400/80",
83 Icon: GitPullRequestDraftIcon,
84 };
85 }
86 if (input.mergeability === "conflicting") {
87 return {
88 // "Has conflicts" leaves out the one thing a reader wants when the warning triangle catches
89 // their eye, so name the branch it collides with wherever the caller knows it.
90 label: input.baseBranch ? `Conflicts with ${input.baseBranch}` : "Has conflicts",
91 toneClassName: "text-destructive",
92 Icon: TriangleAlertIcon,
93 };
94 }75 }
95 return {76 return {
96 label: "Open",77 label: input.baseBranch ? `Conflicts with ${input.baseBranch}` : "Has conflicts",
97 toneClassName: "text-emerald-600 dark:text-emerald-300/90",78 toneClassName: "text-destructive",
98 Icon: GitPullRequestIcon,79 Icon: PullRequestGlyph.conflicting,
99 };80 };
100}81}
10182
102export function PullRequestStateGlyph({83export function PullRequestStateGlyph({
84 state,
85 isDraft,
86 className,
87}: {
88 state: PullRequestState;
89 isDraft: boolean;
90 className?: string;
91}) {
92 const presentation = resolvePullRequestState({ state, isDraft });
93 return (
94 <Tooltip>
95 {/* The list row is itself a button, so the trigger stays a span: an interactive one would
96 nest a control inside that button and steal the row's click target. */}
97 <TooltipTrigger render={<span className="inline-flex shrink-0" />}>
98 <presentation.Icon
99 role="img"
100 aria-label={presentation.label}
101 className={cn("size-4 shrink-0", presentation.toneClassName, className)}
102 />
103 </TooltipTrigger>
104 <TooltipPopup>{presentation.label}</TooltipPopup>
105 </Tooltip>
106 );
107}
108
109export function PullRequestConflictGlyph({
103 state,110 state,
104 isDraft,111 isDraft,
105 mergeability,112 mergeability,
...@@ -112,16 +119,15 @@ export function PullRequestStateGlyph({...@@ -112,16 +119,15 @@ export function PullRequestStateGlyph({
112 baseBranch?: string;119 baseBranch?: string;
113 className?: string;120 className?: string;
114}) {121}) {
115 const presentation = resolvePullRequestState({122 const presentation = resolvePullRequestConflict({
116 state,123 state,
117 isDraft,124 isDraft,
118 ...(mergeability ? { mergeability } : {}),125 ...(mergeability === undefined ? {} : { mergeability }),
119 ...(baseBranch ? { baseBranch } : {}),126 ...(baseBranch === undefined ? {} : { baseBranch }),
120 });127 });
128 if (presentation === null) return null;
121 return (129 return (
122 <Tooltip>130 <Tooltip>
123 {/* The list row is itself a button, so the trigger stays a span: an interactive one would
124 nest a control inside that button and steal the row's click target. */}
125 <TooltipTrigger render={<span className="inline-flex shrink-0" />}>131 <TooltipTrigger render={<span className="inline-flex shrink-0" />}>
126 <presentation.Icon132 <presentation.Icon
127 role="img"133 role="img"
apps/web/src/components/settings/SourceControlSettings.tsx+3-2
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { RefreshIcon } from "~/components/ui/refresh-icon";1import { RefreshIcon } from "~/components/ui/refresh-icon";
2import { ChevronDownIcon, GitPullRequestIcon } from "lucide-react";2import { ChevronDownIcon } from "lucide-react";
3import * as Duration from "effect/Duration";3import * as Duration from "effect/Duration";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
5import { useEffect, useState, type ReactNode } from "react";5import { useEffect, useState, type ReactNode } from "react";
...@@ -67,6 +67,7 @@ import {...@@ -67,6 +67,7 @@ import {
67 useSettingsSearchTargetId,67 useSettingsSearchTargetId,
68} from "./settingsLayout";68} from "./settingsLayout";
69import { searchableSetting } from "./settingsSearch";69import { searchableSetting } from "./settingsSearch";
70import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
7071
71const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {72const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {
72 versionControlSystems: [],73 versionControlSystems: [],
...@@ -476,7 +477,7 @@ function EmptySourceControlDiscovery({...@@ -476,7 +477,7 @@ function EmptySourceControlDiscovery({
476 <SettingsSection id={searchableSetting("source-control").id} title="Server environment">477 <SettingsSection id={searchableSetting("source-control").id} title="Server environment">
477 <Empty className="min-h-88">478 <Empty className="min-h-88">
478 <EmptyMedia variant="icon">479 <EmptyMedia variant="icon">
479 <GitPullRequestIcon />480 <PullRequestGlyph.pullRequest />
480 </EmptyMedia>481 </EmptyMedia>
481 <EmptyHeader>482 <EmptyHeader>
482 <EmptyTitle>483 <EmptyTitle>
apps/web/src/components/sidebar/SidebarChrome.tsx+3-7
...@@ -1,9 +1,4 @@...@@ -1,9 +1,4 @@
1import {1import { ArrowLeftIcon, ChartNoAxesColumnIcon, SettingsIcon } from "lucide-react";
2 ArrowLeftIcon,
3 ChartNoAxesColumnIcon,
4 GitPullRequestIcon,
5 SettingsIcon,
6} from "lucide-react";
7import type { ReactNode } from "react";2import type { ReactNode } from "react";
8import { memo, useCallback } from "react";3import { memo, useCallback } from "react";
9import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router";4import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router";
...@@ -33,6 +28,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";...@@ -33,6 +28,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
33import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences";28import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences";
34import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill";29import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill";
35import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill";30import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill";
31import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
3632
37export const SidebarChromeHeader = memo(function SidebarChromeHeader({33export const SidebarChromeHeader = memo(function SidebarChromeHeader({
38 isElectron,34 isElectron,
...@@ -205,7 +201,7 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {...@@ -205,7 +201,7 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
205 />201 />
206 {pullRequestsSupported ? (202 {pullRequestsSupported ? (
207 <SidebarUtilityItem203 <SidebarUtilityItem
208 icon={<GitPullRequestIcon />}204 icon={<PullRequestGlyph.pullRequest />}
209 label="Pull Requests"205 label="Pull Requests"
210 onClick={handlePullRequestsClick}206 onClick={handlePullRequestsClick}
211 />207 />
apps/web/src/routes/_chat.pull-requests.tsx+4-6
...@@ -21,9 +21,6 @@ import {...@@ -21,9 +21,6 @@ import {
21 ChevronDownIcon,21 ChevronDownIcon,
22 ClockIcon,22 ClockIcon,
23 EyeIcon,23 EyeIcon,
24 GitMergeIcon,
25 GitPullRequestClosedIcon,
26 GitPullRequestIcon,
27 LayersIcon,24 LayersIcon,
28 ListChecksIcon,25 ListChecksIcon,
29 PenLineIcon,26 PenLineIcon,
...@@ -150,6 +147,7 @@ import { useAtomCommand } from "../state/use-atom-command";...@@ -150,6 +147,7 @@ import { useAtomCommand } from "../state/use-atom-command";
150import { cn } from "~/lib/utils";147import { cn } from "~/lib/utils";
151import { primaryServerKeybindingsAtom } from "~/state/server";148import { primaryServerKeybindingsAtom } from "~/state/server";
152import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";149import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
150import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
153151
154function getShortcutContext() {152function getShortcutContext() {
155 return {153 return {
...@@ -196,9 +194,9 @@ const INVOLVEMENT_TABS = [...@@ -196,9 +194,9 @@ const INVOLVEMENT_TABS = [
196194
197const STATE_TABS = [195const STATE_TABS = [
198 { value: "all", label: "All", Icon: LayersIcon },196 { value: "all", label: "All", Icon: LayersIcon },
199 { value: "open", label: "Open", Icon: GitPullRequestIcon },197 { value: "open", label: "Open", Icon: PullRequestGlyph.pullRequest },
200 { value: "closed", label: "Closed", Icon: GitPullRequestClosedIcon },198 { value: "closed", label: "Closed", Icon: PullRequestGlyph.closed },
201 { value: "merged", label: "Merged", Icon: GitMergeIcon },199 { value: "merged", label: "Merged", Icon: PullRequestGlyph.merged },
202] as const satisfies ReadonlyArray<PullRequestFilterOption<PullRequestListState>>;200] as const satisfies ReadonlyArray<PullRequestFilterOption<PullRequestListState>>;
203201
204const SORT_OPTIONS = [202const SORT_OPTIONS = [
apps/web/src/sourceControlPresentation.ts+3-3
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1import { GitPullRequestIcon } from "lucide-react";
2import type { ElementType } from "react";1import type { ElementType } from "react";
3import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts";2import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts";
4export {3export {
...@@ -16,10 +15,11 @@ import {...@@ -16,10 +15,11 @@ import {
16import {15import {
17 AzureDevOpsIcon,16 AzureDevOpsIcon,
18 BitbucketIcon,17 BitbucketIcon,
18 ForgejoIcon,
19 GitHubIcon,19 GitHubIcon,
20 GitLabIcon,20 GitLabIcon,
21 ForgejoIcon,
22} from "./components/Icons";21} from "./components/Icons";
22import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
2323
24export interface SourceControlPresentation {24export interface SourceControlPresentation {
25 readonly providerName: string;25 readonly providerName: string;
...@@ -66,7 +66,7 @@ export function getSourceControlPresentation(...@@ -66,7 +66,7 @@ export function getSourceControlPresentation(
66 return {66 return {
67 providerName: provider?.name || presentation.providerName,67 providerName: provider?.name || presentation.providerName,
68 terminology: getChangeRequestTerminology(provider),68 terminology: getChangeRequestTerminology(provider),
69 Icon: GitPullRequestIcon,69 Icon: PullRequestGlyph.pullRequest,
70 };70 };
71 }71 }
72}72}
vite.config.ts+44-15
...@@ -2,6 +2,43 @@ import "vite-plus/test/config";...@@ -2,6 +2,43 @@ import "vite-plus/test/config";
2import { defineConfig } from "vite-plus";2import { defineConfig } from "vite-plus";
3import * as NodeURL from "node:url";3import * as NodeURL from "node:url";
44
5/** Import restrictions every file keeps, including the one module exempt from the glyph rule. */
6const RESTRICTED_IMPORT_PATHS = [
7 {
8 name: "@t3tools/client-runtime",
9 message:
10 "Import from an explicit @t3tools/client-runtime/* subpath. The package has no root export.",
11 },
12 {
13 name: "@pierre/diffs/react",
14 importNames: ["CodeView"],
15 message: "Use StyledDiffCodeView so web diff surfaces share styling and virtualized geometry.",
16 },
17];
18
19/** Lucide's pull-request glyphs, which only `pullRequestIcons.tsx` may name. */
20const RESTRICTED_PULL_REQUEST_GLYPH_IMPORTS = {
21 name: "lucide-react",
22 importNames: [
23 "GitMerge",
24 "GitMergeIcon",
25 "GitPullRequest",
26 "GitPullRequestIcon",
27 "GitPullRequestArrow",
28 "GitPullRequestArrowIcon",
29 "GitPullRequestClosed",
30 "GitPullRequestClosedIcon",
31 "GitPullRequestDraft",
32 "GitPullRequestDraftIcon",
33 "GitPullRequestCreate",
34 "GitPullRequestCreateIcon",
35 "GitPullRequestCreateArrow",
36 "GitPullRequestCreateArrowIcon",
37 ],
38 message:
39 "Pick a glyph by meaning from PullRequestGlyph in apps/web/src/components/pullRequest/pullRequestIcons.tsx so every surface draws the same pull request the same way.",
40};
41
5export default defineConfig({42export default defineConfig({
6 resolve: {43 resolve: {
7 alias: {44 alias: {
...@@ -103,21 +140,7 @@ export default defineConfig({...@@ -103,21 +140,7 @@ export default defineConfig({
103 "typescript/unbound-method": "off",140 "typescript/unbound-method": "off",
104 "eslint/no-restricted-imports": [141 "eslint/no-restricted-imports": [
105 "error",142 "error",
106 {143 { paths: [...RESTRICTED_IMPORT_PATHS, RESTRICTED_PULL_REQUEST_GLYPH_IMPORTS] },
107 paths: [
108 {
109 name: "@t3tools/client-runtime",
110 message:
111 "Import from an explicit @t3tools/client-runtime/* subpath. The package has no root export.",
112 },
113 {
114 name: "@pierre/diffs/react",
115 importNames: ["CodeView"],
116 message:
117 "Use StyledDiffCodeView so web diff surfaces share styling and virtualized geometry.",
118 },
119 ],
120 },
121 ],144 ],
122 "t3code/no-global-process-runtime": "error",145 "t3code/no-global-process-runtime": "error",
123 "t3code/no-inline-schema-compile": "warn",146 "t3code/no-inline-schema-compile": "warn",
...@@ -131,6 +154,12 @@ export default defineConfig({...@@ -131,6 +154,12 @@ export default defineConfig({
131 files: ["packages/shared/src/hostProcess.ts"],154 files: ["packages/shared/src/hostProcess.ts"],
132 rules: { "t3code/no-global-process-runtime": "off" },155 rules: { "t3code/no-global-process-runtime": "off" },
133 },156 },
157 {
158 // The one module allowed to name lucide's pull-request glyphs; everything else picks
159 // from its vocabulary. The other import restrictions still apply here.
160 files: ["apps/web/src/components/pullRequest/pullRequestIcons.tsx"],
161 rules: { "eslint/no-restricted-imports": ["error", { paths: RESTRICTED_IMPORT_PATHS }] },
162 },
134 {163 {
135 files: ["apps/mobile/src/**"],164 files: ["apps/mobile/src/**"],
136 rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" },165 rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" },