feat(app,server): add pull request pane to the workspace sidebar

Introduce a PR tab in the explorer sidebar that shows status checks,
reviews, and the merged timeline for the current branch's pull
request. Extend checkout PR status with number, draft flag, repo
identity, and per-check workflow/duration; add a new
pull_request_timeline request/response pair backed by a GraphQL
query; enrich the workspace git snapshot to carry the fields through
to the client; and extend the checkout-git-actions store so timeline
queries are invalidated alongside status when mutations land.
This commit is contained in:
Mohamed Boudra
2026-04-19 14:26:52 +07:00
parent 6a36db9924
commit e373c33108
27 changed files with 4583 additions and 174 deletions
@@ -1,7 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { useSessionStore } from "@/stores/session-store";
import {
__resetCheckoutGitActionsStoreForTests,
invalidateCheckoutGitQueriesForClient,
useCheckoutGitActionsStore,
} from "@/stores/checkout-git-actions-store";
@@ -61,4 +63,30 @@ describe("checkout-git-actions-store", () => {
vi.advanceTimersByTime(1000);
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("idle");
});
it("invalidates checkout PR status and every PR pane timeline for a checkout", async () => {
const queryClient = new QueryClient();
queryClient.setQueryData(["checkoutPrStatus", serverId, cwd], { status: { number: 12 } });
queryClient.setQueryData(["prPaneTimeline", serverId, cwd, 12], { items: [] });
queryClient.setQueryData(["prPaneTimeline", serverId, cwd, 13], { items: [] });
queryClient.setQueryData(["prPaneTimeline", serverId, "/tmp/other", 12], { items: [] });
await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, cwd });
expect(queryClient.getQueryState(["checkoutPrStatus", serverId, cwd])?.isInvalidated).toBe(
true,
);
expect(queryClient.getQueryState(["prPaneTimeline", serverId, cwd, 12])?.isInvalidated).toBe(
true,
);
expect(queryClient.getQueryState(["prPaneTimeline", serverId, cwd, 13])?.isInvalidated).toBe(
true,
);
expect(
queryClient.getQueryState(["prPaneTimeline", serverId, "/tmp/other", 12])?.isInvalidated,
).toBe(false);
queryClient.clear();
});
});
@@ -1,10 +1,11 @@
import type { QueryClient } from "@tanstack/react-query";
import { create } from "zustand";
import { useSessionStore } from "@/stores/session-store";
import { queryClient } from "@/query/query-client";
import { queryClient as appQueryClient } from "@/query/query-client";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store";
const SUCCESS_DISPLAY_MS = 1000;
@@ -59,34 +60,54 @@ function setStatus(
});
}
export async function invalidateCheckoutGitQueriesForClient(
queryClient: QueryClient,
{ serverId, cwd }: { serverId: string; cwd: string },
) {
await Promise.all([
queryClient.invalidateQueries({
queryKey: ["checkoutStatus", serverId, cwd],
}),
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return (
Array.isArray(key) && key[0] === "checkoutDiff" && key[1] === serverId && key[2] === cwd
);
},
}),
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return (
Array.isArray(key) &&
key[0] === "checkoutPrStatus" &&
key[1] === serverId &&
key[2] === cwd
);
},
}),
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return (
Array.isArray(key) && key[0] === "prPaneTimeline" && key[1] === serverId && key[2] === cwd
);
},
}),
]);
}
function invalidateCheckoutGitQueries(serverId: string, cwd: string) {
void queryClient.invalidateQueries({
queryKey: ["checkoutStatus", serverId, cwd],
});
void queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return (
Array.isArray(key) && key[0] === "checkoutDiff" && key[1] === serverId && key[2] === cwd
);
},
});
void queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return (
Array.isArray(key) && key[0] === "checkoutPrStatus" && key[1] === serverId && key[2] === cwd
);
},
});
return invalidateCheckoutGitQueriesForClient(appQueryClient, { serverId, cwd });
}
function invalidateWorktreeList() {
void queryClient.invalidateQueries({
void appQueryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList",
});
void queryClient.invalidateQueries({
void appQueryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === "sidebarPaseoWorktreeList",
});
@@ -107,7 +128,7 @@ function removeWorktreeFromCachedLists(input: { serverId: string; worktreePath:
return filtered.length === current.length ? current : filtered;
};
queryClient.setQueriesData(
appQueryClient.setQueriesData(
{
predicate: (query) =>
Array.isArray(query.queryKey) &&
@@ -117,7 +138,7 @@ function removeWorktreeFromCachedLists(input: { serverId: string; worktreePath:
removeFromList,
);
queryClient.setQueriesData(
appQueryClient.setQueriesData(
{
predicate: (query) =>
Array.isArray(query.queryKey) &&
@@ -201,7 +222,7 @@ async function runCheckoutAction({
const promise = (async () => {
try {
await run();
invalidateCheckoutGitQueries(serverId, cwd);
await invalidateCheckoutGitQueries(serverId, cwd);
setStatus(key, actionId, "success");
const timer = setTimeout(() => {
setStatus(key, actionId, "idle");
@@ -1,7 +1,7 @@
export type ExplorerTab = "changes" | "files";
export type ExplorerTab = "changes" | "files" | "pr";
export function isExplorerTab(value: unknown): value is ExplorerTab {
return value === "changes" || value === "files";
return value === "changes" || value === "files" || value === "pr";
}
export function buildExplorerCheckoutKey(serverId: string, cwd: string): string | null {