-
+
No linked pull requests
Pull requests the agent opens from this thread land here. Link one yourself from a URL or
diff --git a/apps/web/src/components/pullRequest/pullRequestIcons.tsx b/apps/web/src/components/pullRequest/pullRequestIcons.tsx
new file mode 100644
index 000000000000..2f1a60a8c1eb
--- /dev/null
+++ b/apps/web/src/components/pullRequest/pullRequestIcons.tsx
@@ -0,0 +1,54 @@
+import {
+ GitMergeIcon,
+ GitPullRequestArrowIcon,
+ GitPullRequestClosedIcon,
+ GitPullRequestDraftIcon,
+ LayersIcon,
+ Link2Icon,
+ Unlink2Icon,
+ TriangleAlertIcon,
+} from "lucide-react";
+import type { PullRequestState } from "@t3tools/contracts";
+
+export const PullRequestGlyph = {
+ pullRequest: GitPullRequestArrowIcon,
+ reopen: GitPullRequestArrowIcon,
+ draft: GitPullRequestDraftIcon,
+ closed: GitPullRequestClosedIcon,
+ merged: GitMergeIcon,
+ conflicting: TriangleAlertIcon,
+ stack: LayersIcon,
+ link: Link2Icon,
+ unlink: Unlink2Icon,
+} as const;
+
+export type PullRequestGlyphIcon = (typeof PullRequestGlyph)[keyof typeof PullRequestGlyph];
+
+export interface PullRequestStatePresentation {
+ readonly label: string;
+ readonly toneClassName: string;
+ readonly Icon: PullRequestGlyphIcon;
+}
+
+export const PULL_REQUEST_STATE_PRESENTATION = {
+ open: {
+ label: "Open",
+ toneClassName: "text-emerald-600 dark:text-emerald-300/90",
+ Icon: PullRequestGlyph.pullRequest,
+ },
+ draft: {
+ label: "Draft",
+ toneClassName: "text-zinc-500 dark:text-zinc-400/80",
+ Icon: PullRequestGlyph.draft,
+ },
+ closed: {
+ label: "Closed",
+ toneClassName: "text-red-600 dark:text-red-300/90",
+ Icon: PullRequestGlyph.closed,
+ },
+ merged: {
+ label: "Merged",
+ toneClassName: "text-violet-600 dark:text-violet-300/90",
+ Icon: PullRequestGlyph.merged,
+ },
+} as const satisfies Record;
diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.test.ts b/apps/web/src/components/pullRequest/pullRequestPresentation.test.ts
new file mode 100644
index 000000000000..be2efa94c207
--- /dev/null
+++ b/apps/web/src/components/pullRequest/pullRequestPresentation.test.ts
@@ -0,0 +1,139 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { resolvePullRequestConflict, resolvePullRequestState } from "./pullRequestPresentation";
+import { PullRequestGlyph } from "./pullRequestIcons";
+
+describe("resolvePullRequestState", () => {
+ it.each([
+ [
+ "open",
+ { state: "open", isDraft: false },
+ PullRequestGlyph.pullRequest,
+ "Open",
+ "text-emerald-600 dark:text-emerald-300/90",
+ ],
+ [
+ "draft",
+ { state: "open", isDraft: true },
+ PullRequestGlyph.draft,
+ "Draft",
+ "text-zinc-500 dark:text-zinc-400/80",
+ ],
+ [
+ "closed",
+ { state: "closed", isDraft: false },
+ PullRequestGlyph.closed,
+ "Closed",
+ "text-red-600 dark:text-red-300/90",
+ ],
+ [
+ "merged",
+ { state: "merged", isDraft: false },
+ PullRequestGlyph.merged,
+ "Merged",
+ "text-violet-600 dark:text-violet-300/90",
+ ],
+ ] as const)(
+ "resolves the %s lifecycle presentation",
+ (_name, input, Icon, label, toneClassName) => {
+ expect(resolvePullRequestState(input)).toEqual({
+ Icon,
+ label,
+ toneClassName,
+ });
+ },
+ );
+
+ it("keeps a merged pull request merged when stale draft metadata is also present", () => {
+ expect(resolvePullRequestState({ state: "merged", isDraft: true })).toMatchObject({
+ Icon: PullRequestGlyph.merged,
+ label: "Merged",
+ });
+ });
+
+ it("keeps a closed pull request closed when stale draft metadata is also present", () => {
+ expect(resolvePullRequestState({ state: "closed", isDraft: true })).toMatchObject({
+ Icon: PullRequestGlyph.closed,
+ label: "Closed",
+ });
+ });
+
+ it("keeps lifecycle and conflict presentation independent for an open conflicting pull request", () => {
+ const input = {
+ state: "open" as const,
+ isDraft: false,
+ mergeability: "conflicting" as const,
+ baseBranch: "main",
+ };
+
+ expect(resolvePullRequestState(input)).toMatchObject({
+ Icon: PullRequestGlyph.pullRequest,
+ label: "Open",
+ });
+ expect(resolvePullRequestConflict(input)).toMatchObject({
+ Icon: PullRequestGlyph.conflicting,
+ label: "Conflicts with main",
+ toneClassName: "text-destructive",
+ });
+ });
+});
+
+describe("resolvePullRequestConflict", () => {
+ it.each([
+ ["closed", { state: "closed", isDraft: false }],
+ ["merged", { state: "merged", isDraft: false }],
+ ["closed draft", { state: "closed", isDraft: true }],
+ ["merged draft", { state: "merged", isDraft: true }],
+ ["open draft", { state: "open", isDraft: true }],
+ ] as const)("does not report a conflict for %s", (_name, input) => {
+ expect(
+ resolvePullRequestConflict({
+ ...input,
+ mergeability: "conflicting",
+ baseBranch: "main",
+ }),
+ ).toBeNull();
+ });
+
+ it.each([
+ ["omitted", undefined],
+ ["unknown", "unknown"],
+ ["mergeable", "mergeable"],
+ ] as const)("does not report an open conflict when mergeability is %s", (_name, mergeability) => {
+ const input = {
+ state: "open" as const,
+ isDraft: false,
+ ...(mergeability === undefined ? {} : { mergeability }),
+ };
+ expect(resolvePullRequestConflict(input)).toBeNull();
+ });
+
+ it("reports a known conflict with its base branch", () => {
+ expect(
+ resolvePullRequestConflict({
+ state: "open",
+ isDraft: false,
+ mergeability: "conflicting",
+ baseBranch: "main",
+ }),
+ ).toEqual({
+ Icon: PullRequestGlyph.conflicting,
+ label: "Conflicts with main",
+ toneClassName: "text-destructive",
+ });
+ });
+
+ it("reports a known conflict without inventing a base branch", () => {
+ expect(
+ resolvePullRequestConflict({
+ state: "open",
+ isDraft: false,
+ mergeability: "conflicting",
+ }),
+ ).toEqual({
+ Icon: PullRequestGlyph.conflicting,
+ label: "Has conflicts",
+ toneClassName: "text-destructive",
+ });
+ });
+});
diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx
index 890efb67cb9b..4792cca95ea7 100644
--- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx
+++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx
@@ -12,11 +12,6 @@ import {
CircleDashedIcon,
CircleDotIcon,
CircleXIcon,
- GitMergeIcon,
- GitPullRequestClosedIcon,
- GitPullRequestDraftIcon,
- GitPullRequestIcon,
- TriangleAlertIcon,
UserCheckIcon,
} from "lucide-react";
import { Children, isValidElement, type ReactNode } from "react";
@@ -26,12 +21,12 @@ import { cn } from "~/lib/utils";
import { Badge } from "../ui/badge";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import type { PullRequestReviewOutcome } from "./pullRequestDetail.logic";
-
-interface StatePresentation {
- readonly label: string;
- readonly toneClassName: string;
- readonly Icon: typeof GitPullRequestIcon;
-}
+import {
+ PULL_REQUEST_STATE_PRESENTATION,
+ PullRequestGlyph,
+ type PullRequestStatePresentation,
+ type PullRequestGlyphIcon,
+} from "./pullRequestIcons";
export function PullRequestApprovalGlyph() {
return (
@@ -49,57 +44,69 @@ export function PullRequestApprovalGlyph() {
}
/**
- * How a pull request's state reads on this page. Open, closed, merged, and draft use the same
- * ink as the thread badge in `ThreadStatusIndicators`, so one pull request cannot look like two
- * different things in two places.
+ * How a pull request's state reads anywhere it appears: the thread badge, the right-panel tab,
+ * the list, and the detail header all resolve through here so one pull request cannot look like
+ * two different things in two places.
*
- * Draft outranks conflicts: a draft is not heading for a merge yet, so conflicts only surface
- * once it is real work.
+ * Closed and merged take precedence over a stale draft flag.
*/
export function resolvePullRequestState(input: {
readonly state: PullRequestState;
readonly isDraft: boolean;
+}): PullRequestStatePresentation {
+ const key = input.state === "open" && input.isDraft ? "draft" : input.state;
+ return PULL_REQUEST_STATE_PRESENTATION[key];
+}
+
+export interface PullRequestConflictPresentation {
+ readonly label: string;
+ readonly toneClassName: string;
+ readonly Icon: PullRequestGlyphIcon;
+}
+
+export function resolvePullRequestConflict(input: {
+ readonly state: PullRequestState;
+ readonly isDraft: boolean;
readonly mergeability?: PullRequestMergeability;
readonly baseBranch?: string;
-}): StatePresentation {
- if (input.state === "merged") {
- return {
- label: "Merged",
- toneClassName: "text-violet-600 dark:text-violet-300/90",
- Icon: GitMergeIcon,
- };
- }
- if (input.state === "closed") {
- return {
- label: "Closed",
- toneClassName: "text-red-600 dark:text-red-300/90",
- Icon: GitPullRequestClosedIcon,
- };
- }
- if (input.isDraft) {
- return {
- label: "Draft",
- toneClassName: "text-zinc-500 dark:text-zinc-400/80",
- Icon: GitPullRequestDraftIcon,
- };
- }
- if (input.mergeability === "conflicting") {
- return {
- // "Has conflicts" leaves out the one thing a reader wants when the warning triangle catches
- // their eye, so name the branch it collides with wherever the caller knows it.
- label: input.baseBranch ? `Conflicts with ${input.baseBranch}` : "Has conflicts",
- toneClassName: "text-destructive",
- Icon: TriangleAlertIcon,
- };
+}): PullRequestConflictPresentation | null {
+ if (input.state !== "open" || input.isDraft || input.mergeability !== "conflicting") {
+ return null;
}
return {
- label: "Open",
- toneClassName: "text-emerald-600 dark:text-emerald-300/90",
- Icon: GitPullRequestIcon,
+ label: input.baseBranch ? `Conflicts with ${input.baseBranch}` : "Has conflicts",
+ toneClassName: "text-destructive",
+ Icon: PullRequestGlyph.conflicting,
};
}
export function PullRequestStateGlyph({
+ state,
+ isDraft,
+ className,
+}: {
+ state: PullRequestState;
+ isDraft: boolean;
+ className?: string;
+}) {
+ const presentation = resolvePullRequestState({ state, isDraft });
+ return (
+
+ {/* The list row is itself a button, so the trigger stays a span: an interactive one would
+ nest a control inside that button and steal the row's click target. */}
+ }>
+
+
+ {presentation.label}
+
+ );
+}
+
+export function PullRequestConflictGlyph({
state,
isDraft,
mergeability,
@@ -112,16 +119,15 @@ export function PullRequestStateGlyph({
baseBranch?: string;
className?: string;
}) {
- const presentation = resolvePullRequestState({
+ const presentation = resolvePullRequestConflict({
state,
isDraft,
- ...(mergeability ? { mergeability } : {}),
- ...(baseBranch ? { baseBranch } : {}),
+ ...(mergeability === undefined ? {} : { mergeability }),
+ ...(baseBranch === undefined ? {} : { baseBranch }),
});
+ if (presentation === null) return null;
return (
- {/* The list row is itself a button, so the trigger stays a span: an interactive one would
- nest a control inside that button and steal the row's click target. */}
}>
-
+
diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx
index afbbf7671dfc..97c6a43d3ada 100644
--- a/apps/web/src/components/sidebar/SidebarChrome.tsx
+++ b/apps/web/src/components/sidebar/SidebarChrome.tsx
@@ -1,9 +1,4 @@
-import {
- ArrowLeftIcon,
- ChartNoAxesColumnIcon,
- GitPullRequestIcon,
- SettingsIcon,
-} from "lucide-react";
+import { ArrowLeftIcon, ChartNoAxesColumnIcon, SettingsIcon } from "lucide-react";
import type { ReactNode } from "react";
import { memo, useCallback } from "react";
import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router";
@@ -33,6 +28,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences";
import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill";
import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill";
+import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
export const SidebarChromeHeader = memo(function SidebarChromeHeader({
isElectron,
@@ -204,7 +200,7 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
/>
{pullRequestsSupported ? (
}
+ icon={}
label="Pull Requests"
onClick={handlePullRequestsClick}
/>
diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx
index 973c406f6900..1dca2dcd9317 100644
--- a/apps/web/src/routes/_chat.pull-requests.tsx
+++ b/apps/web/src/routes/_chat.pull-requests.tsx
@@ -21,9 +21,6 @@ import {
ChevronDownIcon,
ClockIcon,
EyeIcon,
- GitMergeIcon,
- GitPullRequestClosedIcon,
- GitPullRequestIcon,
LayersIcon,
ListChecksIcon,
PenLineIcon,
@@ -150,6 +147,7 @@ import { useAtomCommand } from "../state/use-atom-command";
import { cn } from "~/lib/utils";
import { primaryServerKeybindingsAtom } from "~/state/server";
import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
+import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
export interface PullRequestsSearch extends PullRequestListPreferences {
/**
@@ -186,9 +184,9 @@ const INVOLVEMENT_TABS = [
const STATE_TABS = [
{ value: "all", label: "All", Icon: LayersIcon },
- { value: "open", label: "Open", Icon: GitPullRequestIcon },
- { value: "closed", label: "Closed", Icon: GitPullRequestClosedIcon },
- { value: "merged", label: "Merged", Icon: GitMergeIcon },
+ { value: "open", label: "Open", Icon: PullRequestGlyph.pullRequest },
+ { value: "closed", label: "Closed", Icon: PullRequestGlyph.closed },
+ { value: "merged", label: "Merged", Icon: PullRequestGlyph.merged },
] as const satisfies ReadonlyArray>;
const SORT_OPTIONS = [
diff --git a/apps/web/src/sourceControlPresentation.ts b/apps/web/src/sourceControlPresentation.ts
index 0f627bca5a17..7a8d2cab8fe5 100644
--- a/apps/web/src/sourceControlPresentation.ts
+++ b/apps/web/src/sourceControlPresentation.ts
@@ -1,4 +1,3 @@
-import { GitPullRequestIcon } from "lucide-react";
import type { ElementType } from "react";
import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts";
export {
@@ -16,10 +15,11 @@ import {
import {
AzureDevOpsIcon,
BitbucketIcon,
+ ForgejoIcon,
GitHubIcon,
GitLabIcon,
- ForgejoIcon,
} from "./components/Icons";
+import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
export interface SourceControlPresentation {
readonly providerName: string;
@@ -66,7 +66,7 @@ export function getSourceControlPresentation(
return {
providerName: provider?.name || presentation.providerName,
terminology: getChangeRequestTerminology(provider),
- Icon: GitPullRequestIcon,
+ Icon: PullRequestGlyph.pullRequest,
};
}
}
diff --git a/vite.config.ts b/vite.config.ts
index b9f2c9cc2c4b..46a54377aad4 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -2,6 +2,43 @@ import "vite-plus/test/config";
import { defineConfig } from "vite-plus";
import * as NodeURL from "node:url";
+/** Import restrictions every file keeps, including the one module exempt from the glyph rule. */
+const RESTRICTED_IMPORT_PATHS = [
+ {
+ name: "@t3tools/client-runtime",
+ message:
+ "Import from an explicit @t3tools/client-runtime/* subpath. The package has no root export.",
+ },
+ {
+ name: "@pierre/diffs/react",
+ importNames: ["CodeView"],
+ message: "Use StyledDiffCodeView so web diff surfaces share styling and virtualized geometry.",
+ },
+];
+
+/** Lucide's pull-request glyphs, which only `pullRequestIcons.tsx` may name. */
+const RESTRICTED_PULL_REQUEST_GLYPH_IMPORTS = {
+ name: "lucide-react",
+ importNames: [
+ "GitMerge",
+ "GitMergeIcon",
+ "GitPullRequest",
+ "GitPullRequestIcon",
+ "GitPullRequestArrow",
+ "GitPullRequestArrowIcon",
+ "GitPullRequestClosed",
+ "GitPullRequestClosedIcon",
+ "GitPullRequestDraft",
+ "GitPullRequestDraftIcon",
+ "GitPullRequestCreate",
+ "GitPullRequestCreateIcon",
+ "GitPullRequestCreateArrow",
+ "GitPullRequestCreateArrowIcon",
+ ],
+ message:
+ "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.",
+};
+
export default defineConfig({
resolve: {
alias: {
@@ -103,21 +140,7 @@ export default defineConfig({
"typescript/unbound-method": "off",
"eslint/no-restricted-imports": [
"error",
- {
- paths: [
- {
- name: "@t3tools/client-runtime",
- message:
- "Import from an explicit @t3tools/client-runtime/* subpath. The package has no root export.",
- },
- {
- name: "@pierre/diffs/react",
- importNames: ["CodeView"],
- message:
- "Use StyledDiffCodeView so web diff surfaces share styling and virtualized geometry.",
- },
- ],
- },
+ { paths: [...RESTRICTED_IMPORT_PATHS, RESTRICTED_PULL_REQUEST_GLYPH_IMPORTS] },
],
"t3code/no-global-process-runtime": "error",
"t3code/no-inline-schema-compile": "warn",
@@ -131,6 +154,12 @@ export default defineConfig({
files: ["packages/shared/src/hostProcess.ts"],
rules: { "t3code/no-global-process-runtime": "off" },
},
+ {
+ // The one module allowed to name lucide's pull-request glyphs; everything else picks
+ // from its vocabulary. The other import restrictions still apply here.
+ files: ["apps/web/src/components/pullRequest/pullRequestIcons.tsx"],
+ rules: { "eslint/no-restricted-imports": ["error", { paths: RESTRICTED_IMPORT_PATHS }] },
+ },
{
files: ["apps/mobile/src/**"],
rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" },