authorgravatar for 118054752+extoci@users.noreply.github.comExotic <118054752+extoci@users.noreply.github.com> 2026-09-04 09:17:18+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 02:17:18-04:00
logcaab2fdbac041ac2e851ad4fa3ac4a40a1d4a8f6
tree021bd438f2ef51ef5a54ffea4fd0632acb74f3a5
parent5cc369b7eb882dbea8d5ad21ba688c73a0058748
signature Signed by PGP key B5690EEEBB952194

fix(web): render draft PRs in gray (#9537)


30 files changed, 152 insertions(+), 36 deletions(-)

apps/mobile/src/features/threads/thread-list-items.tsx+10-6
...@@ -23,7 +23,7 @@ import { relativeTime } from "../../lib/time";...@@ -23,7 +23,7 @@ import { relativeTime } from "../../lib/time";
23import { themeColorWithAlpha } from "../../lib/mobileTheme";23import { themeColorWithAlpha } from "../../lib/mobileTheme";
24import { useUniwindTheme } from "../../lib/useUniwindTheme";24import { useUniwindTheme } from "../../lib/useUniwindTheme";
25import type { PendingNewTask } from "../../state/use-pending-new-tasks";25import type { PendingNewTask } from "../../state/use-pending-new-tasks";
26import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr";26import { useThreadPr, type ThreadPrPresentation } from "../../state/use-thread-pr";
27import type { HomeGroupDisplayAction } from "../home/homeListItems";27import type { HomeGroupDisplayAction } from "../home/homeListItems";
28import { ThreadSwipeable } from "../home/thread-swipe-actions";28import { ThreadSwipeable } from "../home/thread-swipe-actions";
29import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";29import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
...@@ -42,9 +42,15 @@ export type ThreadListVariant = "compact" | "sidebar";...@@ -42,9 +42,15 @@ export type ThreadListVariant = "compact" | "sidebar";
42export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET;42export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET;
43const SIDEBAR_ROW_RADIUS = 12;43const SIDEBAR_ROW_RADIUS = 12;
4444
45function pullRequestTintColor(state: ThreadPr["state"], colorScheme: "light" | "dark") {45function pullRequestTintColor(
46 pr: Pick<ThreadPrPresentation, "state" | "isDraft">,
47 colorScheme: "light" | "dark",
48) {
46 const dark = colorScheme === "dark";49 const dark = colorScheme === "dark";
47 switch (state) {50 if (pr.state === "open" && pr.isDraft === true) {
51 return dark ? "#a1a1aa" : "#71717a";
52 }
53 switch (pr.state) {
48 case "open":54 case "open":
49 return dark ? "#34d399" : "#059669";55 return dark ? "#34d399" : "#059669";
50 case "merged":56 case "merged":
...@@ -548,9 +554,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -548,9 +554,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
548 <PullRequestIcon554 <PullRequestIcon
549 size={compact ? 13 : 11}555 size={compact ? 13 : 11}
550 color={556 color={
551 selected557 selected ? String(selectedForegroundColor) : pullRequestTintColor(pr, colorScheme)
552 ? String(selectedForegroundColor)
553 : pullRequestTintColor(pr.state, colorScheme)
554 }558 }
555 />559 />
556 <Text560 <Text
apps/mobile/src/state/thread-pr-presentation.ts+5-2
...@@ -6,6 +6,7 @@ export type ThreadPr = NonNullable<VcsStatusResult["pr"]>;...@@ -6,6 +6,7 @@ export type ThreadPr = NonNullable<VcsStatusResult["pr"]>;
6export interface ThreadPrPresentation {6export interface ThreadPrPresentation {
7 readonly number: number;7 readonly number: number;
8 readonly state: ThreadPr["state"];8 readonly state: ThreadPr["state"];
9 readonly isDraft: boolean;
9 /** Provider-side last activity, bounding when a terminal state landed. */10 /** Provider-side last activity, bounding when a terminal state landed. */
10 readonly updatedAt: string | null;11 readonly updatedAt: string | null;
11 readonly url: string;12 readonly url: string;
...@@ -27,13 +28,15 @@ export function presentThreadPr(...@@ -27,13 +28,15 @@ export function presentThreadPr(
27 provider: VcsStatusResult["sourceControlProvider"] | null | undefined,28 provider: VcsStatusResult["sourceControlProvider"] | null | undefined,
28): ThreadPrPresentation {29): ThreadPrPresentation {
29 const presentation = resolveChangeRequestPresentation(provider);30 const presentation = resolveChangeRequestPresentation(provider);
31 const isDraft = pr.state === "open" && pr.isDraft === true;
30 return {32 return {
31 number: pr.number,33 number: pr.number,
32 state: pr.state,34 state: pr.state,
35 isDraft,
33 updatedAt: pr.updatedAt ?? null,36 updatedAt: pr.updatedAt ?? null,
34 url: pr.url,37 url: pr.url,
35 label: String(pr.number),38 label: String(pr.number),
36 accessibilityLabel: `#${pr.number} ${presentation.longName} ${pr.state}`,39 accessibilityLabel: `#${pr.number} ${presentation.longName} ${isDraft ? "draft" : pr.state}`,
37 textClassName: PR_STATE_TEXT_CLASS[pr.state],40 textClassName: isDraft ? "text-adaptive-zinc-500-400" : PR_STATE_TEXT_CLASS[pr.state],
38 };41 };
39}42}
apps/mobile/src/state/use-thread-pr.test.ts+9
...@@ -33,4 +33,13 @@ describe("presentThreadPr", () => {...@@ -33,4 +33,13 @@ describe("presentThreadPr", () => {
33 accessibilityLabel: "#3774 merge request merged",33 accessibilityLabel: "#3774 merge request merged",
34 });34 });
35 });35 });
36
37 it("uses gray for draft pull requests", () => {
38 expect(
39 presentThreadPr({ ...pullRequest, state: "open", isDraft: true }, undefined),
40 ).toMatchObject({
41 accessibilityLabel: "#3774 pull request draft",
42 textClassName: "text-adaptive-zinc-500-400",
43 });
44 });
36});45});
apps/server/src/git/GitManager.test.ts+10-5
...@@ -54,6 +54,7 @@ interface FakeGhScenario {...@@ -54,6 +54,7 @@ interface FakeGhScenario {
54 baseRefName: string;54 baseRefName: string;
55 headRefName: string;55 headRefName: string;
56 state?: "open" | "closed" | "merged";56 state?: "open" | "closed" | "merged";
57 isDraft?: boolean;
57 isCrossRepository?: boolean;58 isCrossRepository?: boolean;
58 headRepositoryNameWithOwner?: string | null;59 headRepositoryNameWithOwner?: string | null;
59 headRepositoryOwnerLogin?: string | null;60 headRepositoryOwnerLogin?: string | null;
...@@ -116,6 +117,7 @@ function normalizeFakePullRequestSummary(raw: unknown): GitHubCli.GitHubPullRequ...@@ -116,6 +117,7 @@ function normalizeFakePullRequestSummary(raw: unknown): GitHubCli.GitHubPullRequ
116 ? "closed"117 ? "closed"
117 : "merged"118 : "merged"
118 : undefined;119 : undefined;
120 const isDraft = typeof record.isDraft === "boolean" ? record.isDraft : undefined;
119 const isCrossRepository =121 const isCrossRepository =
120 typeof record.isCrossRepository === "boolean" ? record.isCrossRepository : undefined;122 typeof record.isCrossRepository === "boolean" ? record.isCrossRepository : undefined;
121 const headRepositoryNameWithOwner =123 const headRepositoryNameWithOwner =
...@@ -138,6 +140,7 @@ function normalizeFakePullRequestSummary(raw: unknown): GitHubCli.GitHubPullRequ...@@ -138,6 +140,7 @@ function normalizeFakePullRequestSummary(raw: unknown): GitHubCli.GitHubPullRequ
138 baseRefName,140 baseRefName,
139 headRefName,141 headRefName,
140 ...(state ? { state } : {}),142 ...(state ? { state } : {}),
143 ...(isDraft === true ? { isDraft: true } : {}),
141 ...(isCrossRepository !== undefined ? { isCrossRepository } : {}),144 ...(isCrossRepository !== undefined ? { isCrossRepository } : {}),
142 ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),145 ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),
143 ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),146 ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
...@@ -508,7 +511,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -508,7 +511,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
508 "--limit",511 "--limit",
509 String(input.limit ?? 1),512 String(input.limit ?? 1),
510 "--json",513 "--json",
511 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",514 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
512 ],515 ],
513 }).pipe(516 }).pipe(
514 Effect.map((result) => JSON.parse(result.stdout) as unknown[]),517 Effect.map((result) => JSON.parse(result.stdout) as unknown[]),
...@@ -552,7 +555,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -552,7 +555,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
552 "view",555 "view",
553 input.reference,556 input.reference,
554 "--json",557 "--json",
555 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",558 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
556 ],559 ],
557 }).pipe(560 }).pipe(
558 Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary),561 Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary),
...@@ -699,7 +702,7 @@ const GitManagerTestLayer = GitVcsDriver.layer.pipe(...@@ -699,7 +702,7 @@ const GitManagerTestLayer = GitVcsDriver.layer.pipe(
699);702);
700703
701it.layer(GitManagerTestLayer)("GitManager", (it) => {704it.layer(GitManagerTestLayer)("GitManager", (it) => {
702 it.effect("status includes PR metadata when branch already has an open PR", () =>705 it.effect("status includes draft PR metadata when branch already has a draft PR", () =>
703 Effect.gen(function* () {706 Effect.gen(function* () {
704 const repoDir = yield* makeTempDir("t3code-git-manager-");707 const repoDir = yield* makeTempDir("t3code-git-manager-");
705 yield* initRepo(repoDir);708 yield* initRepo(repoDir);
...@@ -719,6 +722,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -719,6 +722,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
719 url: "https://github.com/pingdotgg/codething-mvp/pull/13",722 url: "https://github.com/pingdotgg/codething-mvp/pull/13",
720 baseRefName: "main",723 baseRefName: "main",
721 headRefName: "feature/status-open-pr",724 headRefName: "feature/status-open-pr",
725 isDraft: true,
722 },726 },
723 ]),727 ]),
724 ],728 ],
...@@ -737,6 +741,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -737,6 +741,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
737 baseRef: "main",741 baseRef: "main",
738 headRef: "feature/status-open-pr",742 headRef: "feature/status-open-pr",
739 state: "open",743 state: "open",
744 isDraft: true,
740 updatedAt: null,745 updatedAt: null,
741 });746 });
742 }),747 }),
...@@ -1686,7 +1691,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1686,7 +1691,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1686 updatedAt: "2026-03-10T07:00:00.000Z",1691 updatedAt: "2026-03-10T07:00:00.000Z",
1687 });1692 });
1688 expect(ghCalls).toContain(1693 expect(ghCalls).toContain(
1689 "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",1694 "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
1690 );1695 );
1691 }),1696 }),
1692 20_000,1697 20_000,
...@@ -1752,7 +1757,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1752,7 +1757,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1752 updatedAt: "2026-03-10T07:00:00.000Z",1757 updatedAt: "2026-03-10T07:00:00.000Z",
1753 });1758 });
1754 expect(ghCalls).toContain(1759 expect(ghCalls).toContain(
1755 "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",1760 "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
1756 );1761 );
1757 }),1762 }),
1758 20_000,1763 20_000,
apps/server/src/git/GitManager.ts+5-5
...@@ -170,6 +170,7 @@ interface OpenPrInfo {...@@ -170,6 +170,7 @@ interface OpenPrInfo {
170170
171interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo {171interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo {
172 state: "open" | "closed" | "merged";172 state: "open" | "closed" | "merged";
173 isDraft?: boolean;
173 updatedAt: Option.Option<DateTime.Utc>;174 updatedAt: Option.Option<DateTime.Utc>;
174}175}
175176
...@@ -404,6 +405,7 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo {...@@ -404,6 +405,7 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo {
404 baseRefName: summary.baseRefName,405 baseRefName: summary.baseRefName,
405 headRefName: summary.headRefName,406 headRefName: summary.headRefName,
406 state: summary.state ?? "open",407 state: summary.state ?? "open",
408 ...(summary.isDraft === true ? { isDraft: true } : {}),
407 updatedAt: summary.updatedAt,409 updatedAt: summary.updatedAt,
408 ...(summary.isCrossRepository !== undefined410 ...(summary.isCrossRepository !== undefined
409 ? { isCrossRepository: summary.isCrossRepository }411 ? { isCrossRepository: summary.isCrossRepository }
...@@ -558,6 +560,7 @@ function toStatusPr(pr: PullRequestInfo): {...@@ -558,6 +560,7 @@ function toStatusPr(pr: PullRequestInfo): {
558 baseRef: string;560 baseRef: string;
559 headRef: string;561 headRef: string;
560 state: "open" | "closed" | "merged";562 state: "open" | "closed" | "merged";
563 isDraft?: boolean;
561 updatedAt: string | null;564 updatedAt: string | null;
562} {565} {
563 return {566 return {
...@@ -567,6 +570,7 @@ function toStatusPr(pr: PullRequestInfo): {...@@ -567,6 +570,7 @@ function toStatusPr(pr: PullRequestInfo): {
567 baseRef: pr.baseRefName,570 baseRef: pr.baseRefName,
568 headRef: pr.headRefName,571 headRef: pr.headRefName,
569 state: pr.state,572 state: pr.state,
573 ...(pr.isDraft === true ? { isDraft: true } : {}),
570 updatedAt: Option.match(pr.updatedAt, {574 updatedAt: Option.match(pr.updatedAt, {
571 onNone: () => null,575 onNone: () => null,
572 onSome: (updatedAt) => DateTime.formatIso(updatedAt),576 onSome: (updatedAt) => DateTime.formatIso(updatedAt),
...@@ -1518,11 +1522,7 @@ export const make = Effect.gen(function* () {...@@ -1518,11 +1522,7 @@ export const make = Effect.gen(function* () {
1518 );1522 );
1519 if (firstPullRequest) {1523 if (firstPullRequest) {
1520 return {1524 return {
1521 number: firstPullRequest.number,1525 ...firstPullRequest,
1522 title: firstPullRequest.title,
1523 url: firstPullRequest.url,
1524 baseRefName: firstPullRequest.baseRefName,
1525 headRefName: firstPullRequest.headRefName,
1526 state: "open",1526 state: "open",
1527 updatedAt: Option.none(),1527 updatedAt: Option.none(),
1528 } satisfies PullRequestInfo;1528 } satisfies PullRequestInfo;
apps/server/src/pullRequest/GitHubPullRequestCli.ts+2
...@@ -468,6 +468,7 @@ export class GitHubPullRequestCli extends Context.Service<...@@ -468,6 +468,7 @@ export class GitHubPullRequestCli extends Context.Service<
468 readonly headBranch: string;468 readonly headBranch: string;
469 readonly baseBranch: string;469 readonly baseBranch: string;
470 readonly state: "open" | "closed" | "merged";470 readonly state: "open" | "closed" | "merged";
471 readonly isDraft?: boolean;
471 readonly updatedAt: string;472 readonly updatedAt: string;
472 },473 },
473 GitHubPullRequestCliError474 GitHubPullRequestCliError
...@@ -1650,6 +1651,7 @@ export const make = Effect.gen(function* () {...@@ -1650,6 +1651,7 @@ export const make = Effect.gen(function* () {
1650 headBranch: summary.headRefName,1651 headBranch: summary.headRefName,
1651 baseBranch: summary.baseRefName,1652 baseBranch: summary.baseRefName,
1652 state: summary.state ?? "open",1653 state: summary.state ?? "open",
1654 ...(summary.isDraft === true ? { isDraft: true } : {}),
1653 updatedAt: summary.updatedAt,1655 updatedAt: summary.updatedAt,
1654 }),1656 }),
1655 ),1657 ),
apps/server/src/pullRequest/PullRequestProvider.ts+2
...@@ -96,6 +96,8 @@ export interface ProviderChangeRequestSummary {...@@ -96,6 +96,8 @@ export interface ProviderChangeRequestSummary {
96 readonly headBranch: string;96 readonly headBranch: string;
97 readonly baseBranch: string;97 readonly baseBranch: string;
98 readonly state: PullRequestState;98 readonly state: PullRequestState;
99 /** Present when the host says an open pull request is still a draft. */
100 readonly isDraft?: boolean;
99 readonly updatedAt: string;101 readonly updatedAt: string;
100}102}
101103
apps/server/src/pullRequest/PullRequestService.ts+2
...@@ -1254,6 +1254,7 @@ export const make = Effect.gen(function* () {...@@ -1254,6 +1254,7 @@ export const make = Effect.gen(function* () {
1254 title: changeRequest.title,1254 title: changeRequest.title,
1255 url: changeRequest.url,1255 url: changeRequest.url,
1256 state: changeRequest.state,1256 state: changeRequest.state,
1257 ...(changeRequest.isDraft === true ? { isDraft: true } : {}),
1257 headBranch: changeRequest.headBranch,1258 headBranch: changeRequest.headBranch,
1258 baseBranch: changeRequest.baseBranch,1259 baseBranch: changeRequest.baseBranch,
1259 updatedAt: changeRequest.updatedAt,1260 updatedAt: changeRequest.updatedAt,
...@@ -2278,6 +2279,7 @@ export const make = Effect.gen(function* () {...@@ -2278,6 +2279,7 @@ export const make = Effect.gen(function* () {
2278 title: detail.title,2279 title: detail.title,
2279 url: detail.url,2280 url: detail.url,
2280 state: detail.state,2281 state: detail.state,
2282 ...(detail.isDraft === true ? { isDraft: true } : {}),
2281 headBranch: detail.headBranch,2283 headBranch: detail.headBranch,
2282 baseBranch: detail.baseBranch,2284 baseBranch: detail.baseBranch,
2283 updatedAt: detail.updatedAt,2285 updatedAt: detail.updatedAt,
apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts+2
...@@ -61,6 +61,7 @@ function toChangeRequest(summary: {...@@ -61,6 +61,7 @@ function toChangeRequest(summary: {
61 readonly baseRefName: string;61 readonly baseRefName: string;
62 readonly headRefName: string;62 readonly headRefName: string;
63 readonly state: "open" | "closed" | "merged";63 readonly state: "open" | "closed" | "merged";
64 readonly isDraft?: boolean;
64 readonly updatedAt: ChangeRequest["updatedAt"];65 readonly updatedAt: ChangeRequest["updatedAt"];
65}): ChangeRequest {66}): ChangeRequest {
66 return {67 return {
...@@ -71,6 +72,7 @@ function toChangeRequest(summary: {...@@ -71,6 +72,7 @@ function toChangeRequest(summary: {
71 baseRefName: summary.baseRefName,72 baseRefName: summary.baseRefName,
72 headRefName: summary.headRefName,73 headRefName: summary.headRefName,
73 state: summary.state,74 state: summary.state,
75 ...(summary.isDraft === true ? { isDraft: true } : {}),
74 updatedAt: summary.updatedAt,76 updatedAt: summary.updatedAt,
75 isCrossRepository: false,77 isCrossRepository: false,
76 };78 };
apps/server/src/sourceControl/BitbucketSourceControlProvider.ts+1
...@@ -17,6 +17,7 @@ function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeR...@@ -17,6 +17,7 @@ function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeR
17 baseRefName: summary.baseRefName,17 baseRefName: summary.baseRefName,
18 headRefName: summary.headRefName,18 headRefName: summary.headRefName,
19 state: summary.state,19 state: summary.state,
20 ...(summary.isDraft === true ? { isDraft: true } : {}),
20 updatedAt: summary.updatedAt ?? Option.none(),21 updatedAt: summary.updatedAt ?? Option.none(),
21 ...(summary.isCrossRepository !== undefined22 ...(summary.isCrossRepository !== undefined
22 ? { isCrossRepository: summary.isCrossRepository }23 ? { isCrossRepository: summary.isCrossRepository }
apps/server/src/sourceControl/GitHubCli.test.ts+3-1
...@@ -65,6 +65,7 @@ describe("GitHubCli.layer", () => {...@@ -65,6 +65,7 @@ describe("GitHubCli.layer", () => {
65 baseRefName: "main",65 baseRefName: "main",
66 headRefName: "feature/pr-threads",66 headRefName: "feature/pr-threads",
67 state: "OPEN",67 state: "OPEN",
68 isDraft: true,
68 mergedAt: null,69 mergedAt: null,
69 updatedAt: "2026-08-24T12:34:56Z",70 updatedAt: "2026-08-24T12:34:56Z",
70 isCrossRepository: true,71 isCrossRepository: true,
...@@ -92,6 +93,7 @@ describe("GitHubCli.layer", () => {...@@ -92,6 +93,7 @@ describe("GitHubCli.layer", () => {
92 baseRefName: "main",93 baseRefName: "main",
93 headRefName: "feature/pr-threads",94 headRefName: "feature/pr-threads",
94 state: "open",95 state: "open",
96 isDraft: true,
95 updatedAt: "2026-08-24T12:34:56.000Z",97 updatedAt: "2026-08-24T12:34:56.000Z",
96 isCrossRepository: true,98 isCrossRepository: true,
97 headRepositoryNameWithOwner: "octocat/codething-mvp",99 headRepositoryNameWithOwner: "octocat/codething-mvp",
...@@ -105,7 +107,7 @@ describe("GitHubCli.layer", () => {...@@ -105,7 +107,7 @@ describe("GitHubCli.layer", () => {
105 "view",107 "view",
106 "#42",108 "#42",
107 "--json",109 "--json",
108 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",110 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
109 ],111 ],
110 cwd: "/repo",112 cwd: "/repo",
111 timeoutMs: 30_000,113 timeoutMs: 30_000,
apps/server/src/sourceControl/GitHubCli.ts+3-2
...@@ -205,6 +205,7 @@ export interface GitHubPullRequestSummary {...@@ -205,6 +205,7 @@ export interface GitHubPullRequestSummary {
205 readonly baseRefName: string;205 readonly baseRefName: string;
206 readonly headRefName: string;206 readonly headRefName: string;
207 readonly state?: "open" | "closed" | "merged";207 readonly state?: "open" | "closed" | "merged";
208 readonly isDraft?: boolean;
208 readonly updatedAt?: string;209 readonly updatedAt?: string;
209 readonly isCrossRepository?: boolean;210 readonly isCrossRepository?: boolean;
210 readonly headRepositoryNameWithOwner?: string | null;211 readonly headRepositoryNameWithOwner?: string | null;
...@@ -366,7 +367,7 @@ export const make = Effect.gen(function* () {...@@ -366,7 +367,7 @@ export const make = Effect.gen(function* () {
366 "--limit",367 "--limit",
367 String(input.limit ?? 1),368 String(input.limit ?? 1),
368 "--json",369 "--json",
369 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",370 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
370 ],371 ],
371 }).pipe(372 }).pipe(
372 Effect.map((result) => result.stdout.trim()),373 Effect.map((result) => result.stdout.trim()),
...@@ -398,7 +399,7 @@ export const make = Effect.gen(function* () {...@@ -398,7 +399,7 @@ export const make = Effect.gen(function* () {
398 "view",399 "view",
399 input.reference,400 input.reference,
400 "--json",401 "--json",
401 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",402 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
402 ],403 ],
403 }).pipe(404 }).pipe(
404 Effect.map((result) => result.stdout.trim()),405 Effect.map((result) => result.stdout.trim()),
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+1-1
...@@ -150,7 +150,7 @@ it.effect("uses gh json listing for non-open change request state queries", () =...@@ -150,7 +150,7 @@ it.effect("uses gh json listing for non-open change request state queries", () =
150 "--limit",150 "--limit",
151 "10",151 "10",
152 "--json",152 "--json",
153 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",153 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
154 ]);154 ]);
155 assert.strictEqual(changeRequests[0]?.provider, "github");155 assert.strictEqual(changeRequests[0]?.provider, "github");
156 assert.strictEqual(changeRequests[0]?.state, "merged");156 assert.strictEqual(changeRequests[0]?.state, "merged");
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+2-1
...@@ -30,6 +30,7 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq...@@ -30,6 +30,7 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq
30 baseRefName: summary.baseRefName,30 baseRefName: summary.baseRefName,
31 headRefName: summary.headRefName,31 headRefName: summary.headRefName,
32 state: summary.state ?? "open",32 state: summary.state ?? "open",
33 ...(summary.isDraft === true ? { isDraft: true } : {}),
33 updatedAt:34 updatedAt:
34 summary.updatedAt === undefined35 summary.updatedAt === undefined
35 ? Option.none()36 ? Option.none()
...@@ -153,7 +154,7 @@ export const make = Effect.gen(function* () {...@@ -153,7 +154,7 @@ export const make = Effect.gen(function* () {
153 "--limit",154 "--limit",
154 String(input.limit ?? 20),155 String(input.limit ?? 20),
155 "--json",156 "--json",
156 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",157 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
157 ],158 ],
158 })159 })
159 .pipe(160 .pipe(
apps/server/src/sourceControl/GitLabCli.ts+1
...@@ -246,6 +246,7 @@ export interface GitLabMergeRequestSummary {...@@ -246,6 +246,7 @@ export interface GitLabMergeRequestSummary {
246 readonly baseRefName: string;246 readonly baseRefName: string;
247 readonly headRefName: string;247 readonly headRefName: string;
248 readonly state?: "open" | "closed" | "merged";248 readonly state?: "open" | "closed" | "merged";
249 readonly isDraft?: boolean;
249 readonly updatedAt?: Option.Option<DateTime.Utc>;250 readonly updatedAt?: Option.Option<DateTime.Utc>;
250 readonly isCrossRepository?: boolean;251 readonly isCrossRepository?: boolean;
251 readonly headRepositoryNameWithOwner?: string | null;252 readonly headRepositoryNameWithOwner?: string | null;
apps/server/src/sourceControl/GitLabSourceControlProvider.ts+1
...@@ -26,6 +26,7 @@ function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRe...@@ -26,6 +26,7 @@ function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRe
26 baseRefName: summary.baseRefName,26 baseRefName: summary.baseRefName,
27 headRefName: summary.headRefName,27 headRefName: summary.headRefName,
28 state: summary.state ?? "open",28 state: summary.state ?? "open",
29 ...(summary.isDraft === true ? { isDraft: true } : {}),
29 updatedAt: summary.updatedAt ?? Option.none(),30 updatedAt: summary.updatedAt ?? Option.none(),
30 ...(summary.isCrossRepository !== undefined31 ...(summary.isCrossRepository !== undefined
31 ? { isCrossRepository: summary.isCrossRepository }32 ? { isCrossRepository: summary.isCrossRepository }
apps/server/src/sourceControl/azureDevOpsPullRequests.ts+3
...@@ -14,6 +14,7 @@ export interface NormalizedAzureDevOpsPullRequestRecord {...@@ -14,6 +14,7 @@ export interface NormalizedAzureDevOpsPullRequestRecord {
14 readonly baseRefName: string;14 readonly baseRefName: string;
15 readonly headRefName: string;15 readonly headRefName: string;
16 readonly state: "open" | "closed" | "merged";16 readonly state: "open" | "closed" | "merged";
17 readonly isDraft?: boolean;
17 readonly updatedAt: Option.Option<DateTime.Utc>;18 readonly updatedAt: Option.Option<DateTime.Utc>;
18}19}
1920
...@@ -35,6 +36,7 @@ const AzureDevOpsPullRequestSchema = Schema.Struct({...@@ -35,6 +36,7 @@ const AzureDevOpsPullRequestSchema = Schema.Struct({
35 sourceRefName: TrimmedNonEmptyString,36 sourceRefName: TrimmedNonEmptyString,
36 targetRefName: TrimmedNonEmptyString,37 targetRefName: TrimmedNonEmptyString,
37 status: Schema.String,38 status: Schema.String,
39 isDraft: Schema.optional(Schema.Boolean),
38 creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),40 creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
39 closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),41 closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
40 _links: Schema.optional(42 _links: Schema.optional(
...@@ -168,6 +170,7 @@ function normalizeAzureDevOpsPullRequestRecord(...@@ -168,6 +170,7 @@ function normalizeAzureDevOpsPullRequestRecord(
168 baseRefName: normalizeRefName(raw.targetRefName),170 baseRefName: normalizeRefName(raw.targetRefName),
169 headRefName: normalizeRefName(raw.sourceRefName),171 headRefName: normalizeRefName(raw.sourceRefName),
170 state: normalizeAzureDevOpsPullRequestState(raw.status),172 state: normalizeAzureDevOpsPullRequestState(raw.status),
173 ...(raw.isDraft === true ? { isDraft: true } : {}),
171 updatedAt: (raw.closedDate ?? Option.none()).pipe(174 updatedAt: (raw.closedDate ?? Option.none()).pipe(
172 Option.orElse(() => raw.creationDate ?? Option.none()),175 Option.orElse(() => raw.creationDate ?? Option.none()),
173 ),176 ),
apps/server/src/sourceControl/bitbucketPullRequests.ts+3
...@@ -10,6 +10,7 @@ export interface NormalizedBitbucketPullRequestRecord {...@@ -10,6 +10,7 @@ export interface NormalizedBitbucketPullRequestRecord {
10 readonly baseRefName: string;10 readonly baseRefName: string;
11 readonly headRefName: string;11 readonly headRefName: string;
12 readonly state: "open" | "closed" | "merged";12 readonly state: "open" | "closed" | "merged";
13 readonly isDraft?: boolean;
13 readonly updatedAt: Option.Option<DateTime.Utc>;14 readonly updatedAt: Option.Option<DateTime.Utc>;
14 readonly isCrossRepository?: boolean;15 readonly isCrossRepository?: boolean;
15 readonly headRepositoryNameWithOwner?: string | null;16 readonly headRepositoryNameWithOwner?: string | null;
...@@ -38,6 +39,7 @@ export const BitbucketPullRequestSchema = Schema.Struct({...@@ -38,6 +39,7 @@ export const BitbucketPullRequestSchema = Schema.Struct({
38 id: PositiveInt,39 id: PositiveInt,
39 title: TrimmedNonEmptyString,40 title: TrimmedNonEmptyString,
40 state: Schema.optional(Schema.NullOr(Schema.String)),41 state: Schema.optional(Schema.NullOr(Schema.String)),
42 draft: Schema.optional(Schema.Boolean),
41 updated_on: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),43 updated_on: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
42 links: Schema.Struct({44 links: Schema.Struct({
43 html: Schema.Struct({45 html: Schema.Struct({
...@@ -98,6 +100,7 @@ export function normalizeBitbucketPullRequestRecord(...@@ -98,6 +100,7 @@ export function normalizeBitbucketPullRequestRecord(
98 baseRefName: raw.destination.branch.name,100 baseRefName: raw.destination.branch.name,
99 headRefName: raw.source.branch.name,101 headRefName: raw.source.branch.name,
100 state: normalizeBitbucketPullRequestState(raw.state),102 state: normalizeBitbucketPullRequestState(raw.state),
103 ...(raw.draft === true ? { isDraft: true } : {}),
101 updatedAt: raw.updated_on ?? Option.none(),104 updatedAt: raw.updated_on ?? Option.none(),
102 ...(isCrossRepository ? { isCrossRepository: true } : {}),105 ...(isCrossRepository ? { isCrossRepository: true } : {}),
103 ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),106 ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),
apps/server/src/sourceControl/gitHubPullRequests.ts+3
...@@ -14,6 +14,7 @@ export interface NormalizedGitHubPullRequestRecord {...@@ -14,6 +14,7 @@ export interface NormalizedGitHubPullRequestRecord {
14 readonly baseRefName: string;14 readonly baseRefName: string;
15 readonly headRefName: string;15 readonly headRefName: string;
16 readonly state: "open" | "closed" | "merged";16 readonly state: "open" | "closed" | "merged";
17 readonly isDraft?: boolean;
17 readonly updatedAt: Option.Option<DateTime.Utc>;18 readonly updatedAt: Option.Option<DateTime.Utc>;
18 readonly isCrossRepository?: boolean;19 readonly isCrossRepository?: boolean;
19 readonly headRepositoryNameWithOwner?: string | null;20 readonly headRepositoryNameWithOwner?: string | null;
...@@ -27,6 +28,7 @@ const GitHubPullRequestSchema = Schema.Struct({...@@ -27,6 +28,7 @@ const GitHubPullRequestSchema = Schema.Struct({
27 baseRefName: TrimmedNonEmptyString,28 baseRefName: TrimmedNonEmptyString,
28 headRefName: TrimmedNonEmptyString,29 headRefName: TrimmedNonEmptyString,
29 state: Schema.optional(Schema.NullOr(Schema.String)),30 state: Schema.optional(Schema.NullOr(Schema.String)),
31 isDraft: Schema.optional(Schema.Boolean),
30 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),32 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),
31 updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),33 updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
32 isCrossRepository: Schema.optional(Schema.Boolean),34 isCrossRepository: Schema.optional(Schema.Boolean),
...@@ -93,6 +95,7 @@ function normalizeGitHubPullRequestRecord(...@@ -93,6 +95,7 @@ function normalizeGitHubPullRequestRecord(
93 baseRefName: raw.baseRefName,95 baseRefName: raw.baseRefName,
94 headRefName: raw.headRefName,96 headRefName: raw.headRefName,
95 state: normalizeGitHubPullRequestState(raw),97 state: normalizeGitHubPullRequestState(raw),
98 ...(raw.isDraft === true ? { isDraft: true } : {}),
96 updatedAt: raw.updatedAt ?? Option.none(),99 updatedAt: raw.updatedAt ?? Option.none(),
97 ...(typeof raw.isCrossRepository === "boolean"100 ...(typeof raw.isCrossRepository === "boolean"
98 ? { isCrossRepository: raw.isCrossRepository }101 ? { isCrossRepository: raw.isCrossRepository }
apps/server/src/sourceControl/gitLabMergeRequests.ts+4
...@@ -14,6 +14,7 @@ export interface NormalizedGitLabMergeRequestRecord {...@@ -14,6 +14,7 @@ export interface NormalizedGitLabMergeRequestRecord {
14 readonly baseRefName: string;14 readonly baseRefName: string;
15 readonly headRefName: string;15 readonly headRefName: string;
16 readonly state: "open" | "closed" | "merged";16 readonly state: "open" | "closed" | "merged";
17 readonly isDraft?: boolean;
17 readonly updatedAt: Option.Option<DateTime.Utc>;18 readonly updatedAt: Option.Option<DateTime.Utc>;
18 readonly isCrossRepository?: boolean;19 readonly isCrossRepository?: boolean;
19 readonly headRepositoryNameWithOwner?: string | null;20 readonly headRepositoryNameWithOwner?: string | null;
...@@ -41,6 +42,8 @@ const GitLabMergeRequestSchema = Schema.Struct({...@@ -41,6 +42,8 @@ const GitLabMergeRequestSchema = Schema.Struct({
41 source_branch: TrimmedNonEmptyString,42 source_branch: TrimmedNonEmptyString,
42 target_branch: TrimmedNonEmptyString,43 target_branch: TrimmedNonEmptyString,
43 state: Schema.optional(Schema.NullOr(Schema.String)),44 state: Schema.optional(Schema.NullOr(Schema.String)),
45 draft: Schema.optional(Schema.Boolean),
46 work_in_progress: Schema.optional(Schema.Boolean),
44 updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),47 updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
45 source_project_id: Schema.optional(Schema.NullOr(Schema.Number)),48 source_project_id: Schema.optional(Schema.NullOr(Schema.Number)),
46 target_project_id: Schema.optional(Schema.NullOr(Schema.Number)),49 target_project_id: Schema.optional(Schema.NullOr(Schema.Number)),
...@@ -108,6 +111,7 @@ function normalizeGitLabMergeRequestRecord(...@@ -108,6 +111,7 @@ function normalizeGitLabMergeRequestRecord(
108 baseRefName: raw.target_branch,111 baseRefName: raw.target_branch,
109 headRefName: raw.source_branch,112 headRefName: raw.source_branch,
110 state: normalizeGitLabMergeRequestState(raw.state),113 state: normalizeGitLabMergeRequestState(raw.state),
114 ...(raw.draft === true || raw.work_in_progress === true ? { isDraft: true } : {}),
111 updatedAt: raw.updated_at ?? Option.none(),115 updatedAt: raw.updated_at ?? Option.none(),
112 ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}),116 ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}),
113 ...(sourceProjectPath ? { headRepositoryNameWithOwner: sourceProjectPath } : {}),117 ...(sourceProjectPath ? { headRepositoryNameWithOwner: sourceProjectPath } : {}),
apps/web/src/components/Sidebar.tsx+1-1
...@@ -954,7 +954,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -954,7 +954,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
954 linkedPullRequestStatus,954 linkedPullRequestStatus,
955 });955 });
956 const prStatus = prStatusIndicator(pr, prProvider);956 const prStatus = prStatusIndicator(pr, prProvider);
957 const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined;957 const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state, pr.isDraft) : undefined;
958 useEffect(() => {958 useEffect(() => {
959 const nextSnapshot = nextThreadChangeRequestSnapshot({959 const nextSnapshot = nextThreadChangeRequestSnapshot({
960 threadBranch: thread.branch,960 threadBranch: thread.branch,
apps/web/src/components/ThreadStatusIndicators.test.ts+28
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10 resolveDisplayedThreadPrProvider,10 resolveDisplayedThreadPrProvider,
11 resolveThreadPr,11 resolveThreadPr,
12 settledPrHoverColorClass,12 settledPrHoverColorClass,
13 threadChangeRequestSnapshotsEqual,
13 threadChangeRequestSnapshotsAtom,14 threadChangeRequestSnapshotsAtom,
14 type ThreadChangeRequestSnapshot,15 type ThreadChangeRequestSnapshot,
15} from "./ThreadStatusIndicators";16} from "./ThreadStatusIndicators";
...@@ -559,6 +560,18 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {...@@ -559,6 +560,18 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {
559 });560 });
560 expect(displayed?.state).toBe("merged");561 expect(displayed?.state).toBe("merged");
561 });562 });
563
564 it("refreshes a cached snapshot when a pull request becomes ready", () => {
565 const readyPr = { ...mergedPr, state: "open" as const };
566 const draftPr = { ...readyPr, isDraft: true };
567
568 expect(
569 threadChangeRequestSnapshotsEqual(
570 snapshotFor(featureBranch, draftPr),
571 snapshotFor(featureBranch, readyPr),
572 ),
573 ).toBe(false);
574 });
562});575});
563576
564describe("threadChangeRequestSnapshotsAtom", () => {577describe("threadChangeRequestSnapshotsAtom", () => {
...@@ -600,6 +613,17 @@ describe("prStatusIndicator", () => {...@@ -600,6 +613,17 @@ describe("prStatusIndicator", () => {
600 "text-red-600",613 "text-red-600",
601 );614 );
602 });615 });
616
617 it("uses gray and draft wording for draft pull requests", () => {
618 const draftPr = status().pr;
619 if (!draftPr) throw new Error("Expected pull request fixture");
620
621 expect(prStatusIndicator({ ...draftPr, isDraft: true }, undefined)).toMatchObject({
622 label: "PR draft",
623 colorClass: "text-zinc-500 dark:text-zinc-400/80",
624 tooltipLead: "PR #42 - Draft",
625 });
626 });
603});627});
604628
605describe("settledPrHoverColorClass", () => {629describe("settledPrHoverColorClass", () => {
...@@ -610,4 +634,8 @@ describe("settledPrHoverColorClass", () => {...@@ -610,4 +634,8 @@ describe("settledPrHoverColorClass", () => {
610 ] as const)("restores the %s pull request color on row hover", (state, colorClass) => {634 ] as const)("restores the %s pull request color on row hover", (state, colorClass) => {
611 expect(settledPrHoverColorClass(state)).toContain(`group-hover/v2-row:${colorClass}`);635 expect(settledPrHoverColorClass(state)).toContain(`group-hover/v2-row:${colorClass}`);
612 });636 });
637
638 it("keeps draft pull requests gray on row hover", () => {
639 expect(settledPrHoverColorClass("open", true)).toContain("group-hover/v2-row:text-zinc-500");
640 });
613});641});
apps/web/src/components/ThreadStatusIndicators.tsx+17-6
...@@ -84,9 +84,15 @@ export function useLinkedThreadPullRequest(...@@ -84,9 +84,15 @@ export function useLinkedThreadPullRequest(
84 );84 );
85}85}
8686
87export function settledPrHoverColorClass(state: NonNullable<ThreadPr>["state"]): string {87export function settledPrHoverColorClass(
88 state: NonNullable<ThreadPr>["state"],
89 isDraft = false,
90): string {
88 switch (state) {91 switch (state) {
89 case "open":92 case "open":
93 if (isDraft) {
94 return "group-hover/v2-row:text-zinc-500 dark:group-hover/v2-row:text-zinc-400/80";
95 }
90 return "group-hover/v2-row:text-emerald-600 dark:group-hover/v2-row:text-emerald-300/90";96 return "group-hover/v2-row:text-emerald-600 dark:group-hover/v2-row:text-emerald-300/90";
91 case "merged":97 case "merged":
92 return "group-hover/v2-row:text-violet-600 dark:group-hover/v2-row:text-violet-300/90";98 return "group-hover/v2-row:text-violet-600 dark:group-hover/v2-row:text-violet-300/90";
...@@ -99,12 +105,13 @@ export function prStatusIndicator(...@@ -99,12 +105,13 @@ export function prStatusIndicator(
99 pr: ThreadPr,105 pr: ThreadPr,
100 provider: VcsStatusResult["sourceControlProvider"] | null | undefined,106 provider: VcsStatusResult["sourceControlProvider"] | null | undefined,
101): PrStatusIndicator | null {107): PrStatusIndicator | null {
102 function formatPrState(state: NonNullable<ThreadPr>["state"]): string {108 function formatPrState(pr: NonNullable<ThreadPr>): string {
103 return state.charAt(0).toUpperCase() + state.slice(1);109 if (pr.state === "open" && pr.isDraft === true) return "Draft";
110 return pr.state.charAt(0).toUpperCase() + pr.state.slice(1);
104 }111 }
105112
106 function formatPrStatusLead(pr: NonNullable<ThreadPr>, changeRequestShortName: string): string {113 function formatPrStatusLead(pr: NonNullable<ThreadPr>, changeRequestShortName: string): string {
107 return `${changeRequestShortName} #${pr.number} - ${formatPrState(pr.state)}`;114 return `${changeRequestShortName} #${pr.number} - ${formatPrState(pr)}`;
108 }115 }
109 if (!pr) return null;116 if (!pr) return null;
110 const presentation = resolveChangeRequestPresentation(provider);117 const presentation = resolveChangeRequestPresentation(provider);
...@@ -113,9 +120,12 @@ export function prStatusIndicator(...@@ -113,9 +120,12 @@ export function prStatusIndicator(
113 const tooltip = `${tooltipLead}: ${pr.title}`;120 const tooltip = `${tooltipLead}: ${pr.title}`;
114121
115 if (pr.state === "open") {122 if (pr.state === "open") {
123 const isDraft = pr.isDraft === true;
116 return {124 return {
117 label: `${presentation.shortName} open`,125 label: `${presentation.shortName} ${isDraft ? "draft" : "open"}`,
118 colorClass: "text-emerald-600 dark:text-emerald-300/90",126 colorClass: isDraft
127 ? "text-zinc-500 dark:text-zinc-400/80"
128 : "text-emerald-600 dark:text-emerald-300/90",
119 tooltip,129 tooltip,
120 tooltipLead,130 tooltipLead,
121 tooltipTitle: pr.title,131 tooltipTitle: pr.title,
...@@ -230,6 +240,7 @@ export function threadChangeRequestSnapshotsEqual(...@@ -230,6 +240,7 @@ export function threadChangeRequestSnapshotsEqual(
230 left.pr.baseRef === right.pr.baseRef &&240 left.pr.baseRef === right.pr.baseRef &&
231 left.pr.headRef === right.pr.headRef &&241 left.pr.headRef === right.pr.headRef &&
232 left.pr.state === right.pr.state &&242 left.pr.state === right.pr.state &&
243 left.pr.isDraft === right.pr.isDraft &&
233 (left.pr.updatedAt ?? null) === (right.pr.updatedAt ?? null) &&244 (left.pr.updatedAt ?? null) === (right.pr.updatedAt ?? null) &&
234 sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) &&245 sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) &&
235 linkedPullRequestsEqual(left.linkedPullRequest, right.linkedPullRequest)246 linkedPullRequestsEqual(left.linkedPullRequest, right.linkedPullRequest)
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+8-2
...@@ -625,7 +625,13 @@ export function PullRequestDetailPanel({...@@ -625,7 +625,13 @@ export function PullRequestDetailPanel({
625 () =>625 () =>
626 resolvedCoreDetail === null || sharedSummary === null || sharedSummary === resolvedCoreDetail626 resolvedCoreDetail === null || sharedSummary === null || sharedSummary === resolvedCoreDetail
627 ? resolvedCoreDetail627 ? resolvedCoreDetail
628 : { ...resolvedCoreDetail, ...sharedSummary },628 : {
629 ...resolvedCoreDetail,
630 ...sharedSummary,
631 // A summary may come from an older server that does not report draft state. Keep the
632 // detail's required value instead of making the complete detail shape partial.
633 isDraft: sharedSummary.isDraft ?? resolvedCoreDetail.isDraft,
634 },
629 [resolvedCoreDetail, sharedSummary],635 [resolvedCoreDetail, sharedSummary],
630 );636 );
631 const activity = activityQuery.data;637 const activity = activityQuery.data;
...@@ -1297,7 +1303,7 @@ export function PullRequestDetailPanel({...@@ -1297,7 +1303,7 @@ export function PullRequestDetailPanel({
1297 !conflicting &&1303 !conflicting &&
1298 allowedMergeMethods.length > 1;1304 allowedMergeMethods.length > 1;
1299 // The pull request number carries this state in the overview and the right-panel tab mirrors1305 // The pull request number carries this state in the overview and the right-panel tab mirrors
1300 // it. The conflict action is separate from this state: an open pull request remains green.1306 // it. Conflicts take the action slot while they need a person, but do not change the PR state.
1301 const statePresentation = detail1307 const statePresentation = detail
1302 ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft })1308 ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft })
1303 : null;1309 : null;
apps/web/src/components/pullRequest/pullRequestPresentation.tsx+3-3
...@@ -33,9 +33,9 @@ interface StatePresentation {...@@ -33,9 +33,9 @@ interface StatePresentation {
33}33}
3434
35/**35/**
36 * How a pull request's state reads on this page. Open, closed and merged use the same ink as36 * How a pull request's state reads on this page. Open, closed, merged, and draft use the same
37 * the thread badge in `ThreadStatusIndicators`, so one pull request cannot look like two37 * ink as the thread badge in `ThreadStatusIndicators`, so one pull request cannot look like two
38 * different things in two places; draft and conflicts are states that badge never shows.38 * different things in two places.
39 *39 *
40 * Draft outranks conflicts: a draft is not heading for a merge yet, so conflicts only surface40 * Draft outranks conflicts: a draft is not heading for a merge yet, so conflicts only surface
41 * once it is real work.41 * once it is real work.
packages/client-runtime/src/state/pullRequests.ts+1
...@@ -69,6 +69,7 @@ export function pullRequestDetailToVcsStatus(...@@ -69,6 +69,7 @@ export function pullRequestDetailToVcsStatus(
69 baseRef: detail.baseBranch,69 baseRef: detail.baseBranch,
70 headRef: detail.headBranch,70 headRef: detail.headBranch,
71 state: detail.state,71 state: detail.state,
72 ...(detail.isDraft === true ? { isDraft: true } : {}),
72 updatedAt: detail.updatedAt,73 updatedAt: detail.updatedAt,
73 };74 };
74}75}
packages/contracts/src/git.ts+2
...@@ -198,6 +198,8 @@ const VcsStatusChangeRequest = Schema.Struct({...@@ -198,6 +198,8 @@ const VcsStatusChangeRequest = Schema.Struct({
198 baseRef: TrimmedNonEmptyStringSchema,198 baseRef: TrimmedNonEmptyStringSchema,
199 headRef: TrimmedNonEmptyStringSchema,199 headRef: TrimmedNonEmptyStringSchema,
200 state: VcsStatusChangeRequestState,200 state: VcsStatusChangeRequestState,
201 /** Optional for compatibility with older servers and providers. */
202 isDraft: Schema.optional(Schema.Boolean),
201 /**203 /**
202 * Last provider-side activity (ISO). For a merged/closed change request204 * Last provider-side activity (ISO). For a merged/closed change request
203 * this bounds when it reached that state, so clients can tell a PR that205 * this bounds when it reached that state, so clients can tell a PR that
packages/contracts/src/pullRequest.ts+2
...@@ -634,6 +634,8 @@ export const PullRequestSummary = Schema.Struct({...@@ -634,6 +634,8 @@ export const PullRequestSummary = Schema.Struct({
634 title: TrimmedNonEmptyString,634 title: TrimmedNonEmptyString,
635 url: TrimmedNonEmptyString,635 url: TrimmedNonEmptyString,
636 state: PullRequestState,636 state: PullRequestState,
637 /** Present when the host says the open pull request is still a draft. */
638 isDraft: Schema.optional(Schema.Boolean),
637 headBranch: TrimmedNonEmptyString,639 headBranch: TrimmedNonEmptyString,
638 baseBranch: TrimmedNonEmptyString,640 baseBranch: TrimmedNonEmptyString,
639 updatedAt: IsoDateTime,641 updatedAt: IsoDateTime,
packages/contracts/src/sourceControl.ts+2
...@@ -29,6 +29,8 @@ export const ChangeRequest = Schema.Struct({...@@ -29,6 +29,8 @@ export const ChangeRequest = Schema.Struct({
29 baseRefName: TrimmedNonEmptyString,29 baseRefName: TrimmedNonEmptyString,
30 headRefName: TrimmedNonEmptyString,30 headRefName: TrimmedNonEmptyString,
31 state: ChangeRequestState,31 state: ChangeRequestState,
32 /** Present when the provider can tell that an open change request is still a draft. */
33 isDraft: Schema.optional(Schema.Boolean),
32 updatedAt: Schema.Option(Schema.DateTimeUtc),34 updatedAt: Schema.Option(Schema.DateTimeUtc),
33 isCrossRepository: Schema.optional(Schema.Boolean),35 isCrossRepository: Schema.optional(Schema.Boolean),
34 headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),36 headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
scripts/build-desktop-artifact.ts+16-1
...@@ -2863,7 +2863,7 @@ export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function...@@ -2863,7 +2863,7 @@ export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function
2863 readonly arch: typeof BuildArch.Type;2863 readonly arch: typeof BuildArch.Type;
2864}) {2864}) {
2865 const fs = yield* FileSystem.FileSystem;2865 const fs = yield* FileSystem.FileSystem;
2866 yield* Effect.tryPromise({2866 const archiveStream = yield* Effect.tryPromise({
2867 try: () =>2867 try: () =>
2868 createPackageWithOptions(input.sourceDir, input.asarPath, {2868 createPackageWithOptions(input.sourceDir, input.asarPath, {
2869 dot: true,2869 dot: true,
...@@ -2872,6 +2872,21 @@ export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function...@@ -2872,6 +2872,21 @@ export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function
2872 }),2872 }),
2873 catch: (cause) => new WindowsServerSidecarPackError({ asarPath: input.asarPath, cause }),2873 catch: (cause) => new WindowsServerSidecarPackError({ asarPath: input.asarPath, cause }),
2874 });2874 });
2875 yield* Effect.tryPromise({
2876 try: () =>
2877 new Promise<void>((resolve, reject) => {
2878 const stream = archiveStream as NodeJS.WritableStream & {
2879 readonly writableFinished?: boolean;
2880 };
2881 if (stream.writableFinished === true) {
2882 resolve();
2883 return;
2884 }
2885 stream.once("finish", resolve);
2886 stream.once("error", reject);
2887 }),
2888 catch: (cause) => new WindowsServerSidecarPackError({ asarPath: input.asarPath, cause }),
2889 });
2875 const unpackedDirPath = `${input.asarPath}.unpacked`;2890 const unpackedDirPath = `${input.asarPath}.unpacked`;
2876 if (!(yield* fs.exists(unpackedDirPath))) {2891 if (!(yield* fs.exists(unpackedDirPath))) {
2877 return yield* new WindowsServerSidecarPackError({2892 return yield* new WindowsServerSidecarPackError({