Files
kandev/apps/web/components/task/dockview-add-panel-items.tsx
T
2026-09-21 23:20:52 +01:00

455 lines
16 KiB
TypeScript

"use client";
import {
IconBrandVscode,
IconDeviceDesktop,
IconFileText,
IconFolder,
IconGitBranch,
IconGitPullRequest,
IconHistory,
IconLayoutGrid,
IconListCheck,
IconNetwork,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
DropdownMenuCheckboxItem,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from "@kandev/ui/dropdown-menu";
import type { DockviewApi } from "dockview-react";
import { prPanelLabel, prIdentitySlug, prTaskKey } from "@/components/github/pr-utils";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { reviewPanelId } from "@/lib/state/dockview-review-panel-id";
import { pluginRegistry, usePluginRegistry } from "@/lib/plugins/registry";
import { resolvePluginIcon } from "@/lib/plugins/icons";
import { registrationIsVisible } from "./plugin-task-panel";
import { resolveTaskPanelTitle } from "@/lib/state/layout-manager/plugin-panels";
import type { PluginSessionKind, ReviewItemSummary } from "@/lib/plugins/types";
import type { TaskPR } from "@/lib/types/github";
import type { TaskMR } from "@/lib/types/gitlab";
import { useAppStore } from "@/components/state-provider";
import { useFeature } from "@/hooks/domains/features/use-feature";
import { useTaskCanvases } from "@/hooks/domains/task/use-task-canvases";
import { activateCanvasPanel, isDiscoverableTaskCanvas } from "./dockview-canvas-activation";
import { canvasPresentationUserId } from "@/lib/canvas-presentation-storage";
import { mrTaskKey } from "@/components/gitlab/mr-detail-panel";
import { RepositoryScriptsMenuItems } from "./repository-scripts-menu";
import { SessionReopenMenuItems } from "./session-reopen-menu";
import { TerminalReopenMenuItems } from "./terminal-reopen-menu";
import { useNormalizedTaskReviews } from "./review-panel-provider";
import { reviewItemId } from "./review-selection";
import type { PortForwardingVisibility } from "./port-forwarding-visibility-provider";
export type AddPanelMenuState = {
taskId: string | null;
isPassthrough: boolean;
hasChanges: boolean;
hasFiles: boolean;
/** All PRs linked to the task; multi-repo tasks render one menu item per PR. */
prs: TaskPR[];
mrs: TaskMR[];
portForwarding?: PortForwardingVisibility;
};
type AddPanelMenuItemsProps = {
groupId: string;
state: AddPanelMenuState;
onNewSession: () => void;
onAddTerminal: () => void;
onRunScript: (scriptId: string) => void;
onRunDevScript: () => void;
};
export const MENU_ICON_CLASS = "h-3.5 w-3.5 mr-1.5 shrink-0";
export const MENU_ITEM_CLASS = "cursor-pointer text-xs";
const PR_SUBMENU_TEST_ID = "add-panel-pr-submenu";
type ReviewMenuIdentity = Pick<ReviewItemSummary, "providerId" | "reviewKey"> &
Partial<Pick<ReviewItemSummary, "connectionScope" | "repositoryId" | "changeRequestNumber">>;
type ReviewPanelLookup = Pick<DockviewApi, "getPanel">;
/** Reads the params of a live dockview panel, or undefined when no panel with
* that id is open. */
function panelParams(api: ReviewPanelLookup, id: string): Record<string, unknown> | undefined {
return api.getPanel(id)?.params as Record<string, unknown> | undefined;
}
/** True when a panel's params identify the built-in GitHub/GitLab review —
* either through the legacy `prKey`/`mrKey` param or the provider + reviewKey
* pair. */
function matchesBuiltInReview(
params: Record<string, unknown> | undefined,
providerId: "github" | "gitlab",
reviewKey: string,
legacyKey: "prKey" | "mrKey",
): boolean {
return (
params?.[legacyKey] === reviewKey ||
((params?.providerId === providerId || params?.provider === providerId) &&
params.reviewKey === reviewKey)
);
}
/** True when a panel's params match a fully-specified registered (non-GitHub/
* GitLab) review by provider, connection scope, repository, and change-request
* number. */
function matchesRegisteredReview(
params: Record<string, unknown> | undefined,
review: Required<ReviewMenuIdentity>,
): boolean {
return (
params?.providerId === review.providerId &&
params.connectionScope === review.connectionScope &&
params.repositoryId === review.repositoryId &&
params.changeRequestNumber !== undefined &&
String(params.changeRequestNumber) === String(review.changeRequestNumber)
);
}
/** True only when this exact review already has a canonical or keyed live panel. */
export function isReviewPanelOpen(
api: ReviewPanelLookup | null,
review: ReviewMenuIdentity,
): boolean {
if (!api) return false;
const canonicalParams = panelParams(api, "pr-detail");
if (review.providerId === "github") {
return (
Boolean(api.getPanel(`pr-detail|${review.reviewKey}`)) ||
matchesBuiltInReview(canonicalParams, "github", review.reviewKey, "prKey")
);
}
if (review.providerId === "gitlab") {
return (
Boolean(api.getPanel(`mr-detail|${review.reviewKey}`)) ||
matchesBuiltInReview(canonicalParams, "gitlab", review.reviewKey, "mrKey") ||
matchesBuiltInReview(panelParams(api, "mr-detail"), "gitlab", review.reviewKey, "mrKey")
);
}
const { connectionScope, repositoryId, changeRequestNumber } = review;
if (
connectionScope === undefined ||
repositoryId === undefined ||
changeRequestNumber === undefined
) {
return false;
}
const registeredReview = { ...review, connectionScope, repositoryId, changeRequestNumber };
return (
Boolean(api.getPanel(reviewPanelId(registeredReview))) ||
matchesRegisteredReview(canonicalParams, registeredReview)
);
}
/**
* Linked GitHub PR entries for the dockview "+" menu. A single PR renders as
* one inline row; multiple PRs collapse behind a "Pull requests" sub-menu so
* tasks with up to ten linked PRs don't stretch the main menu too tall.
*/
function PRPanelMenuItems({ prs, onOpenPR }: { prs: TaskPR[]; onOpenPR: (pr: TaskPR) => void }) {
const { t } = useTranslation();
if (prs.length === 0) return null;
if (prs.length === 1) {
const pr = prs[0];
return (
<DropdownMenuItem
onClick={() => onOpenPR(pr)}
className={MENU_ITEM_CLASS}
data-testid={`add-panel-pr-item-${prIdentitySlug(pr)}`}
>
<IconGitPullRequest className={MENU_ICON_CLASS} />
{prPanelLabel(pr.pr_number)}
</DropdownMenuItem>
);
}
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger
className={MENU_ITEM_CLASS}
data-testid={PR_SUBMENU_TEST_ID}
data-pr-count={prs.length}
>
<IconGitPullRequest className={MENU_ICON_CLASS} />
{t("task:pullRequests")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-[min(24rem,60vh)] w-52 overflow-y-auto">
{prs.map((pr) => (
<DropdownMenuItem
key={pr.id}
onClick={() => onOpenPR(pr)}
className={MENU_ITEM_CLASS}
data-testid={`add-panel-pr-item-${prIdentitySlug(pr)}`}
>
<IconGitPullRequest className={MENU_ICON_CLASS} />
{`${prPanelLabel(pr.pr_number)} - ${pr.owner}/${pr.repo}`}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
/** One "+" menu row per plugin-registered task panel (AC1), rendered after Plan. */
function PluginTaskPanelMenuItems({
groupId,
state,
}: {
groupId: string;
state: AddPanelMenuState;
}) {
usePluginRegistry();
const addPluginPanel = useDockviewStore((s) => s.addPluginPanel);
const sessionId = useAppStore((store) => store.tasks.activeSessionId);
let sessionKind: PluginSessionKind = null;
if (sessionId) {
sessionKind = state.isPassthrough ? "passthrough" : "managed";
}
const panels = state.taskId
? pluginRegistry.getTaskPanels().filter((registration) =>
registrationIsVisible(registration, {
taskId: state.taskId!,
sessionId,
sessionKind,
presentation: "desktop",
}),
)
: [];
return (
<>
{panels.map((registration) => {
const Icon = resolvePluginIcon(registration.icon);
const title = resolveTaskPanelTitle(registration);
return (
<DropdownMenuItem
key={`${registration.pluginId}:${registration.id}`}
onClick={() =>
addPluginPanel(registration.pluginId, registration.id, title, {
groupId,
})
}
className={MENU_ITEM_CLASS}
data-testid={`add-panel-plugin-item-${registration.pluginId}-${registration.id}`}
>
<Icon className={MENU_ICON_CLASS} />
{title}
</DropdownMenuItem>
);
})}
</>
);
}
function TaskCanvasMenuItems({ groupId, taskId }: { groupId: string; taskId: string | null }) {
const enabled = useFeature("canvases");
const workspaceId = useAppStore((state) => state.workspaces.activeId);
const userId = useAppStore((state) => canvasPresentationUserId(state.auth));
const canvases = useTaskCanvases(taskId, workspaceId, enabled).filter(isDiscoverableTaskCanvas);
const api = useDockviewStore((s) => s.api);
if (!enabled || canvases.length === 0) return null;
return (
<>
{canvases.map((canvas) => (
<DropdownMenuItem
key={canvas.id}
onClick={() =>
api &&
activateCanvasPanel(api, canvas, groupId, {
focusExisting: true,
presentation:
userId && taskId
? {
identity: {
userId,
workspaceId: canvas.workspace_id,
taskId,
canvasId: canvas.id,
},
reason: "manual",
}
: undefined,
})
}
className={MENU_ITEM_CLASS}
data-testid={`add-panel-canvas-item-${canvas.id}`}
>
<IconLayoutGrid className={MENU_ICON_CLASS} />
{canvas.title}
</DropdownMenuItem>
))}
</>
);
}
/** "+" menu row that opens a prompt-history panel in the given group. */
function PromptHistoryPanelMenuItem({ groupId }: { groupId: string }) {
const { t } = useTranslation();
const addPromptHistoryPanel = useDockviewStore((s) => s.addPromptHistoryPanel);
return (
<DropdownMenuItem
data-testid="add-panel-prompt-history-item"
onClick={() => addPromptHistoryPanel({ groupId })}
className={MENU_ITEM_CLASS}
>
<IconHistory className={MENU_ICON_CLASS} />
{t("task:promptHistory")}
</DropdownMenuItem>
);
}
/** Filters the linked PRs/MRs down to those whose review panel isn't already
* open, so the "+" menu doesn't offer duplicates. */
function missingBuiltInReviews(
api: ReviewPanelLookup | null,
state: Pick<AddPanelMenuState, "prs" | "mrs">,
): Pick<AddPanelMenuState, "prs" | "mrs"> {
return {
prs: state.prs.filter(
(pr) => !isReviewPanelOpen(api, { providerId: "github", reviewKey: prTaskKey(pr) }),
),
mrs: state.mrs.filter(
(mr) => !isReviewPanelOpen(api, { providerId: "gitlab", reviewKey: mrTaskKey(mr) }),
),
};
}
/** Renders the dockview "+" menu: session/terminal reopen entries, browser,
* VS Code, plan, port-forwarding toggle, plugin task panels, task canvases,
* todos, prompt history, changes/files, review panels, and repository scripts. */
export function AddPanelMenuItems({
groupId,
state,
onNewSession,
onAddTerminal,
onRunScript,
onRunDevScript,
}: AddPanelMenuItemsProps) {
const { t } = useTranslation();
const addBrowserPanel = useDockviewStore((s) => s.addBrowserPanel);
const addVscodePanel = useDockviewStore((s) => s.addVscodePanel);
const addPlanPanel = useDockviewStore((s) => s.addPlanPanel);
const addTodosPanel = useDockviewStore((s) => s.addTodosPanel);
const addFilesPanel = useDockviewStore((s) => s.addFilesPanel);
const addChangesPanel = useDockviewStore((s) => s.addChangesPanel);
const api = useDockviewStore((s) => s.api);
return (
<>
{state.taskId && (
<SessionReopenMenuItems
taskId={state.taskId}
groupId={groupId}
onNewSession={onNewSession}
/>
)}
<TerminalReopenMenuItems groupId={groupId} onNewTerminal={onAddTerminal} />
<DropdownMenuItem
onClick={() => addBrowserPanel(undefined, groupId)}
className={MENU_ITEM_CLASS}
>
<IconDeviceDesktop className={MENU_ICON_CLASS} />
{t("task:browser")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => addVscodePanel()} className={MENU_ITEM_CLASS}>
<IconBrandVscode className={MENU_ICON_CLASS} />
{t("task:vsCode")}
</DropdownMenuItem>
{!state.isPassthrough && (
<DropdownMenuItem onClick={() => addPlanPanel({ groupId })} className={MENU_ITEM_CLASS}>
<IconFileText className={MENU_ICON_CLASS} />
{t("task:plan")}
</DropdownMenuItem>
)}
{state.portForwarding && (
<DropdownMenuCheckboxItem
checked={state.portForwarding.enabled}
disabled={!state.portForwarding.canToggle || state.portForwarding.isUpdating}
onCheckedChange={() =>
void state.portForwarding?.togglePortForwarding({ openDialogOnEnable: true })
}
className={MENU_ITEM_CLASS}
data-testid="port-forwarding-menu-item"
>
<IconNetwork className={MENU_ICON_CLASS} />
{t("task:portForwarding")}
</DropdownMenuCheckboxItem>
)}
<PluginTaskPanelMenuItems groupId={groupId} state={state} />
<TaskCanvasMenuItems groupId={groupId} taskId={state.taskId} />
{!state.isPassthrough && (
<DropdownMenuItem onClick={() => addTodosPanel({ groupId })} className={MENU_ITEM_CLASS}>
<IconListCheck className={MENU_ICON_CLASS} />
{t("common:todos")}
</DropdownMenuItem>
)}
{!state.isPassthrough && <PromptHistoryPanelMenuItem groupId={groupId} />}
{!state.hasChanges && (
<DropdownMenuItem onClick={() => addChangesPanel(groupId)} className={MENU_ITEM_CLASS}>
<IconGitBranch className={MENU_ICON_CLASS} />
{t("task:changes")}
</DropdownMenuItem>
)}
{!state.hasFiles && (
<DropdownMenuItem onClick={() => addFilesPanel(groupId)} className={MENU_ITEM_CLASS}>
<IconFolder className={MENU_ICON_CLASS} />
{t("task:files")}
</DropdownMenuItem>
)}
<ReviewPanelMenuItems groupId={groupId} state={state} api={api} />
<RepositoryScriptsMenuItems onRunScript={onRunScript} onRunDevScript={onRunDevScript} />
</>
);
}
/** Renders the "+" menu's review rows: linked PRs/MRs (skipping ones already
* open) plus registered non-GitHub/GitLab review panels. */
function ReviewPanelMenuItems({
groupId,
state,
api,
}: Pick<AddPanelMenuItemsProps, "groupId" | "state"> & { api: ReviewPanelLookup | null }) {
const { t } = useTranslation();
const addPRPanel = useDockviewStore((s) => s.addPRPanel);
const addMRPanel = useDockviewStore((s) => s.addMRPanel);
const addReviewPanel = useDockviewStore((s) => s.addReviewPanel);
const { prs, mrs } = missingBuiltInReviews(api, state);
const reviews = useNormalizedTaskReviews(state.taskId)
.filter((review) => review.providerId !== "github" && review.providerId !== "gitlab")
.filter((review) => !isReviewPanelOpen(api, review));
return (
<>
<PRPanelMenuItems prs={prs} onOpenPR={(pr) => addPRPanel(prTaskKey(pr), { groupId })} />
{mrs.map((mr) => (
<DropdownMenuItem
key={mr.id}
onClick={() => addMRPanel(mrTaskKey(mr), { groupId })}
className={MENU_ITEM_CLASS}
data-testid={`add-panel-mr-item-${mr.id}`}
>
<IconGitPullRequest className={`${MENU_ICON_CLASS} text-orange-500`} />
{mrs.length > 1
? `MR !${mr.mr_iid} - ${mr.project_path}`
: t("task:mergeRequest", { mriid: mr.mr_iid })}
</DropdownMenuItem>
))}
{reviews.map((review) => (
<DropdownMenuItem
key={reviewItemId(review)}
onClick={() => addReviewPanel(review, { groupId })}
className={MENU_ITEM_CLASS}
data-testid={`add-panel-review-item-${reviewItemId(review)}`}
>
<IconGitPullRequest className={MENU_ICON_CLASS} />
{review.title}
</DropdownMenuItem>
))}
</>
);
}