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
+183
View File
@@ -0,0 +1,183 @@
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type {
CheckoutPrStatusResponse,
PullRequestTimelineResponse,
} from "@server/shared/messages";
import { mapPrPaneData, type PrPaneData } from "../utils/pr-pane-data";
import { useCheckoutPrStatusQuery } from "./use-checkout-pr-status-query";
const PR_PANE_TIMELINE_STALE_TIME = 15_000;
const PR_PANE_TIMELINE_REFETCH_INTERVAL = 30_000;
type CheckoutPrStatusPayloadError = CheckoutPrStatusResponse["payload"]["error"];
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
const unsupportedTimelineKeys = new Set<string>();
export interface UsePrPaneDataOptions {
serverId: string;
cwd: string;
enabled?: boolean;
timelineEnabled?: boolean;
}
export interface UsePrPaneDataResult {
data: PrPaneData | null;
prNumber: number | null;
isLoading: boolean;
isRefreshing: boolean;
error: Error | null;
githubFeaturesEnabled: boolean;
}
export function usePrPaneData({
serverId,
cwd,
enabled = true,
timelineEnabled = enabled,
}: UsePrPaneDataOptions): UsePrPaneDataResult {
const daemonClient = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const checkoutPrStatus = useCheckoutPrStatusQuery({ serverId, cwd, enabled });
const status = checkoutPrStatus.status;
const prNumber = status?.number ?? null;
const repoOwner = status?.repoOwner && status.repoOwner.length > 0 ? status.repoOwner : null;
const repoName = status?.repoName && status.repoName.length > 0 ? status.repoName : null;
const githubFeaturesEnabled = checkoutPrStatus.githubFeaturesEnabled !== false;
const unsupportedKey =
prNumber === null ? null : timelineUnsupportedKey({ serverId, cwd, prNumber });
const timelineUnsupported = unsupportedKey ? unsupportedTimelineKeys.has(unsupportedKey) : false;
const shouldFetchTimeline =
!!daemonClient &&
isConnected &&
timelineEnabled &&
githubFeaturesEnabled &&
!!cwd &&
prNumber !== null &&
repoOwner !== null &&
repoName !== null &&
!timelineUnsupported;
const timelineQuery = useQuery<PullRequestTimeline, Error>({
queryKey: prPaneTimelineQueryKey({ serverId, cwd, prNumber }),
queryFn: async () => {
if (!daemonClient || prNumber === null || repoOwner === null || repoName === null) {
throw new Error("Daemon client not available");
}
try {
return await daemonClient.pullRequestTimeline({
cwd,
prNumber,
repoOwner,
repoName,
});
} catch (error) {
if (unsupportedKey && isUnsupportedTimelineError(error)) {
unsupportedTimelineKeys.add(unsupportedKey);
}
throw error;
}
},
enabled: shouldFetchTimeline,
staleTime: PR_PANE_TIMELINE_STALE_TIME,
refetchInterval: PR_PANE_TIMELINE_REFETCH_INTERVAL,
placeholderData: keepPreviousData,
retry: (failureCount, error) => !isUnsupportedTimelineError(error) && failureCount < 3,
});
const data =
prNumber === null || !timelineEnabled ? null : mapPrPaneData(status, timelineQuery.data);
const statusRefreshing = checkoutPrStatus.isFetching && !checkoutPrStatus.isLoading;
const timelineRefreshing = timelineQuery.isFetching && !timelineQuery.isLoading;
return {
data,
prNumber,
isLoading:
checkoutPrStatus.isLoading ||
(shouldFetchTimeline && timelineQuery.isLoading && timelineQuery.data === undefined),
isRefreshing: statusRefreshing || timelineRefreshing,
error: firstNonSuppressedError({
statusPayloadError: checkoutPrStatus.payloadError,
statusError: checkoutPrStatus.error,
timelineError: timelineQuery.error,
timelinePayloadError: timelineQuery.data?.error ?? null,
}),
githubFeaturesEnabled,
};
}
export function prPaneTimelineQueryKey({
serverId,
cwd,
prNumber,
}: {
serverId: string;
cwd: string;
prNumber: number | null;
}) {
return ["prPaneTimeline", serverId, cwd, prNumber] as const;
}
function firstNonSuppressedError({
statusPayloadError,
statusError,
timelineError,
timelinePayloadError,
}: {
statusPayloadError: CheckoutPrStatusPayloadError;
statusError: Error | null;
timelineError: Error | null;
timelinePayloadError: PullRequestTimeline["error"];
}): Error | null {
if (statusPayloadError) {
return new Error(statusPayloadError.message || "Unable to load pull request status");
}
if (statusError) {
return statusError;
}
if (timelineError && !isUnsupportedTimelineError(timelineError)) {
return timelineError;
}
if (timelinePayloadError) {
return new Error(timelinePayloadError.message || "Unable to load pull request activity");
}
return null;
}
function timelineUnsupportedKey({
serverId,
cwd,
prNumber,
}: {
serverId: string;
cwd: string;
prNumber: number;
}): string {
return `${serverId}\0${cwd}\0${prNumber}`;
}
function isUnsupportedTimelineError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const name = error.name.toLowerCase();
const rpcError = error as Error & { code?: unknown; requestType?: unknown };
if (
name === "daemonrpcerror" &&
rpcError.code === "unknown_schema" &&
rpcError.requestType === "pull_request_timeline_request"
) {
return true;
}
return false;
}