diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index fdbf3c8bc30b948eacc861b1c140f93eb469c6dd..b5fb6b5074a94626f58daecbbd637eef45ad8a29 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -393,7 +393,7 @@ const makeGitCore = Effect.gen(function* () { hasUpstream: details.hasUpstream, aheadCount: details.aheadCount, behindCount: details.behindCount, - openPr: null, + pr: null, })), ); diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 56c7839246d471cd636931eb5ae24aaeb63e01e4..88d26f7710877e9f9eaf01a4dd5f153afedde0e6 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -297,7 +297,7 @@ function makeManager(input?: { const GitManagerTestLayer = Layer.provideMerge(GitServiceLive, NodeServices.layer); it.layer(GitManagerTestLayer)("GitManager", (it) => { - it.effect("status includes open PR metadata when branch already has an open PR", () => + it.effect("status includes PR metadata when branch already has an open PR", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -324,17 +324,103 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const status = yield* manager.status({ cwd: repoDir }); expect(status.branch).toBe("feature/status-open-pr"); - expect(status.openPr).toEqual({ + expect(status.pr).toEqual({ number: 13, title: "Existing PR", url: "https://github.com/pingdotgg/codething-mvp/pull/13", baseBranch: "main", headBranch: "feature/status-open-pr", + state: "open", }); }), ); - it.effect("status is resilient to gh lookup failures and returns openPr null", () => + it.effect("status returns merged PR state when latest PR was merged", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-merged-pr"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 22, + title: "Merged PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/22", + baseRefName: "main", + headRefName: "feature/status-merged-pr", + state: "MERGED", + mergedAt: "2026-01-30T10:00:00Z", + updatedAt: "2026-01-30T10:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.branch).toBe("feature/status-merged-pr"); + expect(status.pr).toEqual({ + number: 22, + title: "Merged PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/22", + baseBranch: "main", + headBranch: "feature/status-merged-pr", + state: "merged", + }); + }), + ); + + it.effect("status prefers open PR when merged PR has newer updatedAt", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-over-merged"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 45, + title: "Merged PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/45", + baseRefName: "main", + headRefName: "feature/status-open-over-merged", + state: "MERGED", + mergedAt: "2026-01-31T10:00:00Z", + updatedAt: "2026-02-01T10:00:00Z", + }, + { + number: 46, + title: "Open PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/46", + baseRefName: "main", + headRefName: "feature/status-open-over-merged", + state: "OPEN", + updatedAt: "2026-01-30T10:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.branch).toBe("feature/status-open-over-merged"); + expect(status.pr).toEqual({ + number: 46, + title: "Open PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/46", + baseBranch: "main", + headBranch: "feature/status-open-over-merged", + state: "open", + }); + }), + ); + + it.effect("status is resilient to gh lookup failures and returns pr null", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -354,7 +440,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const status = yield* manager.status({ cwd: repoDir }); expect(status.branch).toBe("feature/status-no-gh"); - expect(status.openPr).toBeNull(); + expect(status.pr).toBeNull(); }), ); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 54be186ef2f3feb22efb8f5b34474f6d635131d7..27ad13af2767b81e1796c0a34030130c6b59e850 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -16,6 +16,62 @@ interface OpenPrInfo { headRefName: string; } +interface PullRequestInfo extends OpenPrInfo { + state: "open" | "closed" | "merged"; + updatedAt: string | null; +} + +function parsePullRequestList(raw: unknown): PullRequestInfo[] { + if (!Array.isArray(raw)) return []; + + const parsed: PullRequestInfo[] = []; + for (const entry of raw) { + if (!entry || typeof entry !== "object") continue; + const record = entry as Record; + const number = record.number; + const title = record.title; + const url = record.url; + const baseRefName = record.baseRefName; + const headRefName = record.headRefName; + const state = record.state; + const mergedAt = record.mergedAt; + const updatedAt = record.updatedAt; + if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) { + continue; + } + if ( + typeof title !== "string" || + typeof url !== "string" || + typeof baseRefName !== "string" || + typeof headRefName !== "string" + ) { + continue; + } + + let normalizedState: "open" | "closed" | "merged"; + if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") { + normalizedState = "merged"; + } else if (state === "OPEN" || state === undefined || state === null) { + normalizedState = "open"; + } else if (state === "CLOSED") { + normalizedState = "closed"; + } else { + continue; + } + + parsed.push({ + number, + title, + url, + baseRefName, + headRefName, + state: normalizedState, + updatedAt: typeof updatedAt === "string" && updatedAt.trim().length > 0 ? updatedAt : null, + }); + } + return parsed; +} + function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError { return new GitManagerError({ operation, @@ -79,12 +135,13 @@ function extractBranchFromRef(ref: string): string { return normalized.slice(firstSlash + 1).trim(); } -function toStatusOpenPr(pr: OpenPrInfo): { +function toStatusPr(pr: PullRequestInfo): { number: number; title: string; url: string; baseBranch: string; headBranch: string; + state: "open" | "closed" | "merged"; } { return { number: pr.number, @@ -92,6 +149,7 @@ function toStatusOpenPr(pr: OpenPrInfo): { url: pr.url, baseBranch: pr.baseRefName, headBranch: pr.headRefName, + state: pr.state, }; } @@ -123,10 +181,56 @@ export const makeGitManager = Effect.gen(function* () { url: first.url, baseRefName: first.baseRefName, headRefName: first.headRefName, - } satisfies OpenPrInfo; + state: "open", + updatedAt: null, + } satisfies PullRequestInfo; }), ); + const findLatestPr = (cwd: string, branch: string) => + Effect.gen(function* () { + const stdout = yield* gitHubCli + .execute({ + cwd, + args: [ + "pr", + "list", + "--head", + branch, + "--state", + "all", + "--limit", + "20", + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt", + ], + }) + .pipe(Effect.map((result) => result.stdout)); + + const raw = stdout.trim(); + if (raw.length === 0) { + return null; + } + + const parsedJson = yield* Effect.try({ + try: () => JSON.parse(raw) as unknown, + catch: (cause) => + gitManagerError("findLatestPr", "GitHub CLI returned invalid PR list JSON.", cause), + }); + + const parsed = parsePullRequestList(parsedJson).toSorted((a, b) => { + const left = a.updatedAt ? Date.parse(a.updatedAt) : 0; + const right = b.updatedAt ? Date.parse(b.updatedAt) : 0; + return right - left; + }); + + const latestOpenPr = parsed.find((pr) => pr.state === "open"); + if (latestOpenPr) { + return latestOpenPr; + } + return parsed[0] ?? null; + }); + const resolveBaseBranch = (cwd: string, branch: string, upstreamRef: string | null) => Effect.gen(function* () { const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); @@ -258,10 +362,10 @@ export const makeGitManager = Effect.gen(function* () { const status: GitManagerShape["status"] = Effect.fnUntraced(function* (input) { const details = yield* gitCore.statusDetails(input.cwd); - const openPr = - details.branch && details.hasUpstream - ? yield* findOpenPr(input.cwd, details.branch).pipe( - Effect.map((pr) => (pr ? toStatusOpenPr(pr) : null)), + const pr = + details.branch !== null + ? yield* findLatestPr(input.cwd, details.branch).pipe( + Effect.map((latest) => (latest ? toStatusPr(latest) : null)), Effect.catch(() => Effect.succeed(null)), ) : null; @@ -273,7 +377,7 @@ export const makeGitManager = Effect.gen(function* () { hasUpstream: details.hasUpstream, aheadCount: details.aheadCount, behindCount: details.behindCount, - openPr, + pr, }; }); diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index 4e1b3fc5b143432bfcc7d49d2b444257ce372991..d2544de90921fbee628f5a79f59b743221692ae2 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -24,7 +24,7 @@ import type { import type { GitCommandError } from "../Errors.ts"; -export interface GitStatusDetails extends Omit { +export interface GitStatusDetails extends Omit { upstreamRef: string | null; } diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index f475dde9d121c0b9486b18d1c0b5cebdd4cf8fb3..d821ac446e6c57e90a6cc792314d2a729c959a56 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1521,7 +1521,7 @@ describe("WebSocket Server", () => { hasUpstream: false, aheadCount: 0, behindCount: 0, - openPr: null, + pr: null, }; const status = vi.fn(() => Effect.succeed(statusResult)); diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index b764fac276bca59c6cd29fc2979df70b611d3bf7..ba8cbdad4485a9b7967075bfbddef6ac4cbf297a 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -20,7 +20,7 @@ function status(overrides: Partial = {}): GitStatusResult { hasUpstream: true, aheadCount: 0, behindCount: 0, - openPr: null, + pr: null, ...overrides, }; } @@ -29,12 +29,13 @@ describe("when: branch is clean and has an open PR", () => { it("resolveQuickAction opens the existing PR", () => { const quick = resolveQuickAction( status({ - openPr: { + pr: { number: 10, title: "Open PR", url: "https://example.com/pr/10", baseBranch: "main", headBranch: "feature/test", + state: "open", }, }), false, @@ -45,12 +46,13 @@ describe("when: branch is clean and has an open PR", () => { it("buildMenuItems disables commit/push and enables open PR", () => { const items = buildMenuItems( status({ - openPr: { + pr: { number: 11, title: "Existing PR", url: "https://example.com/pr/11", baseBranch: "main", headBranch: "feature/test", + state: "open", }, }), false, @@ -147,12 +149,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => { const quick = resolveQuickAction( status({ aheadCount: 3, - openPr: { + pr: { number: 13, title: "Open PR", url: "https://example.com/pr/13", baseBranch: "main", headBranch: "feature/test", + state: "open", }, }), false, @@ -164,12 +167,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => { const items = buildMenuItems( status({ aheadCount: 2, - openPr: { + pr: { number: 12, title: "Existing PR", url: "https://example.com/pr/12", baseBranch: "main", headBranch: "feature/test", + state: "open", }, }), false, @@ -204,7 +208,7 @@ describe("when: branch is clean, ahead, and has an open PR", () => { describe("when: branch is clean, ahead, and has no open PR", () => { it("resolveQuickAction pushes and creates a PR", () => { - const quick = resolveQuickAction(status({ aheadCount: 2, openPr: null }), false); + const quick = resolveQuickAction(status({ aheadCount: 2, pr: null }), false); assert.deepInclude(quick, { kind: "run_action", action: "commit_push_pr", @@ -213,7 +217,7 @@ describe("when: branch is clean, ahead, and has no open PR", () => { }); it("buildMenuItems enables push and create PR, with commit disabled", () => { - const items = buildMenuItems(status({ aheadCount: 2, openPr: null }), false); + const items = buildMenuItems(status({ aheadCount: 2, pr: null }), false); assert.deepEqual(items, [ { id: "commit", @@ -246,14 +250,14 @@ describe("when: branch is clean, ahead, and has no open PR", () => { describe("when: branch is clean, up to date, and has no open PR", () => { it("resolveQuickAction returns disabled no-action state", () => { const quick = resolveQuickAction( - status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, openPr: null }), + status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, pr: null }), false, ); assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true }); }); it("buildMenuItems disables commit, push, and create PR", () => { - const items = buildMenuItems(status({ aheadCount: 0, behindCount: 0, openPr: null }), false); + const items = buildMenuItems(status({ aheadCount: 0, behindCount: 0, pr: null }), false); assert.deepEqual(items, [ { id: "commit", @@ -290,7 +294,7 @@ describe("when: branch is behind upstream", () => { }); it("buildMenuItems disables push and create PR", () => { - const items = buildMenuItems(status({ behindCount: 1, openPr: null }), false); + const items = buildMenuItems(status({ behindCount: 1, pr: null }), false); assert.deepEqual(items, [ { id: "commit", @@ -346,12 +350,13 @@ describe("when: working tree has local changes", () => { const quick = resolveQuickAction( status({ hasWorkingTreeChanges: true, - openPr: { + pr: { number: 16, title: "Existing PR", url: "https://example.com/pr/16", baseBranch: "main", headBranch: "feature/test", + state: "open", }, }), false, @@ -411,7 +416,7 @@ describe("when: on default branch without open PR", () => { it("resolveQuickAction returns push when branch is ahead", () => { const quick = resolveQuickAction( - status({ branch: "main", aheadCount: 2, openPr: null }), + status({ branch: "main", aheadCount: 2, pr: null }), false, true, ); @@ -511,7 +516,7 @@ describe("when: HEAD is detached and there are no local changes", () => { describe("when: branch has no upstream configured", () => { it("resolveQuickAction returns disabled commit state when clean and no commits are ahead", () => { const quick = resolveQuickAction( - status({ hasUpstream: false, openPr: null, aheadCount: 0 }), + status({ hasUpstream: false, pr: null, aheadCount: 0 }), false, ); assert.deepInclude(quick, { @@ -526,12 +531,13 @@ describe("when: branch has no upstream configured", () => { status({ hasUpstream: false, aheadCount: 0, - openPr: { + pr: { number: 14, title: "Existing PR", url: "https://example.com/pr/14", baseBranch: "main", headBranch: "feature/test", + state: "open", }, }), false, @@ -548,12 +554,13 @@ describe("when: branch has no upstream configured", () => { status({ hasUpstream: false, aheadCount: 1, - openPr: { + pr: { number: 15, title: "Existing PR", url: "https://example.com/pr/15", baseBranch: "main", headBranch: "feature/test", + state: "open", }, }), false, @@ -568,7 +575,7 @@ describe("when: branch has no upstream configured", () => { it("buildMenuItems disables push and create PR when no commits are ahead", () => { const items = buildMenuItems( - status({ hasUpstream: false, openPr: null, aheadCount: 0 }), + status({ hasUpstream: false, pr: null, aheadCount: 0 }), false, ); assert.deepEqual(items, [ diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index a740d6472245b40a9ac3039bbc22505727004bd3..9b39904c60bee4544798558d619b52133f00abd2 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -108,7 +108,7 @@ export function buildMenuItems( const hasBranch = gitStatus.branch !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; - const hasOpenPr = gitStatus.openPr !== null; + const hasOpenPr = gitStatus.pr?.state === "open"; const isBehind = gitStatus.behindCount > 0; const canCommit = !isBusy && hasChanges; const canPush = !isBusy && hasBranch && !hasChanges && !isBehind && gitStatus.aheadCount > 0; @@ -178,7 +178,7 @@ export function resolveQuickAction( const hasBranch = gitStatus.branch !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; - const hasOpenPr = gitStatus.openPr !== null; + const hasOpenPr = gitStatus.pr?.state === "open"; const isAhead = gitStatus.aheadCount > 0; const isBehind = gitStatus.behindCount > 0; const isDiverged = isAhead && isBehind; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index a75d4697a75d07ab7c2d4c850466271ea5f70248..694ecbc0553a6a6da96dceedff9d6170140066aa 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -58,7 +58,7 @@ function getMenuActionDisabledReason( const hasBranch = gitStatus.branch !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; - const hasOpenPr = gitStatus.openPr !== null; + const hasOpenPr = gitStatus.pr?.state === "open"; const isAhead = gitStatus.aheadCount > 0; const isBehind = gitStatus.behindCount > 0; @@ -219,7 +219,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }); return; } - const prUrl = gitStatusForActions?.openPr?.url ?? null; + const prUrl = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : null; if (!prUrl) { toastManager.add({ type: "error", @@ -236,7 +236,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions data: threadToastData, }); }); - }, [gitStatusForActions?.openPr?.url, threadToastData]); + }, [gitStatusForActions?.pr?.state, gitStatusForActions?.pr?.url, threadToastData]); const runGitActionWithToast = useCallback( async ({ action, @@ -295,7 +295,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions stopProgressUpdates(); const resultToast = summarizeGitResult(result); - const prUrl = result.pr.url ?? gitStatusForActions?.openPr?.url; + const existingOpenPrUrl = + gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : undefined; + const prUrl = result.pr.url ?? existingOpenPrUrl; const shouldOfferPushCta = action === "commit" && result.commit.status === "created"; const shouldOfferOpenPrCta = (action === "commit_push" || action === "commit_push_pr") && !!prUrl && !isDefaultBranch; @@ -365,7 +367,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions [ gitStatusForActions?.branch, gitStatusForActions?.hasWorkingTreeChanges, - gitStatusForActions?.openPr?.url, + gitStatusForActions?.pr, isDefaultBranch, maybeConfirmPushToDefaultBranch, runImmediateGitActionMutation, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 35fd562650c99281956abe1d1b088727e2e7ede2..de40306687cd7c390ddd145b9b51a1f84df9de35 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,12 +1,13 @@ -import { ChevronRightIcon, FolderIcon, TerminalIcon } from "lucide-react"; +import { ChevronRightIcon, FolderIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { DEFAULT_MODEL, ProjectId, ThreadId, + type GitStatusResult, type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams } from "@tanstack/react-router"; import { useAppSettings } from "../appSettings"; import { isElectron } from "../env"; @@ -16,7 +17,7 @@ import { useStore } from "../store"; import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; import { type Thread } from "../types"; import { derivePendingApprovals } from "../session-logic"; -import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; +import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; import { toastManager } from "./ui/toast"; @@ -63,6 +64,13 @@ interface TerminalStatusIndicator { pulse: boolean; } +interface PrStatusIndicator { + label: "PR open" | "PR closed" | "PR merged"; + colorClass: string; +} + +type ThreadPrState = NonNullable["state"] | null; + function hasUnseenCompletion(thread: Thread): boolean { if (!thread.latestTurn?.completedAt) return false; const completedAt = Date.parse(thread.latestTurn.completedAt); @@ -125,6 +133,28 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null }; } +function prStatusIndicator(prState: ThreadPrState): PrStatusIndicator | null { + if (prState === "open") { + return { + label: "PR open", + colorClass: "text-emerald-600 dark:text-emerald-300/90", + }; + } + if (prState === "closed") { + return { + label: "PR closed", + colorClass: "text-zinc-500 dark:text-zinc-400/80", + }; + } + if (prState === "merged") { + return { + label: "PR merged", + colorClass: "text-violet-600 dark:text-violet-300/90", + }; + } + return null; +} + function T3Wordmark() { return ( new Map(state.projects.map((project) => [project.id, project.cwd] as const)), + [state.projects], + ); + const threadGitTargets = useMemo( + () => + state.threads.map((thread) => ({ + threadId: thread.id, + branch: thread.branch, + cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null, + })), + [projectCwdById, state.threads], + ); + const threadGitStatusCwds = useMemo( + () => + [ + ...new Set( + threadGitTargets + .filter((target) => target.branch !== null) + .map((target) => target.cwd) + .filter((cwd): cwd is string => cwd !== null), + ), + ], + [threadGitTargets], + ); + const threadGitStatusQueries = useQueries({ + queries: threadGitStatusCwds.map((cwd) => ({ + ...gitStatusQueryOptions(cwd), + staleTime: 30_000, + refetchInterval: 60_000, + })), + }); + const prStateByThreadId = useMemo(() => { + const statusByCwd = new Map(); + for (let index = 0; index < threadGitStatusCwds.length; index += 1) { + const cwd = threadGitStatusCwds[index]; + if (!cwd) continue; + const status = threadGitStatusQueries[index]?.data; + if (status) { + statusByCwd.set(cwd, status); + } + } + + const map = new Map(); + for (const target of threadGitTargets) { + const status = target.cwd ? statusByCwd.get(target.cwd) : undefined; + const branchMatches = + target.branch !== null && status?.branch !== null && status?.branch === target.branch; + map.set(target.threadId, branchMatches ? status?.pr?.state ?? null : null); + } + return map; + }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); const handleNewThread = useCallback( ( @@ -632,6 +714,7 @@ export default function Sidebar() { thread, pendingApprovalByThreadId.get(thread.id) === true, ); + const prStatus = prStatusIndicator(prStateByThreadId.get(thread.id) ?? null); const terminalStatus = terminalStatusIndicator(thread); return ( @@ -677,6 +760,16 @@ export default function Sidebar() {
+ {prStatus && ( + + + + )} {terminalStatus && (