"use client"; import { useEffect, useMemo } from "react"; import type { AddPanelOptions, DockviewApi } from "dockview-react"; import { prTaskKey } from "@/components/github/pr-utils"; import { mrTaskKey } from "@/components/gitlab/mr-detail-panel"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { getPrimaryTaskPR, useTaskPR } from "@/hooks/domains/github/use-task-pr"; import { t } from "@/lib/i18n"; import { markPRPanelOffered, wasPRPanelOffered } from "@/lib/local-storage"; import { focusOrAddPanel } from "@/lib/state/dockview-layout-builders"; import { useDockviewStore } from "@/lib/state/dockview-store"; import type { ReviewItemSummary } from "@/lib/plugins/types"; import { CENTER_GROUP, isCenterCandidateGroupId, type LayoutState, } from "@/lib/state/layout-manager"; import type { TaskPR } from "@/lib/types/github"; import type { TaskMR } from "@/lib/types/gitlab"; import { useNormalizedTaskReviews } from "./review-panel-provider"; export type CanonicalReviewParams = { providerId: string | undefined; provider: "github" | "gitlab" | undefined; reviewKey: string | undefined; connectionScope: string | undefined; repositoryId: string | undefined; changeRequestNumber: string | number | undefined; prKey: string | undefined; mrKey: string | undefined; }; export type CanonicalReviewPanelState = { kind: "multiple" | "github" | "gitlab" | "registered" | "empty"; params: CanonicalReviewParams; title: string; }; export function resolveCanonicalReviewPanelState( prs: TaskPR[] | undefined, mrs: TaskMR[] | undefined, registeredReviews: readonly ReviewItemSummary[] = [], ): CanonicalReviewPanelState { const linkedProviderIds = new Set(); if (prs?.length) linkedProviderIds.add("github"); if (mrs?.length) linkedProviderIds.add("gitlab"); registeredReviews.forEach((review) => linkedProviderIds.add(review.providerId)); if (linkedProviderIds.size > 1) { return { kind: "multiple", params: { providerId: undefined, provider: undefined, reviewKey: undefined, connectionScope: undefined, repositoryId: undefined, changeRequestNumber: undefined, prKey: undefined, mrKey: undefined, }, title: t("integrations:reviews", { summary: "" }), }; } const pr = getPrimaryTaskPR(prs); if (pr) { const key = prTaskKey(pr); return { kind: "github", params: { providerId: "github", provider: "github", reviewKey: key, connectionScope: reviewConnectionScope(pr.pr_url, "github"), repositoryId: pr.repository_id || `${pr.owner}/${pr.repo}`, changeRequestNumber: pr.pr_number, prKey: key, mrKey: undefined, }, title: t("task:pullRequest2"), }; } const mr = mrs?.[0]; if (mr) { const key = mrTaskKey(mr); return { kind: "gitlab", params: { providerId: "gitlab", provider: "gitlab", reviewKey: key, connectionScope: mr.host || reviewConnectionScope(mr.mr_url, "gitlab"), repositoryId: mr.repository_id || mr.project_path, changeRequestNumber: mr.mr_iid, prKey: undefined, mrKey: key, }, title: t("task:mergeRequestLabel"), }; } const registered = registeredReviews.find( (review) => review.providerId !== "github" && review.providerId !== "gitlab", ); if (registered) { return { kind: "registered", params: { providerId: registered.providerId, provider: undefined, reviewKey: registered.reviewKey, connectionScope: registered.connectionScope, repositoryId: registered.repositoryId, changeRequestNumber: registered.changeRequestNumber, prKey: undefined, mrKey: undefined, }, title: registered.title, }; } return { kind: "empty", params: { providerId: undefined, provider: undefined, reviewKey: undefined, connectionScope: undefined, repositoryId: undefined, changeRequestNumber: undefined, prKey: undefined, mrKey: undefined, }, title: t("common:prDetails"), }; } const CANONICAL_REVIEW_PARAM_KEYS = [ "providerId", "provider", "reviewKey", "connectionScope", "repositoryId", "changeRequestNumber", "prKey", "mrKey", ] as const satisfies readonly (keyof CanonicalReviewParams)[]; function hasSameReviewParams( current: Record | undefined, next: CanonicalReviewParams, ): boolean { return CANONICAL_REVIEW_PARAM_KEYS.every((key) => current?.[key] === next[key]); } export type ConditionalReviewPanelAction = "add" | "remove" | "sync" | "none"; export function resolveConditionalReviewPanelAction(params: { hasReview: boolean; panelExists: boolean; reviewsLoaded: boolean; isRestoringLayout: boolean; isMaximized: boolean; wasOffered: boolean; }): ConditionalReviewPanelAction { if (!params.hasReview) { if (params.panelExists && !params.reviewsLoaded) return "none"; return params.panelExists ? "remove" : "none"; } if (params.panelExists) return "sync"; if (params.isRestoringLayout || params.isMaximized || params.wasOffered) return "none"; return "add"; } export type ConditionalReviewPanelOptions = { sessionId: string; centerGroupId: string; configuredPlacement?: ReviewPanelPlacement | null; reviewsLoaded: boolean; isRestoringLayout: boolean; isMaximized: boolean; wasOffered: boolean; }; export type ReviewPanelPlacement = { groupId: string; index: number; }; /** Resolve where a custom Default layout wants the conditional review tab. */ export function resolveConfiguredReviewPanelPlacement( layout: LayoutState | null, ): ReviewPanelPlacement | null { if (!layout) return null; for (const column of layout.columns) { for (const group of column.groups) { const index = group.panels.findIndex((panel) => panel.id === "pr-detail"); if (index >= 0 && group.id) return { groupId: group.id, index }; } } return null; } function resolveReviewPanelTargetGroup( api: DockviewApi, sessionId: string, centerGroupId: string, ): string { const sessionPanel = api.getPanel(`session:${sessionId}`); const sessionGroupId = sessionPanel?.group?.id; if (sessionGroupId && isCenterCandidateGroupId(sessionGroupId)) return sessionGroupId; return isCenterCandidateGroupId(centerGroupId) ? centerGroupId : CENTER_GROUP; } function resolveReviewPanelTargetPosition( api: DockviewApi, options: ConditionalReviewPanelOptions, ): NonNullable { const configured = options.configuredPlacement; if (configured && api.groups.some((group) => group.id === configured.groupId)) { return { referenceGroup: configured.groupId, index: configured.index }; } return { referenceGroup: resolveReviewPanelTargetGroup(api, options.sessionId, options.centerGroupId), }; } function addConditionalReviewPanel( api: DockviewApi, next: CanonicalReviewPanelState, options: ConditionalReviewPanelOptions, ): void { focusOrAddPanel( api, { id: "pr-detail", component: "pr-detail", title: next.title || t("common:prDetails"), position: resolveReviewPanelTargetPosition(api, options), params: next.params, }, true, ); markPRPanelOffered(options.sessionId); } function syncExistingReviewPanel( panel: NonNullable>, next: CanonicalReviewPanelState, options: ConditionalReviewPanelOptions, ): boolean { if (hasCanonicalReview(next)) markPRPanelOffered(options.sessionId); const paramsChanged = !hasSameReviewParams(panel.params, next.params); const titleChanged = panel.api.title !== next.title; if (!paramsChanged && !titleChanged) return false; if (paramsChanged) panel.api.updateParameters(next.params); if (titleChanged) panel.api.setTitle(next.title); return true; } function hasCanonicalReview(next: CanonicalReviewPanelState): boolean { return next.kind !== "empty"; } function resolveReviewPanelAction( panel: ReturnType, next: CanonicalReviewPanelState, options: ConditionalReviewPanelOptions, ): ConditionalReviewPanelAction { return resolveConditionalReviewPanelAction({ hasReview: hasCanonicalReview(next), panelExists: !!panel, reviewsLoaded: options.reviewsLoaded, isRestoringLayout: options.isRestoringLayout, isMaximized: options.isMaximized, wasOffered: options.wasOffered, }); } /** * Synchronize the canonical PR Details panel and manage the conditional panel * shown for a linked review. * * Review association owns panel existence. A custom Default layout can still * provide the group and tab index used when a linked review makes it visible. */ export function syncCanonicalReviewPanel( api: DockviewApi, next: CanonicalReviewPanelState, options: ConditionalReviewPanelOptions, ): boolean { const panel = api.getPanel("pr-detail"); const action = resolveReviewPanelAction(panel, next, options); if (action === "remove") { panel?.api.close(); return true; } if (action === "add") { addConditionalReviewPanel(api, next, options); return true; } return action === "sync" && panel ? syncExistingReviewPanel(panel, next, options) : false; } function reviewIdentity(state: CanonicalReviewPanelState): string { return [ state.kind, state.params.providerId ?? "none", state.params.connectionScope ?? "", state.params.repositoryId ?? "", String(state.params.changeRequestNumber ?? ""), ].join(":"); } function reviewConnectionScope(url: string, fallback: string): string { try { return new URL(url).origin; } catch { return fallback; } } /** Keep an existing canonical PR Details panel in sync with the active task. */ export function useSyncReviewPanel() { const appStore = useAppStoreApi(); const taskId = useAppStore((state) => state.tasks.activeTaskId); const { loaded: githubReviewsLoaded } = useTaskPR(taskId); const sessionId = useAppStore((state) => state.tasks.activeSessionId); const workspaceId = useAppStore((state) => state.workspaces.activeId); const reviews = useNormalizedTaskReviews(taskId); const registeredReviews = useMemo( () => reviews.filter((review) => review.providerId !== "github" && review.providerId !== "gitlab"), [reviews], ); const gitlabReviewsLoaded = useAppStore((state) => workspaceId ? Object.hasOwn(state.taskMRs.byWorkspaceId, workspaceId) : false, ); const reviewsLoaded = githubReviewsLoaded && gitlabReviewsLoaded; const identity = useAppStore((state) => { if (!taskId || !workspaceId) return "none"; return reviewIdentity( resolveCanonicalReviewPanelState( state.taskPRs.byTaskId[taskId], state.taskMRs.byWorkspaceId[workspaceId]?.[taskId], registeredReviews, ), ); }); const hasApi = useDockviewStore((state) => !!state.api); const isRestoringLayout = useDockviewStore((state) => state.isRestoringLayout); const isMaximized = useDockviewStore((state) => state.preMaximizeLayout !== null); const centerGroupId = useDockviewStore((state) => state.centerGroupId); const userDefaultLayout = useDockviewStore((state) => state.userDefaultLayout); useEffect(() => { if (!taskId || !sessionId || !workspaceId || !hasApi) return; let innerFrame: number | null = null; const outerFrame = requestAnimationFrame(() => { innerFrame = requestAnimationFrame(() => { const live = appStore.getState(); if ( live.tasks.activeTaskId !== taskId || live.tasks.activeSessionId !== sessionId || live.workspaces.activeId !== workspaceId ) return; const api = useDockviewStore.getState().api; if (!api) return; const dockview = useDockviewStore.getState(); syncCanonicalReviewPanel( api, resolveCanonicalReviewPanelState( live.taskPRs.byTaskId[taskId], live.taskMRs.byWorkspaceId[workspaceId]?.[taskId], registeredReviews, ), { sessionId, centerGroupId: dockview.centerGroupId, configuredPlacement: resolveConfiguredReviewPanelPlacement(dockview.userDefaultLayout), reviewsLoaded, isRestoringLayout: dockview.isRestoringLayout, isMaximized: dockview.preMaximizeLayout !== null, wasOffered: wasPRPanelOffered(sessionId), }, ); }); }); return () => { cancelAnimationFrame(outerFrame); if (innerFrame !== null) cancelAnimationFrame(innerFrame); }; }, [ appStore, centerGroupId, hasApi, identity, isMaximized, isRestoringLayout, reviewsLoaded, registeredReviews, sessionId, taskId, userDefaultLayout, workspaceId, ]); }