Skip to content
39 changes: 38 additions & 1 deletion apps/code/src/main/services/git/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ vi.mock("../../utils/logger.js", () => ({

import type { LlmGatewayService } from "../llm-gateway/service";
import type { WorkspaceService } from "../workspace/service";
import { GitService } from "./service";
import { GitService, mapPrState } from "./service";

describe("GitService.getPrChangedFiles", () => {
let service: GitService;
Expand Down Expand Up @@ -269,3 +269,40 @@ describe("GitService.getPrUrlForBranch", () => {
expect(result).toBeNull();
});
});

describe("mapPrState", () => {
it("returns merged when merged boolean is true", () => {
expect(mapPrState("open", true, false)).toBe("merged");
expect(mapPrState("closed", true, false)).toBe("merged");
expect(mapPrState(null, true, false)).toBe("merged");
});

it("returns merged when state string is MERGED", () => {
expect(mapPrState("MERGED", false, false)).toBe("merged");
expect(mapPrState("merged", false, false)).toBe("merged");
expect(mapPrState("Merged", false, false)).toBe("merged");
});

it("returns closed for closed state", () => {
expect(mapPrState("closed", false, false)).toBe("closed");
expect(mapPrState("CLOSED", false, false)).toBe("closed");
});

it("returns draft when draft is true and not merged/closed", () => {
expect(mapPrState("open", false, true)).toBe("draft");
});

it("closed takes priority over draft", () => {
expect(mapPrState("closed", false, true)).toBe("closed");
});

it("returns open for open state", () => {
expect(mapPrState("open", false, false)).toBe("open");
expect(mapPrState("OPEN", false, false)).toBe("open");
});

it("returns null for unknown state", () => {
expect(mapPrState(null, false, false)).toBeNull();
expect(mapPrState("something", false, false)).toBeNull();
});
});
83 changes: 83 additions & 0 deletions apps/code/src/main/services/git/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { MAIN_TOKENS } from "../../di/tokens";
import { logger } from "../../utils/logger";
import { TypedEventEmitter } from "../../utils/typed-event-emitter";
import type { LlmGatewayService } from "../llm-gateway/service";
import type { SidebarPrState } from "../workspace/schemas";
import type { WorkspaceService } from "../workspace/service";
import { CreatePrSaga } from "./create-pr-saga";
import type {
Expand Down Expand Up @@ -94,6 +95,19 @@ const log = logger.scope("git-service");
const FETCH_THROTTLE_MS = 5 * 60 * 1000;
const MAX_DIFF_LENGTH = 8000;

export function mapPrState(
state: string | null,
merged: boolean,
draft: boolean,
): SidebarPrState {
const lower = state?.toLowerCase() ?? null;
if (merged || lower === "merged") return "merged";
if (lower === "closed") return "closed";
if (draft) return "draft";
if (lower === "open") return "open";
return null;
}

/**
* Wraps a GitHub API per-file patch (hunk content only) with
* the `diff --git` / `---` / `+++` header so that unified-diff
Expand Down Expand Up @@ -1668,4 +1682,73 @@ ${truncatedDiff || "(no diff available)"}${contextSection}`;
return [];
}
}

async getTaskPrStatus(
taskId: string,
cloudPrUrl: string | null,
): Promise<{ prState: SidebarPrState; hasDiff: boolean }> {
const workspace = await this.workspaceService.getWorkspace(taskId);
if (!workspace) return { prState: null, hasDiff: false };

const { mode, worktreePath, folderPath, linkedBranch } = workspace;
const isCloud = mode === "cloud";
const repoPath = worktreePath ?? (folderPath || null);

// Cloud tasks: look up PR details by the cloud run's PR URL
if (isCloud && cloudPrUrl) {
const details = await this.getPrDetailsByUrl(cloudPrUrl);
if (details) {
return {
prState: mapPrState(details.state, details.merged, details.draft),
hasDiff: false,
};
}
return { prState: null, hasDiff: false };
}

if (isCloud) return { prState: null, hasDiff: false };

// Linked branch: look up PR by branch name
if (linkedBranch && repoPath) {
const prUrl = await this.getPrUrlForBranch(repoPath, linkedBranch);
if (prUrl) {
const details = await this.getPrDetailsByUrl(prUrl);
if (details) {
return {
prState: mapPrState(details.state, details.merged, details.draft),
hasDiff: false,
};
}
}
return { prState: null, hasDiff: false };
}

// Worktree tasks without linked branch: check current branch PR + diff
if (worktreePath) {
const prStatus = await this.getPrStatus(worktreePath);
if (prStatus.prExists && prStatus.prState) {
return {
prState: mapPrState(
prStatus.prState,
false,
prStatus.isDraft ?? false,
),
hasDiff: false,
};
}

const [diffStats, syncStatus] = await Promise.all([
this.getDiffStats(worktreePath),
this.getGitSyncStatus(worktreePath),
]);

const hasDiff =
(diffStats?.filesChanged ?? 0) > 0 ||
(syncStatus?.aheadOfDefault ?? 0) > 0;

return { prState: null, hasDiff };
}

return { prState: null, hasDiff: false };
}
}
19 changes: 19 additions & 0 deletions apps/code/src/main/services/workspace/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,25 @@ export const getAllTaskTimestampsOutput = z.record(
}),
);

// Task PR status
export const taskPrStatusInput = z.object({
taskId: z.string(),
cloudPrUrl: z.string().nullable(),
});

export const sidebarPrStateSchema = z
.enum(["merged", "open", "draft", "closed"])
.nullable();

export const taskPrStatusOutput = z.object({
prState: sidebarPrStateSchema,
hasDiff: z.boolean(),
});

export type TaskPrStatusInput = z.infer<typeof taskPrStatusInput>;
export type SidebarPrState = z.infer<typeof sidebarPrStateSchema>;
export type TaskPrStatus = z.infer<typeof taskPrStatusOutput>;

// Type exports
export type WorkspaceMode = z.infer<typeof workspaceModeSchema>;
export type WorktreeInfo = z.infer<typeof worktreeInfoSchema>;
Expand Down
55 changes: 55 additions & 0 deletions apps/code/src/main/services/workspace/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,61 @@ export class WorkspaceService extends TypedEventEmitter<WorkspaceServiceEvents>
return { exists: false };
}

async getWorkspace(taskId: string): Promise<Workspace | null> {
const assoc = this.findTaskAssociation(taskId);
if (!assoc) return null;

const dbRow = this.workspaceRepo.findByTaskId(taskId);
const linkedBranch = dbRow?.linkedBranch ?? null;

if (assoc.mode === "cloud") {
return {
taskId,
folderId: assoc.folderId ?? "",
folderPath: "",
mode: "cloud",
worktreePath: null,
worktreeName: null,
branchName: null,
baseBranch: null,
linkedBranch,
createdAt: new Date().toISOString(),
};
}

const folderPath = this.getFolderPath(assoc.folderId);
if (!folderPath) return null;

let worktreePath: string | null = null;
let worktreeName: string | null = null;
let branchName: string | null = null;

if (assoc.mode === "worktree") {
worktreeName = assoc.worktree;
worktreePath = deriveWorktreePath(folderPath, worktreeName);
const gitBranch = await getBranchFromPath(worktreePath);
branchName = gitBranch ?? assoc.branchName;
} else if (assoc.mode === "local") {
const localWorktreePath =
await this.getLocalWorktreePathIfExists(folderPath);
const branchPath = localWorktreePath ?? folderPath;
branchName = await getBranchFromPath(branchPath);
}

return {
taskId,
folderId: assoc.folderId,
folderPath,
mode: assoc.mode,
worktreePath,
worktreeName,
branchName,
baseBranch: null,
linkedBranch,
createdAt: new Date().toISOString(),
};
}

async getWorkspaceInfo(taskId: string): Promise<WorkspaceInfo | null> {
const association = this.findTaskAssociation(taskId);
if (!association) {
Expand Down
12 changes: 12 additions & 0 deletions apps/code/src/main/trpc/routers/workspace.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { WorkspaceRepository } from "../../db/repositories/workspace-repository";
import { container } from "../../di/container";
import { MAIN_TOKENS } from "../../di/tokens";
import type { GitService } from "../../services/git/service";
import {
createWorkspaceInput,
createWorkspaceOutput,
Expand All @@ -24,6 +25,8 @@ import {
listGitWorktreesOutput,
markActivityInput,
markViewedInput,
taskPrStatusInput,
taskPrStatusOutput,
togglePinInput,
togglePinOutput,
unlinkBranchInput,
Expand All @@ -40,6 +43,8 @@ import { publicProcedure, router } from "../trpc";
const getService = () =>
container.get<WorkspaceService>(MAIN_TOKENS.WorkspaceService);

const getGitService = () => container.get<GitService>(MAIN_TOKENS.GitService);

const getWorkspaceRepo = () =>
container.get<WorkspaceRepository>(MAIN_TOKENS.WorkspaceRepository);

Expand Down Expand Up @@ -193,6 +198,13 @@ export const workspaceRouter = router({
.input(unlinkBranchInput)
.mutation(({ input }) => getService().unlinkBranch(input.taskId, "user")),

getTaskPrStatus: publicProcedure
.input(taskPrStatusInput)
.output(taskPrStatusOutput)
.query(({ input }) =>
getGitService().getTaskPrStatus(input.taskId, input.cloudPrUrl),
),

onError: subscribe(WorkspaceServiceEvent.Error),
onWarning: subscribe(WorkspaceServiceEvent.Warning),
onPromoted: subscribe(WorkspaceServiceEvent.Promoted),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,14 @@ vi.mock("@utils/logger", () => ({
}));

vi.mock("@features/sessions/stores/sessionStore", () => {
const fn: any = (selector: any) =>
selector({
taskIdIndex: { "task-1": "run-1" },
sessions: { "run-1": { events: mockPrompts.value } },
});
fn.getState = () => ({
const state = {
taskIdIndex: { "task-1": "run-1" },
sessions: { "run-1": { events: mockPrompts.value } },
});
};
const fn = Object.assign(
(selector: (s: typeof state) => unknown) => selector(state),
{ getState: () => state },
);
return {
useSessionStore: fn,
sessionStoreSetters: mockSessionStoreSetters,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { getRelativeDateGroup } from "@utils/time";
import { motion } from "framer-motion";
import { Fragment, useCallback, useEffect, useMemo } from "react";
import type { TaskData, TaskGroup } from "../hooks/useSidebarData";
import { useTaskPrStatus } from "../hooks/useTaskPrStatus";
import { useSidebarStore } from "../stores/sidebarStore";
import { DraggableFolder } from "./DraggableFolder";
import { TaskItem } from "./items/TaskItem";
Expand Down Expand Up @@ -100,6 +101,7 @@ function TaskRow({
const effectiveMode =
workspace?.mode ??
(task.taskRunEnvironment === "cloud" ? "cloud" : undefined);
const { prState, hasDiff } = useTaskPrStatus(task);

return (
<TaskItem
Expand All @@ -116,6 +118,8 @@ function TaskRow({
isPinned={task.isPinned}
needsPermission={task.needsPermission}
taskRunStatus={task.taskRunStatus}
prState={prState}
hasDiff={hasDiff}
timestamp={timestamp}
onClick={onClick}
onDoubleClick={onDoubleClick}
Expand Down
Loading
Loading