authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-28 11:33:43-08:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-02-28 11:33:43-08:00
logb500106fde0f90344174cf113d439777c6f3b266
treef9413d88322916a8456614e8e89e07d52ea2b435
parentd48b42c0174f7ed26de8fdd1b0f4f75c82c71fe7
parent7f2a95fe14c18b3feba039df6b88c6c47a846d04
signature Signed by PGP key B5690EEEBB952194

Merge pull request #122 from pingdotgg/codething/10276f55


10 files changed, 345 insertions(+), 49 deletions(-)

apps/server/src/git/Layers/GitCore.ts+1-1
......@@ -393,7 +393,7 @@ const makeGitCore = Effect.gen(function* () {
393393 hasUpstream: details.hasUpstream,
394394 aheadCount: details.aheadCount,
395395 behindCount: details.behindCount,
396 openPr: null,
396 pr: null,
397397 })),
398398 );
399399
apps/server/src/git/Layers/GitManager.test.ts+90-4
......@@ -297,7 +297,7 @@ function makeManager(input?: {
297297const GitManagerTestLayer = Layer.provideMerge(GitServiceLive, NodeServices.layer);
298298
299299it.layer(GitManagerTestLayer)("GitManager", (it) => {
300 it.effect("status includes open PR metadata when branch already has an open PR", () =>
300 it.effect("status includes PR metadata when branch already has an open PR", () =>
301301 Effect.gen(function* () {
302302 const repoDir = yield* makeTempDir("t3code-git-manager-");
303303 yield* initRepo(repoDir);
......@@ -324,17 +324,103 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
324324
325325 const status = yield* manager.status({ cwd: repoDir });
326326 expect(status.branch).toBe("feature/status-open-pr");
327 expect(status.openPr).toEqual({
327 expect(status.pr).toEqual({
328328 number: 13,
329329 title: "Existing PR",
330330 url: "https://github.com/pingdotgg/codething-mvp/pull/13",
331331 baseBranch: "main",
332332 headBranch: "feature/status-open-pr",
333 state: "open",
333334 });
334335 }),
335336 );
336337
337 it.effect("status is resilient to gh lookup failures and returns openPr null", () =>
338 it.effect("status returns merged PR state when latest PR was merged", () =>
339 Effect.gen(function* () {
340 const repoDir = yield* makeTempDir("t3code-git-manager-");
341 yield* initRepo(repoDir);
342 yield* runGit(repoDir, ["checkout", "-b", "feature/status-merged-pr"]);
343
344 const { manager } = yield* makeManager({
345 ghScenario: {
346 prListSequence: [
347 JSON.stringify([
348 {
349 number: 22,
350 title: "Merged PR",
351 url: "https://github.com/pingdotgg/codething-mvp/pull/22",
352 baseRefName: "main",
353 headRefName: "feature/status-merged-pr",
354 state: "MERGED",
355 mergedAt: "2026-01-30T10:00:00Z",
356 updatedAt: "2026-01-30T10:00:00Z",
357 },
358 ]),
359 ],
360 },
361 });
362
363 const status = yield* manager.status({ cwd: repoDir });
364 expect(status.branch).toBe("feature/status-merged-pr");
365 expect(status.pr).toEqual({
366 number: 22,
367 title: "Merged PR",
368 url: "https://github.com/pingdotgg/codething-mvp/pull/22",
369 baseBranch: "main",
370 headBranch: "feature/status-merged-pr",
371 state: "merged",
372 });
373 }),
374 );
375
376 it.effect("status prefers open PR when merged PR has newer updatedAt", () =>
377 Effect.gen(function* () {
378 const repoDir = yield* makeTempDir("t3code-git-manager-");
379 yield* initRepo(repoDir);
380 yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-over-merged"]);
381
382 const { manager } = yield* makeManager({
383 ghScenario: {
384 prListSequence: [
385 JSON.stringify([
386 {
387 number: 45,
388 title: "Merged PR",
389 url: "https://github.com/pingdotgg/codething-mvp/pull/45",
390 baseRefName: "main",
391 headRefName: "feature/status-open-over-merged",
392 state: "MERGED",
393 mergedAt: "2026-01-31T10:00:00Z",
394 updatedAt: "2026-02-01T10:00:00Z",
395 },
396 {
397 number: 46,
398 title: "Open PR",
399 url: "https://github.com/pingdotgg/codething-mvp/pull/46",
400 baseRefName: "main",
401 headRefName: "feature/status-open-over-merged",
402 state: "OPEN",
403 updatedAt: "2026-01-30T10:00:00Z",
404 },
405 ]),
406 ],
407 },
408 });
409
410 const status = yield* manager.status({ cwd: repoDir });
411 expect(status.branch).toBe("feature/status-open-over-merged");
412 expect(status.pr).toEqual({
413 number: 46,
414 title: "Open PR",
415 url: "https://github.com/pingdotgg/codething-mvp/pull/46",
416 baseBranch: "main",
417 headBranch: "feature/status-open-over-merged",
418 state: "open",
419 });
420 }),
421 );
422
423 it.effect("status is resilient to gh lookup failures and returns pr null", () =>
338424 Effect.gen(function* () {
339425 const repoDir = yield* makeTempDir("t3code-git-manager-");
340426 yield* initRepo(repoDir);
......@@ -354,7 +440,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
354440
355441 const status = yield* manager.status({ cwd: repoDir });
356442 expect(status.branch).toBe("feature/status-no-gh");
357 expect(status.openPr).toBeNull();
443 expect(status.pr).toBeNull();
358444 }),
359445 );
360446
apps/server/src/git/Layers/GitManager.ts+111-7
......@@ -16,6 +16,62 @@ interface OpenPrInfo {
1616 headRefName: string;
1717}
1818
19interface PullRequestInfo extends OpenPrInfo {
20 state: "open" | "closed" | "merged";
21 updatedAt: string | null;
22}
23
24function parsePullRequestList(raw: unknown): PullRequestInfo[] {
25 if (!Array.isArray(raw)) return [];
26
27 const parsed: PullRequestInfo[] = [];
28 for (const entry of raw) {
29 if (!entry || typeof entry !== "object") continue;
30 const record = entry as Record<string, unknown>;
31 const number = record.number;
32 const title = record.title;
33 const url = record.url;
34 const baseRefName = record.baseRefName;
35 const headRefName = record.headRefName;
36 const state = record.state;
37 const mergedAt = record.mergedAt;
38 const updatedAt = record.updatedAt;
39 if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) {
40 continue;
41 }
42 if (
43 typeof title !== "string" ||
44 typeof url !== "string" ||
45 typeof baseRefName !== "string" ||
46 typeof headRefName !== "string"
47 ) {
48 continue;
49 }
50
51 let normalizedState: "open" | "closed" | "merged";
52 if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") {
53 normalizedState = "merged";
54 } else if (state === "OPEN" || state === undefined || state === null) {
55 normalizedState = "open";
56 } else if (state === "CLOSED") {
57 normalizedState = "closed";
58 } else {
59 continue;
60 }
61
62 parsed.push({
63 number,
64 title,
65 url,
66 baseRefName,
67 headRefName,
68 state: normalizedState,
69 updatedAt: typeof updatedAt === "string" && updatedAt.trim().length > 0 ? updatedAt : null,
70 });
71 }
72 return parsed;
73}
74
1975function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError {
2076 return new GitManagerError({
2177 operation,
......@@ -79,12 +135,13 @@ function extractBranchFromRef(ref: string): string {
79135 return normalized.slice(firstSlash + 1).trim();
80136}
81137
82function toStatusOpenPr(pr: OpenPrInfo): {
138function toStatusPr(pr: PullRequestInfo): {
83139 number: number;
84140 title: string;
85141 url: string;
86142 baseBranch: string;
87143 headBranch: string;
144 state: "open" | "closed" | "merged";
88145} {
89146 return {
90147 number: pr.number,
......@@ -92,6 +149,7 @@ function toStatusOpenPr(pr: OpenPrInfo): {
92149 url: pr.url,
93150 baseBranch: pr.baseRefName,
94151 headBranch: pr.headRefName,
152 state: pr.state,
95153 };
96154}
97155
......@@ -123,10 +181,56 @@ export const makeGitManager = Effect.gen(function* () {
123181 url: first.url,
124182 baseRefName: first.baseRefName,
125183 headRefName: first.headRefName,
126 } satisfies OpenPrInfo;
184 state: "open",
185 updatedAt: null,
186 } satisfies PullRequestInfo;
127187 }),
128188 );
129189
190 const findLatestPr = (cwd: string, branch: string) =>
191 Effect.gen(function* () {
192 const stdout = yield* gitHubCli
193 .execute({
194 cwd,
195 args: [
196 "pr",
197 "list",
198 "--head",
199 branch,
200 "--state",
201 "all",
202 "--limit",
203 "20",
204 "--json",
205 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt",
206 ],
207 })
208 .pipe(Effect.map((result) => result.stdout));
209
210 const raw = stdout.trim();
211 if (raw.length === 0) {
212 return null;
213 }
214
215 const parsedJson = yield* Effect.try({
216 try: () => JSON.parse(raw) as unknown,
217 catch: (cause) =>
218 gitManagerError("findLatestPr", "GitHub CLI returned invalid PR list JSON.", cause),
219 });
220
221 const parsed = parsePullRequestList(parsedJson).toSorted((a, b) => {
222 const left = a.updatedAt ? Date.parse(a.updatedAt) : 0;
223 const right = b.updatedAt ? Date.parse(b.updatedAt) : 0;
224 return right - left;
225 });
226
227 const latestOpenPr = parsed.find((pr) => pr.state === "open");
228 if (latestOpenPr) {
229 return latestOpenPr;
230 }
231 return parsed[0] ?? null;
232 });
233
130234 const resolveBaseBranch = (cwd: string, branch: string, upstreamRef: string | null) =>
131235 Effect.gen(function* () {
132236 const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`);
......@@ -258,10 +362,10 @@ export const makeGitManager = Effect.gen(function* () {
258362 const status: GitManagerShape["status"] = Effect.fnUntraced(function* (input) {
259363 const details = yield* gitCore.statusDetails(input.cwd);
260364
261 const openPr =
262 details.branch && details.hasUpstream
263 ? yield* findOpenPr(input.cwd, details.branch).pipe(
264 Effect.map((pr) => (pr ? toStatusOpenPr(pr) : null)),
365 const pr =
366 details.branch !== null
367 ? yield* findLatestPr(input.cwd, details.branch).pipe(
368 Effect.map((latest) => (latest ? toStatusPr(latest) : null)),
265369 Effect.catch(() => Effect.succeed(null)),
266370 )
267371 : null;
......@@ -273,7 +377,7 @@ export const makeGitManager = Effect.gen(function* () {
273377 hasUpstream: details.hasUpstream,
274378 aheadCount: details.aheadCount,
275379 behindCount: details.behindCount,
276 openPr,
380 pr,
277381 };
278382 });
279383
apps/server/src/git/Services/GitCore.ts+1-1
......@@ -24,7 +24,7 @@ import type {
2424
2525import type { GitCommandError } from "../Errors.ts";
2626
27export interface GitStatusDetails extends Omit<GitStatusResult, "openPr"> {
27export interface GitStatusDetails extends Omit<GitStatusResult, "pr"> {
2828 upstreamRef: string | null;
2929}
3030
apps/server/src/wsServer.test.ts+1-1
......@@ -1521,7 +1521,7 @@ describe("WebSocket Server", () => {
15211521 hasUpstream: false,
15221522 aheadCount: 0,
15231523 behindCount: 0,
1524 openPr: null,
1524 pr: null,
15251525 };
15261526
15271527 const status = vi.fn(() => Effect.succeed(statusResult));
apps/web/src/components/GitActionsControl.logic.test.ts+23-16
......@@ -20,7 +20,7 @@ function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult {
2020 hasUpstream: true,
2121 aheadCount: 0,
2222 behindCount: 0,
23 openPr: null,
23 pr: null,
2424 ...overrides,
2525 };
2626}
......@@ -29,12 +29,13 @@ describe("when: branch is clean and has an open PR", () => {
2929 it("resolveQuickAction opens the existing PR", () => {
3030 const quick = resolveQuickAction(
3131 status({
32 openPr: {
32 pr: {
3333 number: 10,
3434 title: "Open PR",
3535 url: "https://example.com/pr/10",
3636 baseBranch: "main",
3737 headBranch: "feature/test",
38 state: "open",
3839 },
3940 }),
4041 false,
......@@ -45,12 +46,13 @@ describe("when: branch is clean and has an open PR", () => {
4546 it("buildMenuItems disables commit/push and enables open PR", () => {
4647 const items = buildMenuItems(
4748 status({
48 openPr: {
49 pr: {
4950 number: 11,
5051 title: "Existing PR",
5152 url: "https://example.com/pr/11",
5253 baseBranch: "main",
5354 headBranch: "feature/test",
55 state: "open",
5456 },
5557 }),
5658 false,
......@@ -147,12 +149,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => {
147149 const quick = resolveQuickAction(
148150 status({
149151 aheadCount: 3,
150 openPr: {
152 pr: {
151153 number: 13,
152154 title: "Open PR",
153155 url: "https://example.com/pr/13",
154156 baseBranch: "main",
155157 headBranch: "feature/test",
158 state: "open",
156159 },
157160 }),
158161 false,
......@@ -164,12 +167,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => {
164167 const items = buildMenuItems(
165168 status({
166169 aheadCount: 2,
167 openPr: {
170 pr: {
168171 number: 12,
169172 title: "Existing PR",
170173 url: "https://example.com/pr/12",
171174 baseBranch: "main",
172175 headBranch: "feature/test",
176 state: "open",
173177 },
174178 }),
175179 false,
......@@ -204,7 +208,7 @@ describe("when: branch is clean, ahead, and has an open PR", () => {
204208
205209describe("when: branch is clean, ahead, and has no open PR", () => {
206210 it("resolveQuickAction pushes and creates a PR", () => {
207 const quick = resolveQuickAction(status({ aheadCount: 2, openPr: null }), false);
211 const quick = resolveQuickAction(status({ aheadCount: 2, pr: null }), false);
208212 assert.deepInclude(quick, {
209213 kind: "run_action",
210214 action: "commit_push_pr",
......@@ -213,7 +217,7 @@ describe("when: branch is clean, ahead, and has no open PR", () => {
213217 });
214218
215219 it("buildMenuItems enables push and create PR, with commit disabled", () => {
216 const items = buildMenuItems(status({ aheadCount: 2, openPr: null }), false);
220 const items = buildMenuItems(status({ aheadCount: 2, pr: null }), false);
217221 assert.deepEqual(items, [
218222 {
219223 id: "commit",
......@@ -246,14 +250,14 @@ describe("when: branch is clean, ahead, and has no open PR", () => {
246250describe("when: branch is clean, up to date, and has no open PR", () => {
247251 it("resolveQuickAction returns disabled no-action state", () => {
248252 const quick = resolveQuickAction(
249 status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, openPr: null }),
253 status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, pr: null }),
250254 false,
251255 );
252256 assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true });
253257 });
254258
255259 it("buildMenuItems disables commit, push, and create PR", () => {
256 const items = buildMenuItems(status({ aheadCount: 0, behindCount: 0, openPr: null }), false);
260 const items = buildMenuItems(status({ aheadCount: 0, behindCount: 0, pr: null }), false);
257261 assert.deepEqual(items, [
258262 {
259263 id: "commit",
......@@ -290,7 +294,7 @@ describe("when: branch is behind upstream", () => {
290294 });
291295
292296 it("buildMenuItems disables push and create PR", () => {
293 const items = buildMenuItems(status({ behindCount: 1, openPr: null }), false);
297 const items = buildMenuItems(status({ behindCount: 1, pr: null }), false);
294298 assert.deepEqual(items, [
295299 {
296300 id: "commit",
......@@ -346,12 +350,13 @@ describe("when: working tree has local changes", () => {
346350 const quick = resolveQuickAction(
347351 status({
348352 hasWorkingTreeChanges: true,
349 openPr: {
353 pr: {
350354 number: 16,
351355 title: "Existing PR",
352356 url: "https://example.com/pr/16",
353357 baseBranch: "main",
354358 headBranch: "feature/test",
359 state: "open",
355360 },
356361 }),
357362 false,
......@@ -411,7 +416,7 @@ describe("when: on default branch without open PR", () => {
411416
412417 it("resolveQuickAction returns push when branch is ahead", () => {
413418 const quick = resolveQuickAction(
414 status({ branch: "main", aheadCount: 2, openPr: null }),
419 status({ branch: "main", aheadCount: 2, pr: null }),
415420 false,
416421 true,
417422 );
......@@ -511,7 +516,7 @@ describe("when: HEAD is detached and there are no local changes", () => {
511516describe("when: branch has no upstream configured", () => {
512517 it("resolveQuickAction returns disabled commit state when clean and no commits are ahead", () => {
513518 const quick = resolveQuickAction(
514 status({ hasUpstream: false, openPr: null, aheadCount: 0 }),
519 status({ hasUpstream: false, pr: null, aheadCount: 0 }),
515520 false,
516521 );
517522 assert.deepInclude(quick, {
......@@ -526,12 +531,13 @@ describe("when: branch has no upstream configured", () => {
526531 status({
527532 hasUpstream: false,
528533 aheadCount: 0,
529 openPr: {
534 pr: {
530535 number: 14,
531536 title: "Existing PR",
532537 url: "https://example.com/pr/14",
533538 baseBranch: "main",
534539 headBranch: "feature/test",
540 state: "open",
535541 },
536542 }),
537543 false,
......@@ -548,12 +554,13 @@ describe("when: branch has no upstream configured", () => {
548554 status({
549555 hasUpstream: false,
550556 aheadCount: 1,
551 openPr: {
557 pr: {
552558 number: 15,
553559 title: "Existing PR",
554560 url: "https://example.com/pr/15",
555561 baseBranch: "main",
556562 headBranch: "feature/test",
563 state: "open",
557564 },
558565 }),
559566 false,
......@@ -568,7 +575,7 @@ describe("when: branch has no upstream configured", () => {
568575
569576 it("buildMenuItems disables push and create PR when no commits are ahead", () => {
570577 const items = buildMenuItems(
571 status({ hasUpstream: false, openPr: null, aheadCount: 0 }),
578 status({ hasUpstream: false, pr: null, aheadCount: 0 }),
572579 false,
573580 );
574581 assert.deepEqual(items, [
apps/web/src/components/GitActionsControl.logic.ts+2-2
......@@ -108,7 +108,7 @@ export function buildMenuItems(
108108
109109 const hasBranch = gitStatus.branch !== null;
110110 const hasChanges = gitStatus.hasWorkingTreeChanges;
111 const hasOpenPr = gitStatus.openPr !== null;
111 const hasOpenPr = gitStatus.pr?.state === "open";
112112 const isBehind = gitStatus.behindCount > 0;
113113 const canCommit = !isBusy && hasChanges;
114114 const canPush = !isBusy && hasBranch && !hasChanges && !isBehind && gitStatus.aheadCount > 0;
......@@ -178,7 +178,7 @@ export function resolveQuickAction(
178178
179179 const hasBranch = gitStatus.branch !== null;
180180 const hasChanges = gitStatus.hasWorkingTreeChanges;
181 const hasOpenPr = gitStatus.openPr !== null;
181 const hasOpenPr = gitStatus.pr?.state === "open";
182182 const isAhead = gitStatus.aheadCount > 0;
183183 const isBehind = gitStatus.behindCount > 0;
184184 const isDiverged = isAhead && isBehind;
apps/web/src/components/GitActionsControl.tsx+7-5
......@@ -58,7 +58,7 @@ function getMenuActionDisabledReason(
5858
5959 const hasBranch = gitStatus.branch !== null;
6060 const hasChanges = gitStatus.hasWorkingTreeChanges;
61 const hasOpenPr = gitStatus.openPr !== null;
61 const hasOpenPr = gitStatus.pr?.state === "open";
6262 const isAhead = gitStatus.aheadCount > 0;
6363 const isBehind = gitStatus.behindCount > 0;
6464
......@@ -219,7 +219,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
219219 });
220220 return;
221221 }
222 const prUrl = gitStatusForActions?.openPr?.url ?? null;
222 const prUrl = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : null;
223223 if (!prUrl) {
224224 toastManager.add({
225225 type: "error",
......@@ -236,7 +236,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
236236 data: threadToastData,
237237 });
238238 });
239 }, [gitStatusForActions?.openPr?.url, threadToastData]);
239 }, [gitStatusForActions?.pr?.state, gitStatusForActions?.pr?.url, threadToastData]);
240240 const runGitActionWithToast = useCallback(
241241 async ({
242242 action,
......@@ -295,7 +295,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
295295 stopProgressUpdates();
296296 const resultToast = summarizeGitResult(result);
297297
298 const prUrl = result.pr.url ?? gitStatusForActions?.openPr?.url;
298 const existingOpenPrUrl =
299 gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : undefined;
300 const prUrl = result.pr.url ?? existingOpenPrUrl;
299301 const shouldOfferPushCta = action === "commit" && result.commit.status === "created";
300302 const shouldOfferOpenPrCta =
301303 (action === "commit_push" || action === "commit_push_pr") && !!prUrl && !isDefaultBranch;
......@@ -365,7 +367,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
365367 [
366368 gitStatusForActions?.branch,
367369 gitStatusForActions?.hasWorkingTreeChanges,
368 gitStatusForActions?.openPr?.url,
370 gitStatusForActions?.pr,
369371 isDefaultBranch,
370372 maybeConfirmPushToDefaultBranch,
371373 runImmediateGitActionMutation,
apps/web/src/components/Sidebar.tsx+96-3
......@@ -1,12 +1,13 @@
1import { ChevronRightIcon, FolderIcon, TerminalIcon } from "lucide-react";
1import { ChevronRightIcon, FolderIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react";
22import { useCallback, useEffect, useMemo, useState } from "react";
33import {
44 DEFAULT_MODEL,
55 ProjectId,
66 ThreadId,
7 type GitStatusResult,
78 type ResolvedKeybindingsConfig,
89} from "@t3tools/contracts";
9import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
10import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
1011import { useNavigate, useParams } from "@tanstack/react-router";
1112import { useAppSettings } from "../appSettings";
1213import { isElectron } from "../env";
......@@ -16,7 +17,7 @@ import { useStore } from "../store";
1617import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";
1718import { type Thread } from "../types";
1819import { derivePendingApprovals } from "../session-logic";
19import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";
20import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";
2021import { serverConfigQueryOptions } from "../lib/serverReactQuery";
2122import { readNativeApi } from "../nativeApi";
2223import { toastManager } from "./ui/toast";
......@@ -63,6 +64,13 @@ interface TerminalStatusIndicator {
6364 pulse: boolean;
6465}
6566
67interface PrStatusIndicator {
68 label: "PR open" | "PR closed" | "PR merged";
69 colorClass: string;
70}
71
72type ThreadPrState = NonNullable<GitStatusResult["pr"]>["state"] | null;
73
6674function hasUnseenCompletion(thread: Thread): boolean {
6775 if (!thread.latestTurn?.completedAt) return false;
6876 const completedAt = Date.parse(thread.latestTurn.completedAt);
......@@ -125,6 +133,28 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null
125133 };
126134}
127135
136function prStatusIndicator(prState: ThreadPrState): PrStatusIndicator | null {
137 if (prState === "open") {
138 return {
139 label: "PR open",
140 colorClass: "text-emerald-600 dark:text-emerald-300/90",
141 };
142 }
143 if (prState === "closed") {
144 return {
145 label: "PR closed",
146 colorClass: "text-zinc-500 dark:text-zinc-400/80",
147 };
148 }
149 if (prState === "merged") {
150 return {
151 label: "PR merged",
152 colorClass: "text-violet-600 dark:text-violet-300/90",
153 };
154 }
155 return null;
156}
157
128158function T3Wordmark() {
129159 return (
130160 <svg
......@@ -210,6 +240,58 @@ export default function Sidebar() {
210240 }
211241 return map;
212242 }, [state.threads]);
243 const projectCwdById = useMemo(
244 () => new Map(state.projects.map((project) => [project.id, project.cwd] as const)),
245 [state.projects],
246 );
247 const threadGitTargets = useMemo(
248 () =>
249 state.threads.map((thread) => ({
250 threadId: thread.id,
251 branch: thread.branch,
252 cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null,
253 })),
254 [projectCwdById, state.threads],
255 );
256 const threadGitStatusCwds = useMemo(
257 () =>
258 [
259 ...new Set(
260 threadGitTargets
261 .filter((target) => target.branch !== null)
262 .map((target) => target.cwd)
263 .filter((cwd): cwd is string => cwd !== null),
264 ),
265 ],
266 [threadGitTargets],
267 );
268 const threadGitStatusQueries = useQueries({
269 queries: threadGitStatusCwds.map((cwd) => ({
270 ...gitStatusQueryOptions(cwd),
271 staleTime: 30_000,
272 refetchInterval: 60_000,
273 })),
274 });
275 const prStateByThreadId = useMemo(() => {
276 const statusByCwd = new Map<string, GitStatusResult>();
277 for (let index = 0; index < threadGitStatusCwds.length; index += 1) {
278 const cwd = threadGitStatusCwds[index];
279 if (!cwd) continue;
280 const status = threadGitStatusQueries[index]?.data;
281 if (status) {
282 statusByCwd.set(cwd, status);
283 }
284 }
285
286 const map = new Map<ThreadId, ThreadPrState>();
287 for (const target of threadGitTargets) {
288 const status = target.cwd ? statusByCwd.get(target.cwd) : undefined;
289 const branchMatches =
290 target.branch !== null && status?.branch !== null && status?.branch === target.branch;
291 map.set(target.threadId, branchMatches ? status?.pr?.state ?? null : null);
292 }
293 return map;
294 }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]);
213295
214296 const handleNewThread = useCallback(
215297 (
......@@ -632,6 +714,7 @@ export default function Sidebar() {
632714 thread,
633715 pendingApprovalByThreadId.get(thread.id) === true,
634716 );
717 const prStatus = prStatusIndicator(prStateByThreadId.get(thread.id) ?? null);
635718 const terminalStatus = terminalStatusIndicator(thread);
636719
637720 return (
......@@ -677,6 +760,16 @@ export default function Sidebar() {
677760 </span>
678761 </div>
679762 <div className="ml-auto flex shrink-0 items-center gap-1.5">
763 {prStatus && (
764 <span
765 role="img"
766 aria-label={prStatus.label}
767 title={prStatus.label}
768 className={`inline-flex items-center justify-center ${prStatus.colorClass}`}
769 >
770 <GitPullRequestIcon className="size-3" />
771 </span>
772 )}
680773 {terminalStatus && (
681774 <span
682775 role="img"
packages/contracts/src/git.ts+13-9
......@@ -18,6 +18,8 @@ export const GitPrStepStatus = Schema.Literals([
1818 "opened_existing",
1919 "skipped_not_requested",
2020]);
21export const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]);
22export type GitStatusPrState = typeof GitStatusPrState.Type;
2123
2224export const GitBranch = Schema.Struct({
2325 name: TrimmedNonEmptyStringSchema,
......@@ -91,6 +93,16 @@ export type GitInitInput = typeof GitInitInput.Type;
9193
9294// RPC Results
9395
96export const GitStatusPr = Schema.Struct({
97 number: PositiveInt,
98 title: TrimmedNonEmptyStringSchema,
99 url: Schema.String,
100 baseBranch: TrimmedNonEmptyStringSchema,
101 headBranch: TrimmedNonEmptyStringSchema,
102 state: GitStatusPrState,
103});
104export type GitStatusPr = typeof GitStatusPr.Type;
105
94106export const GitStatusResult = Schema.Struct({
95107 branch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr),
96108 hasWorkingTreeChanges: Schema.Boolean,
......@@ -108,15 +120,7 @@ export const GitStatusResult = Schema.Struct({
108120 hasUpstream: Schema.Boolean,
109121 aheadCount: NonNegativeInt,
110122 behindCount: NonNegativeInt,
111 openPr: Schema.NullOr(
112 Schema.Struct({
113 number: PositiveInt,
114 title: TrimmedNonEmptyStringSchema,
115 url: Schema.String,
116 baseBranch: TrimmedNonEmptyStringSchema,
117 headBranch: TrimmedNonEmptyStringSchema,
118 }),
119 ),
123 pr: Schema.NullOr(GitStatusPr),
120124});
121125export type GitStatusResult = typeof GitStatusResult.Type;
122126