authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-11 00:58:57-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-11 00:58:57-03:00
log02297e3dbd896ef619d5c916938c751e343e76a8
tree716986153a4f5b32c171b2b43a9abc0ba38337b6
parent0a37240a87bb2fed8f48cf20dc8312ee3b94eba6
signature Signed by PGP key B5690EEEBB952194

fix(ui): color linked pr counts by aggregate status (#11180)


7 files changed, 123 insertions(+), 28 deletions(-)

apps/mobile/src/features/threads/thread-list-items.tsx+7-3
......@@ -44,11 +44,11 @@ export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET;
4444const SIDEBAR_ROW_RADIUS = 12;
4545
4646function pullRequestTintColor(
47 pr: Pick<ThreadPrPresentation, "state" | "isDraft" | "others">,
47 pr: Pick<ThreadPrPresentation, "state" | "isDraft" | "others" | "kind">,
4848 colorScheme: "light" | "dark",
4949) {
5050 const dark = colorScheme === "dark";
51 if (pr.others > 0 || (pr.state === "open" && pr.isDraft === true)) {
51 if (pr.state === "open" && pr.isDraft === true) {
5252 return dark ? "#a1a1aa" : "#71717a";
5353 }
5454 switch (pr.state) {
......@@ -56,8 +56,12 @@ function pullRequestTintColor(
5656 return dark ? "#34d399" : "#059669";
5757 case "merged":
5858 return dark ? "#a78bfa" : "#7c3aed";
59 case null:
6059 case "closed":
60 if (pr.kind === "stack" || pr.others > 0) {
61 return dark ? "#fb7185" : "#e11d48";
62 }
63 return dark ? "#a1a1aa" : "#71717a";
64 case null:
6165 return dark ? "#a1a1aa" : "#71717a";
6266 }
6367}
apps/mobile/src/features/threads/thread-list-v2-items.tsx+7-1
......@@ -880,7 +880,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
880880 ? materialYouStyleLayoutActive
881881 ? "accent-thread-selected-foreground"
882882 : "accent-user-bubble-foreground"
883 : "accent-foreground-muted"
883 : pr.state === null || pr.isDraft
884 ? "accent-foreground-muted"
885 : pr.state === "open"
886 ? "accent-adaptive-emerald-600-400"
887 : pr.state === "closed"
888 ? "accent-adaptive-rose-600-400"
889 : "accent-adaptive-violet-600-400"
884890 }
885891 />
886892 ) : null}
apps/mobile/src/state/thread-pr-presentation.ts+17-6
......@@ -63,9 +63,16 @@ export function presentThreadLinkedPullRequests(
6363 const badge = resolveThreadPullRequestBadge(links);
6464 if (link === null || badge === null) return null;
6565 const snapshot = link.snapshot;
66 const state = badge.kind === "stack" ? badge.state : (snapshot?.state ?? null);
67 const isDraft = snapshot?.isDraft === true && state === "open";
6866 const linkedCount = badge.kind === "pull-request" && badge.others > 0 ? badge.others + 1 : null;
67 const isMultiple = badge.kind === "stack" || linkedCount !== null;
68 const state = isMultiple
69 ? badge.state === "draft"
70 ? "open"
71 : badge.state
72 : (snapshot?.state ?? null);
73 const isDraft = isMultiple
74 ? badge.state === "draft"
75 : snapshot?.isDraft === true && state === "open";
6976 const label =
7077 badge.kind === "stack"
7178 ? String(badge.layers)
......@@ -83,12 +90,16 @@ export function presentThreadLinkedPullRequests(
8390 label,
8491 accessibilityLabel:
8592 badge.kind === "stack"
86 ? `${badge.layers} pull requests in stack, ${state ?? "status pending"}`
87 : `#${link.number} pull request ${state === null ? "status pending" : isDraft ? "draft" : state}${badge.others > 0 ? `, ${badge.others} more linked` : ""}`,
93 ? `${badge.layers} pull requests in stack, ${isDraft ? "draft" : (state ?? "status pending")}`
94 : linkedCount !== null
95 ? `${linkedCount} linked pull requests, overall ${badge.state}`
96 : `#${link.number} pull request ${state === null ? "status pending" : isDraft ? "draft" : state}`,
8897 textClassName:
89 linkedCount !== null || state === null || isDraft
98 state === null || isDraft
9099 ? "text-foreground-muted"
91 : PR_STATE_TEXT_CLASS[state],
100 : isMultiple && state === "closed"
101 ? "text-adaptive-rose-600-400"
102 : PR_STATE_TEXT_CLASS[state],
92103 };
93104}
94105
apps/mobile/src/state/use-thread-pr.test.ts+33-1
......@@ -89,10 +89,42 @@ describe("presentThreadLinkedPullRequests", () => {
8989 kind: "pull-request",
9090 label: "+2",
9191 others: 1,
92 textClassName: "text-foreground-muted",
92 state: "open",
93 isDraft: false,
94 textClassName: "text-adaptive-emerald-600-400",
9395 });
9496 });
9597
98 it.each([
99 ["closed", false, "closed", false, "closed", false, "text-adaptive-rose-600-400"],
100 ["open", true, "open", true, "open", true, "text-foreground-muted"],
101 ["open", true, "open", false, "open", false, "text-adaptive-emerald-600-400"],
102 ["closed", false, "open", false, "open", false, "text-adaptive-emerald-600-400"],
103 ["merged", false, "merged", false, "merged", false, "text-adaptive-violet-600-400"],
104 ["closed", false, "merged", false, "closed", false, "text-adaptive-rose-600-400"],
105 ] as const)(
106 "colors linked %s (draft %s) and %s (draft %s) by their aggregate state",
107 (firstState, firstDraft, secondState, secondDraft, state, isDraft, textClassName) => {
108 const first = linkedPr(1);
109 const second = linkedPr(2);
110 expect(
111 presentThreadLinkedPullRequests([
112 { ...first, snapshot: { ...first.snapshot!, state: firstState, isDraft: firstDraft } },
113 {
114 ...second,
115 snapshot: { ...second.snapshot!, state: secondState, isDraft: secondDraft },
116 },
117 ]),
118 ).toMatchObject({
119 label: "+2",
120 state,
121 isDraft,
122 textClassName,
123 accessibilityLabel: `2 linked pull requests, overall ${isDraft ? "draft" : state}`,
124 });
125 },
126 );
127
96128 it("uses the top of a derived stack even when its bottom was linked later", () => {
97129 const bottom = linkedPr(1, { linkedAt: "2026-09-09T00:00:00.000Z" });
98130 const top = linkedPr(2);
apps/web/src/components/ThreadStatusIndicators.tsx+6-7
......@@ -160,7 +160,7 @@ export function ThreadPullRequestBadgeControl({
160160 ? `Stack of ${badge.layers} pull requests, ${badge.state}`
161161 : `${status?.tooltip ?? `PR #${number}, status pending`}${
162162 badge?.kind === "pull-request" && badge.others > 0
163 ? `, and ${badge.others} more linked`
163 ? `, and ${badge.others} more linked; overall ${badge.state}`
164164 : ""
165165 }`;
166166 const className = cn(
......@@ -170,11 +170,9 @@ export function ThreadPullRequestBadgeControl({
170170 "text-xs tabular-nums",
171171 variant === "ghost" &&
172172 "font-normal text-xs! active:scale-100 [--control-icon-color:currentColor]",
173 linkedCount !== null
174 ? "text-secondary-label"
175 : isStack
176 ? PR_STATE_COLOR_CLASS[badge.state]
177 : (status?.colorClass ?? "text-muted-foreground"),
173 badge !== null && (isStack || linkedCount !== null)
174 ? PR_STATE_COLOR_CLASS[badge.state]
175 : (status?.colorClass ?? "text-muted-foreground"),
178176 );
179177 const content = (
180178 <>
......@@ -276,10 +274,11 @@ export function ThreadPullRequestsMiniList({
276274}
277275
278276/** The ink each pull-request state wears in the sidebar, shared by the number and stack badges. */
279const PR_STATE_COLOR_CLASS: Record<NonNullable<ThreadPr>["state"], string> = {
277const PR_STATE_COLOR_CLASS: Record<ThreadPullRequestBadge["state"], string> = {
280278 open: "text-emerald-600 dark:text-emerald-300/90",
281279 merged: "text-violet-600 dark:text-violet-300/90",
282280 closed: "text-red-600 dark:text-red-300/90",
281 draft: "text-zinc-500 dark:text-zinc-400/80",
283282};
284283
285284export function settledPrHoverColorClass(
packages/shared/src/threadPullRequests.test.ts+40-1
......@@ -248,6 +248,40 @@ describe("resolveThreadPullRequestChains", () => {
248248});
249249
250250describe("chain selection and badge state", () => {
251 it.each([
252 ["open", false, "open", false, "open"],
253 ["closed", false, "closed", false, "closed"],
254 ["open", true, "open", true, "draft"],
255 ["open", false, "open", true, "open"],
256 ["closed", false, "open", true, "open"],
257 ["merged", false, "merged", false, "merged"],
258 ["merged", false, "closed", true, "closed"],
259 ] as const)(
260 "aggregates %s (draft %s) and %s (draft %s) as %s",
261 (firstState, firstDraft, secondState, secondDraft, state) => {
262 for (const stacked of [false, true]) {
263 const links = [
264 link(1, {
265 snapshot: snapshot({ state: firstState, isDraft: firstDraft, headBranch: "base" }),
266 }),
267 link(2, {
268 snapshot: snapshot({
269 state: secondState,
270 isDraft: secondDraft,
271 baseBranch: stacked ? "base" : "main",
272 }),
273 }),
274 link(3, { source: "stack-dismissed" }),
275 ];
276 expect(resolveThreadPullRequestBadge(links)).toEqual(
277 stacked
278 ? { kind: "stack", layers: 2, state }
279 : { kind: "pull-request", others: 1, state },
280 );
281 }
282 },
283 );
284
251285 it.each(["open", "merged", "closed"] as const)(
252286 "targets the top of a derived %s chain despite a later bottom update and link",
253287 (state) => {
......@@ -309,6 +343,7 @@ describe("chain selection and badge state", () => {
309343 expect(resolveThreadPullRequestBadge([bottom, top, link(3)])).toEqual({
310344 kind: "pull-request",
311345 others: 2,
346 state: "open",
312347 });
313348 expect(resolveThreadPullRequestBadge([link(3, { source: "stack-dismissed" })])).toBeNull();
314349 });
......@@ -340,7 +375,11 @@ describe("chain selection and badge state", () => {
340375 kind: "stack",
341376 top: { number: 2 },
342377 });
343 expect(resolveThreadPullRequestBadge(links)).toEqual({ kind: "pull-request", others: 1 });
378 expect(resolveThreadPullRequestBadge(links)).toEqual({
379 kind: "pull-request",
380 others: 1,
381 state: "open",
382 });
344383 });
345384
346385 it("does not guess a parent when a head branch was reused", () => {
packages/shared/src/threadPullRequests.ts+13-9
......@@ -243,31 +243,35 @@ export function resolveThreadPullRequestChains(
243243 return chains;
244244}
245245
246export type ThreadPullRequestBadge =
246export type ThreadPullRequestBadge = {
247 readonly state: "open" | "closed" | "merged" | "draft";
248} & (
247249 | {
248250 readonly kind: "stack";
249251 readonly layers: number;
250 readonly state: "open" | "closed" | "merged";
251252 }
252 | { readonly kind: "pull-request"; readonly others: number };
253 | { readonly kind: "pull-request"; readonly others: number }
254);
253255
254/** Aggregate a single chain's state; unrelated links show a count beside the current PR. */
256/** Aggregate visible links' state for both stacks and unrelated linked counts. */
255257export function resolveThreadPullRequestBadge(
256258 pullRequests: ReadonlyArray<ThreadPullRequestLink> | undefined,
257259): ThreadPullRequestBadge | null {
258260 const visible = visibleThreadPullRequests(pullRequests ?? []);
259261 if (visible.length === 0) return null;
260 const chains = resolveThreadPullRequestChains(visible);
261 if (visible.length > 1 && chains.length === 1) {
262 const states = visible.map((link) => link.snapshot?.state ?? "open");
263 const state = states.includes("open")
262 const states = visible.map((link) => link.snapshot?.state ?? "open");
263 const state = visible.every((link) => link.snapshot?.state === "open" && link.snapshot.isDraft)
264 ? "draft"
265 : states.includes("open")
264266 ? "open"
265267 : states.every((entry) => entry === "merged")
266268 ? "merged"
267269 : "closed";
270 const chains = resolveThreadPullRequestChains(visible);
271 if (visible.length > 1 && chains.length === 1) {
268272 return { kind: "stack", layers: visible.length, state };
269273 }
270 return { kind: "pull-request", others: visible.length - 1 };
274 return { kind: "pull-request", others: visible.length - 1, state };
271275}
272276
273277/** Search terms for visible PR links, including the legacy single-link projection. */