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;...@@ -44,11 +44,11 @@ export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET;
44const SIDEBAR_ROW_RADIUS = 12;44const SIDEBAR_ROW_RADIUS = 12;
4545
46function pullRequestTintColor(46function pullRequestTintColor(
47 pr: Pick<ThreadPrPresentation, "state" | "isDraft" | "others">,47 pr: Pick<ThreadPrPresentation, "state" | "isDraft" | "others" | "kind">,
48 colorScheme: "light" | "dark",48 colorScheme: "light" | "dark",
49) {49) {
50 const dark = colorScheme === "dark";50 const dark = colorScheme === "dark";
51 if (pr.others > 0 || (pr.state === "open" && pr.isDraft === true)) {51 if (pr.state === "open" && pr.isDraft === true) {
52 return dark ? "#a1a1aa" : "#71717a";52 return dark ? "#a1a1aa" : "#71717a";
53 }53 }
54 switch (pr.state) {54 switch (pr.state) {
...@@ -56,8 +56,12 @@ function pullRequestTintColor(...@@ -56,8 +56,12 @@ function pullRequestTintColor(
56 return dark ? "#34d399" : "#059669";56 return dark ? "#34d399" : "#059669";
57 case "merged":57 case "merged":
58 return dark ? "#a78bfa" : "#7c3aed";58 return dark ? "#a78bfa" : "#7c3aed";
59 case null:
60 case "closed":59 case "closed":
60 if (pr.kind === "stack" || pr.others > 0) {
61 return dark ? "#fb7185" : "#e11d48";
62 }
63 return dark ? "#a1a1aa" : "#71717a";
64 case null:
61 return dark ? "#a1a1aa" : "#71717a";65 return dark ? "#a1a1aa" : "#71717a";
62 }66 }
63}67}
apps/mobile/src/features/threads/thread-list-v2-items.tsx+7-1
...@@ -880,7 +880,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -880,7 +880,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
880 ? materialYouStyleLayoutActive880 ? materialYouStyleLayoutActive
881 ? "accent-thread-selected-foreground"881 ? "accent-thread-selected-foreground"
882 : "accent-user-bubble-foreground"882 : "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"
884 }890 }
885 />891 />
886 ) : null}892 ) : null}
apps/mobile/src/state/thread-pr-presentation.ts+17-6
...@@ -63,9 +63,16 @@ export function presentThreadLinkedPullRequests(...@@ -63,9 +63,16 @@ export function presentThreadLinkedPullRequests(
63 const badge = resolveThreadPullRequestBadge(links);63 const badge = resolveThreadPullRequestBadge(links);
64 if (link === null || badge === null) return null;64 if (link === null || badge === null) return null;
65 const snapshot = link.snapshot;65 const snapshot = link.snapshot;
66 const state = badge.kind === "stack" ? badge.state : (snapshot?.state ?? null);
67 const isDraft = snapshot?.isDraft === true && state === "open";
68 const linkedCount = badge.kind === "pull-request" && badge.others > 0 ? badge.others + 1 : null;66 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";
69 const label =76 const label =
70 badge.kind === "stack"77 badge.kind === "stack"
71 ? String(badge.layers)78 ? String(badge.layers)
...@@ -83,12 +90,16 @@ export function presentThreadLinkedPullRequests(...@@ -83,12 +90,16 @@ export function presentThreadLinkedPullRequests(
83 label,90 label,
84 accessibilityLabel:91 accessibilityLabel:
85 badge.kind === "stack"92 badge.kind === "stack"
86 ? `${badge.layers} pull requests in stack, ${state ?? "status pending"}`93 ? `${badge.layers} pull requests in stack, ${isDraft ? "draft" : (state ?? "status pending")}`
87 : `#${link.number} pull request ${state === null ? "status pending" : isDraft ? "draft" : state}${badge.others > 0 ? `, ${badge.others} more linked` : ""}`,94 : linkedCount !== null
95 ? `${linkedCount} linked pull requests, overall ${badge.state}`
96 : `#${link.number} pull request ${state === null ? "status pending" : isDraft ? "draft" : state}`,
88 textClassName:97 textClassName:
89 linkedCount !== null || state === null || isDraft98 state === null || isDraft
90 ? "text-foreground-muted"99 ? "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],
92 };103 };
93}104}
94105
apps/mobile/src/state/use-thread-pr.test.ts+33-1
...@@ -89,10 +89,42 @@ describe("presentThreadLinkedPullRequests", () => {...@@ -89,10 +89,42 @@ describe("presentThreadLinkedPullRequests", () => {
89 kind: "pull-request",89 kind: "pull-request",
90 label: "+2",90 label: "+2",
91 others: 1,91 others: 1,
92 textClassName: "text-foreground-muted",92 state: "open",
93 isDraft: false,
94 textClassName: "text-adaptive-emerald-600-400",
93 });95 });
94 });96 });
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
96 it("uses the top of a derived stack even when its bottom was linked later", () => {128 it("uses the top of a derived stack even when its bottom was linked later", () => {
97 const bottom = linkedPr(1, { linkedAt: "2026-09-09T00:00:00.000Z" });129 const bottom = linkedPr(1, { linkedAt: "2026-09-09T00:00:00.000Z" });
98 const top = linkedPr(2);130 const top = linkedPr(2);
apps/web/src/components/ThreadStatusIndicators.tsx+6-7
...@@ -160,7 +160,7 @@ export function ThreadPullRequestBadgeControl({...@@ -160,7 +160,7 @@ export function ThreadPullRequestBadgeControl({
160 ? `Stack of ${badge.layers} pull requests, ${badge.state}`160 ? `Stack of ${badge.layers} pull requests, ${badge.state}`
161 : `${status?.tooltip ?? `PR #${number}, status pending`}${161 : `${status?.tooltip ?? `PR #${number}, status pending`}${
162 badge?.kind === "pull-request" && badge.others > 0162 badge?.kind === "pull-request" && badge.others > 0
163 ? `, and ${badge.others} more linked`163 ? `, and ${badge.others} more linked; overall ${badge.state}`
164 : ""164 : ""
165 }`;165 }`;
166 const className = cn(166 const className = cn(
...@@ -170,11 +170,9 @@ export function ThreadPullRequestBadgeControl({...@@ -170,11 +170,9 @@ export function ThreadPullRequestBadgeControl({
170 "text-xs tabular-nums",170 "text-xs tabular-nums",
171 variant === "ghost" &&171 variant === "ghost" &&
172 "font-normal text-xs! active:scale-100 [--control-icon-color:currentColor]",172 "font-normal text-xs! active:scale-100 [--control-icon-color:currentColor]",
173 linkedCount !== null173 badge !== null && (isStack || linkedCount !== null)
174 ? "text-secondary-label"174 ? PR_STATE_COLOR_CLASS[badge.state]
175 : isStack175 : (status?.colorClass ?? "text-muted-foreground"),
176 ? PR_STATE_COLOR_CLASS[badge.state]
177 : (status?.colorClass ?? "text-muted-foreground"),
178 );176 );
179 const content = (177 const content = (
180 <>178 <>
...@@ -276,10 +274,11 @@ export function ThreadPullRequestsMiniList({...@@ -276,10 +274,11 @@ export function ThreadPullRequestsMiniList({
276}274}
277275
278/** The ink each pull-request state wears in the sidebar, shared by the number and stack badges. */276/** 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> = {
280 open: "text-emerald-600 dark:text-emerald-300/90",278 open: "text-emerald-600 dark:text-emerald-300/90",
281 merged: "text-violet-600 dark:text-violet-300/90",279 merged: "text-violet-600 dark:text-violet-300/90",
282 closed: "text-red-600 dark:text-red-300/90",280 closed: "text-red-600 dark:text-red-300/90",
281 draft: "text-zinc-500 dark:text-zinc-400/80",
283};282};
284283
285export function settledPrHoverColorClass(284export function settledPrHoverColorClass(
packages/shared/src/threadPullRequests.test.ts+40-1
...@@ -248,6 +248,40 @@ describe("resolveThreadPullRequestChains", () => {...@@ -248,6 +248,40 @@ describe("resolveThreadPullRequestChains", () => {
248});248});
249249
250describe("chain selection and badge state", () => {250describe("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
251 it.each(["open", "merged", "closed"] as const)(285 it.each(["open", "merged", "closed"] as const)(
252 "targets the top of a derived %s chain despite a later bottom update and link",286 "targets the top of a derived %s chain despite a later bottom update and link",
253 (state) => {287 (state) => {
...@@ -309,6 +343,7 @@ describe("chain selection and badge state", () => {...@@ -309,6 +343,7 @@ describe("chain selection and badge state", () => {
309 expect(resolveThreadPullRequestBadge([bottom, top, link(3)])).toEqual({343 expect(resolveThreadPullRequestBadge([bottom, top, link(3)])).toEqual({
310 kind: "pull-request",344 kind: "pull-request",
311 others: 2,345 others: 2,
346 state: "open",
312 });347 });
313 expect(resolveThreadPullRequestBadge([link(3, { source: "stack-dismissed" })])).toBeNull();348 expect(resolveThreadPullRequestBadge([link(3, { source: "stack-dismissed" })])).toBeNull();
314 });349 });
...@@ -340,7 +375,11 @@ describe("chain selection and badge state", () => {...@@ -340,7 +375,11 @@ describe("chain selection and badge state", () => {
340 kind: "stack",375 kind: "stack",
341 top: { number: 2 },376 top: { number: 2 },
342 });377 });
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 });
344 });383 });
345384
346 it("does not guess a parent when a head branch was reused", () => {385 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(...@@ -243,31 +243,35 @@ export function resolveThreadPullRequestChains(
243 return chains;243 return chains;
244}244}
245245
246export type ThreadPullRequestBadge =246export type ThreadPullRequestBadge = {
247 readonly state: "open" | "closed" | "merged" | "draft";
248} & (
247 | {249 | {
248 readonly kind: "stack";250 readonly kind: "stack";
249 readonly layers: number;251 readonly layers: number;
250 readonly state: "open" | "closed" | "merged";
251 }252 }
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. */
255export function resolveThreadPullRequestBadge(257export function resolveThreadPullRequestBadge(
256 pullRequests: ReadonlyArray<ThreadPullRequestLink> | undefined,258 pullRequests: ReadonlyArray<ThreadPullRequestLink> | undefined,
257): ThreadPullRequestBadge | null {259): ThreadPullRequestBadge | null {
258 const visible = visibleThreadPullRequests(pullRequests ?? []);260 const visible = visibleThreadPullRequests(pullRequests ?? []);
259 if (visible.length === 0) return null;261 if (visible.length === 0) return null;
260 const chains = resolveThreadPullRequestChains(visible);262 const states = visible.map((link) => link.snapshot?.state ?? "open");
261 if (visible.length > 1 && chains.length === 1) {263 const state = visible.every((link) => link.snapshot?.state === "open" && link.snapshot.isDraft)
262 const states = visible.map((link) => link.snapshot?.state ?? "open");264 ? "draft"
263 const state = states.includes("open")265 : states.includes("open")
264 ? "open"266 ? "open"
265 : states.every((entry) => entry === "merged")267 : states.every((entry) => entry === "merged")
266 ? "merged"268 ? "merged"
267 : "closed";269 : "closed";
270 const chains = resolveThreadPullRequestChains(visible);
271 if (visible.length > 1 && chains.length === 1) {
268 return { kind: "stack", layers: visible.length, state };272 return { kind: "stack", layers: visible.length, state };
269 }273 }
270 return { kind: "pull-request", others: visible.length - 1 };274 return { kind: "pull-request", others: visible.length - 1, state };
271}275}
272276
273/** Search terms for visible PR links, including the legacy single-link projection. */277/** Search terms for visible PR links, including the legacy single-link projection. */