Files
kandev/apps/web/components/settings/system/bundle-customizer.tsx
T
2026-09-21 18:22:29 +01:00

534 lines
17 KiB
TypeScript

"use client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert";
import { Button } from "@kandev/ui/button";
import { Checkbox } from "@kandev/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@kandev/ui/dialog";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from "@kandev/ui/drawer";
import { Spinner } from "@kandev/ui/spinner";
import { IconExternalLink } from "@tabler/icons-react";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import TaskLink from "@/components/routing/task-link";
import type {
DiagnosticBundleCapabilities,
DiagnosticBundleSource,
DiagnosticSession,
} from "@/lib/types/system";
type BundleCustomizerProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
capabilities: DiagnosticBundleCapabilities | null;
sessions: DiagnosticSession[];
sessionsLoading: boolean;
selectedSources: DiagnosticBundleSource[];
selectedSessionIDs: string[];
submitting: boolean;
onSourcesChange: (sources: DiagnosticBundleSource[]) => void;
onSessionChange: (sessionID: string, checked: boolean) => void;
onSessionSelectionChange: (sessionIDs: string[]) => void;
onSubmit: () => void;
};
const SOURCE_ORDER: DiagnosticBundleSource[] = ["backend", "frontend", "runtime", "acp"];
const SOURCE_COPY: Record<DiagnosticBundleSource, { label: string; description: string }> = {
backend: {
label: "settings:diagnosticSourceBackend",
description: "settings:diagnosticSourceBackendDescription",
},
frontend: {
label: "settings:diagnosticSourceFrontend",
description: "settings:diagnosticSourceFrontendDescription",
},
runtime: {
label: "settings:diagnosticSourceRuntime",
description: "settings:diagnosticSourceRuntimeDescription",
},
acp: {
label: "settings:diagnosticSourceACP",
description: "settings:diagnosticSourceACPDescription",
},
};
export function BundleCustomizer(props: BundleCustomizerProps) {
const { t } = useTranslation();
const { isMobile } = useResponsiveBreakpoint();
const sourceOptions = useMemo(() => {
const available = new Set<DiagnosticBundleSource>(
props.capabilities?.sources ?? ["backend", "frontend", "runtime"],
);
return SOURCE_ORDER.filter(
(source) =>
available.has(source) &&
(source !== "acp" || props.capabilities?.acp_debug_enabled === true),
);
}, [props.capabilities?.acp_debug_enabled, props.capabilities?.sources]);
const acpSelected = props.selectedSources.includes("acp");
const selectableSessions = props.sessions.filter(
(session) => session.acp_availability !== "unavailable",
);
const validationKey = validationMessageKey(
props.selectedSources,
acpSelected,
props.selectedSessionIDs.length,
selectableSessions.length,
props.capabilities?.acp_max_sessions ?? 10,
);
const content = (
<CustomizerContent
sourceOptions={sourceOptions}
selectedSources={props.selectedSources}
selectedSessionIDs={props.selectedSessionIDs}
sessions={props.sessions}
sessionsLoading={props.sessionsLoading}
acpSelected={acpSelected}
selectableSessions={selectableSessions}
maxSessions={props.capabilities?.acp_max_sessions ?? 10}
onSourcesChange={props.onSourcesChange}
onSessionChange={props.onSessionChange}
onSessionSelectionChange={props.onSessionSelectionChange}
/>
);
const footer = (
<CustomizerFooter
submitting={props.submitting}
invalid={validationKey !== null}
onCancel={() => props.onOpenChange(false)}
onSubmit={props.onSubmit}
/>
);
const validation = validationKey ? (
<p className="mt-3 text-sm text-destructive" role="alert">
{validationMessage(validationKey, t)}
</p>
) : null;
if (isMobile) {
return (
<Drawer open={props.open} onOpenChange={props.onOpenChange}>
<DrawerContent
data-testid="diagnostic-bundle-drawer"
className="h-[min(88dvh,680px)] max-h-[calc(100dvh-16px-env(safe-area-inset-bottom,0px))] min-w-0 overflow-hidden p-0"
>
<DrawerHeader className="shrink-0 border-b border-border px-4 py-3 text-left">
<DrawerTitle>{t("settings:diagnosticCustomizerTitle")}</DrawerTitle>
<DrawerDescription>{t("settings:diagnosticCustomizerDescription")}</DrawerDescription>
</DrawerHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4">
{content}
{validation}
</div>
{footer}
</DrawerContent>
</Drawer>
);
}
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent
data-testid="diagnostic-bundle-dialog"
className="flex max-h-[88dvh] w-[calc(100vw-2rem)] max-w-[calc(100vw-2rem)] min-w-0 flex-col overflow-hidden p-0 sm:max-w-5xl"
>
<DialogHeader className="shrink-0 border-b border-border px-4 py-3 pr-12 text-left">
<DialogTitle>{t("settings:diagnosticCustomizerTitle")}</DialogTitle>
<DialogDescription>{t("settings:diagnosticCustomizerDescription")}</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4">
{content}
{validation}
</div>
{footer}
</DialogContent>
</Dialog>
);
}
type CustomizerContentProps = {
sourceOptions: DiagnosticBundleSource[];
selectedSources: DiagnosticBundleSource[];
selectedSessionIDs: string[];
sessions: DiagnosticSession[];
sessionsLoading: boolean;
acpSelected: boolean;
selectableSessions: DiagnosticSession[];
maxSessions: number;
onSourcesChange: (sources: DiagnosticBundleSource[]) => void;
onSessionChange: (sessionID: string, checked: boolean) => void;
onSessionSelectionChange: (sessionIDs: string[]) => void;
};
function CustomizerContent({
sourceOptions,
selectedSources,
selectedSessionIDs,
sessions,
sessionsLoading,
acpSelected,
selectableSessions,
maxSessions,
onSourcesChange,
onSessionChange,
onSessionSelectionChange,
}: CustomizerContentProps) {
const { t } = useTranslation();
return (
<div className="space-y-6">
{acpSelected && (
<Alert variant="destructive">
<AlertTitle>{t("settings:diagnosticSourceACP")}</AlertTitle>
<AlertDescription>{t("settings:diagnosticACPWarning")}</AlertDescription>
</Alert>
)}
<SourceSection
sourceOptions={sourceOptions}
selectedSources={selectedSources}
onSourcesChange={onSourcesChange}
/>
{acpSelected && (
<SessionSection
sessions={sessions}
sessionsLoading={sessionsLoading}
selectableSessions={selectableSessions}
selectedSessionIDs={selectedSessionIDs}
maxSessions={maxSessions}
onSessionChange={onSessionChange}
onSessionSelectionChange={onSessionSelectionChange}
/>
)}
</div>
);
}
type CustomizerFooterProps = {
submitting: boolean;
invalid: boolean;
onCancel: () => void;
onSubmit: () => void;
};
function CustomizerFooter({ submitting, invalid, onCancel, onSubmit }: CustomizerFooterProps) {
const { t } = useTranslation();
return (
<div className="flex shrink-0 justify-end gap-2 border-t border-border px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
<Button
type="button"
variant="outline"
className="cursor-pointer"
onClick={onCancel}
disabled={submitting}
>
{t("settings:diagnosticBundleCancel")}
</Button>
<Button
type="button"
className="cursor-pointer"
disabled={submitting || invalid}
onClick={onSubmit}
data-testid="create-custom-diagnostic-bundle"
>
{submitting && <Spinner className="mr-2 size-4" />}
{t("settings:diagnosticBundleCreate")}
</Button>
</div>
);
}
type SourceSectionProps = {
sourceOptions: DiagnosticBundleSource[];
selectedSources: DiagnosticBundleSource[];
onSourcesChange: (sources: DiagnosticBundleSource[]) => void;
};
function SourceSection({ sourceOptions, selectedSources, onSourcesChange }: SourceSectionProps) {
const { t } = useTranslation();
const setSource = (source: DiagnosticBundleSource, checked: boolean) => {
const next = checked
? [...selectedSources, source]
: selectedSources.filter((selected) => selected !== source);
onSourcesChange(SOURCE_ORDER.filter((candidate) => next.includes(candidate)));
};
return (
<section aria-labelledby="diagnostic-sources-heading" className="space-y-3">
<h3 id="diagnostic-sources-heading" className="text-sm font-semibold">
{t("settings:diagnosticSourcesTitle")}
</h3>
<div className="space-y-2">
{sourceOptions.map((source) => (
<SourceRow
key={source}
source={source}
checked={selectedSources.includes(source)}
onCheckedChange={(checked) => setSource(source, checked)}
/>
))}
</div>
</section>
);
}
function SourceRow({
source,
checked,
onCheckedChange,
}: {
source: DiagnosticBundleSource;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
}) {
const { t } = useTranslation();
const copy = SOURCE_COPY[source];
return (
<label className="flex min-h-11 cursor-pointer items-start gap-3 rounded-lg border border-border/70 p-3 transition-colors hover:bg-muted/50">
<Checkbox
checked={checked}
onCheckedChange={(value) => onCheckedChange(value === true)}
className="mt-0.5"
aria-label={t(copy.label)}
/>
<span className="min-w-0">
<span className="block text-sm font-medium">{t(copy.label)}</span>
<span className="block text-xs text-muted-foreground">{t(copy.description)}</span>
</span>
</label>
);
}
type SessionSectionProps = {
sessions: DiagnosticSession[];
sessionsLoading: boolean;
selectableSessions: DiagnosticSession[];
selectedSessionIDs: string[];
maxSessions: number;
onSessionChange: (sessionID: string, checked: boolean) => void;
onSessionSelectionChange: (sessionIDs: string[]) => void;
};
function SessionSection({
sessions,
sessionsLoading,
selectableSessions,
selectedSessionIDs,
maxSessions,
onSessionChange,
onSessionSelectionChange,
}: SessionSectionProps) {
const { t } = useTranslation();
return (
<section aria-labelledby="diagnostic-sessions-heading" className="space-y-3">
<div>
<h3 id="diagnostic-sessions-heading" className="text-sm font-semibold">
{t("settings:diagnosticSelectSessions")}
</h3>
<p className="text-xs text-muted-foreground">
{t("settings:diagnosticSessionsDescription", { count: maxSessions })}
</p>
</div>
<SessionResults
sessions={sessions}
sessionsLoading={sessionsLoading}
selectableSessions={selectableSessions}
selectedSessionIDs={selectedSessionIDs}
maxSessions={maxSessions}
onSessionChange={onSessionChange}
onSessionSelectionChange={onSessionSelectionChange}
/>
</section>
);
}
function SessionResults({
sessions,
sessionsLoading,
selectableSessions,
selectedSessionIDs,
maxSessions,
onSessionChange,
onSessionSelectionChange,
}: SessionSectionProps) {
const { t } = useTranslation();
if (sessionsLoading) {
return (
<div className="flex min-h-11 items-center gap-2 text-sm text-muted-foreground">
<Spinner className="size-4" />
{t("settings:diagnosticSessionsLoading")}
</div>
);
}
if (sessions.length === 0) {
return (
<p className="rounded-lg border border-dashed border-border p-3 text-sm text-muted-foreground">
{t("settings:diagnosticACPNoSessions")}
</p>
);
}
const selectAll = () => {
onSessionSelectionChange(
selectableSessions.slice(0, maxSessions).map((session) => session.session_id),
);
};
return (
<div className="space-y-2">
<SessionSelectionActions
hasSelectableSessions={selectableSessions.length > 0}
hasSelectedSessions={selectedSessionIDs.length > 0}
onSelectAll={selectAll}
onClear={() => onSessionSelectionChange([])}
/>
{sessions.map((session) => (
<SessionRow
key={session.session_id}
session={session}
selectable={selectableSessions.includes(session)}
checked={selectedSessionIDs.includes(session.session_id)}
maxReached={selectedSessionIDs.length >= maxSessions}
onSessionChange={onSessionChange}
/>
))}
<p className="text-xs text-muted-foreground">
{t("settings:diagnosticSelectedSessions", { count: selectedSessionIDs.length })}
</p>
</div>
);
}
function SessionSelectionActions({
hasSelectableSessions,
hasSelectedSessions,
onSelectAll,
onClear,
}: {
hasSelectableSessions: boolean;
hasSelectedSessions: boolean;
onSelectAll: () => void;
onClear: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="button"
variant="outline"
className="cursor-pointer sm:w-auto"
disabled={!hasSelectableSessions}
onClick={onSelectAll}
data-testid="select-all-acp-sessions"
>
{t("settings:diagnosticSelectAllACPSessions")}
</Button>
<Button
type="button"
variant="ghost"
className="cursor-pointer sm:w-auto"
disabled={!hasSelectedSessions}
onClick={onClear}
data-testid="clear-acp-session-selection"
>
{t("settings:diagnosticClearACPSelection")}
</Button>
</div>
);
}
function SessionRow({
session,
selectable,
checked,
maxReached,
onSessionChange,
}: {
session: DiagnosticSession;
selectable: boolean;
checked: boolean;
maxReached: boolean;
onSessionChange: (sessionID: string, checked: boolean) => void;
}) {
const { t } = useTranslation();
const title = session.agent || session.provider || session.session_id;
const taskTitle = session.task_title || session.task_id;
const details =
[session.model, session.status, session.executor_type].filter(Boolean).join(" · ") ||
session.session_id;
const checkboxID = `diagnostic-acp-session-${session.session_id}`;
return (
<div className="flex min-h-11 items-start gap-3 rounded-lg border border-border/70 p-3 transition-colors hover:bg-muted/50">
<Checkbox
id={checkboxID}
checked={checked}
disabled={!selectable || (!checked && maxReached)}
onCheckedChange={(value) => onSessionChange(session.session_id, value === true)}
className="mt-0.5"
aria-label={session.session_id}
/>
<div className="min-w-0 flex-1">
<TaskLink
taskId={session.task_id}
target="_blank"
rel="noopener noreferrer"
className="flex w-fit max-w-full items-center gap-1 truncate text-sm font-medium text-foreground underline-offset-4 hover:underline focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t("settings:diagnosticOpenTask", { title: taskTitle })}
data-testid={`acp-session-task-link-${session.session_id}`}
>
<span className="truncate">{taskTitle}</span>
<IconExternalLink className="size-3 shrink-0" />
</TaskLink>
<label htmlFor={checkboxID} className="mt-1 block min-h-11 cursor-pointer">
<span className="block truncate text-sm font-medium">{title}</span>
<span className="block truncate text-xs text-muted-foreground">{details}</span>
</label>
</div>
<span className="mt-0.5 shrink-0 text-xs text-muted-foreground">
{availabilityLabel(session.acp_availability, t)}
</span>
</div>
);
}
function validationMessageKey(
sources: DiagnosticBundleSource[],
acpSelected: boolean,
selectedSessionCount: number,
selectableSessionCount: number,
maxSessions: number,
): string | null {
if (sources.length === 0) return "diagnosticSourceRequired";
if (acpSelected && selectedSessionCount > maxSessions) return "diagnosticACPSelectionLimit";
if (acpSelected && (selectableSessionCount === 0 || selectedSessionCount === 0)) {
return "diagnosticACPSelectionRequired";
}
return null;
}
function availabilityLabel(
availability: DiagnosticSession["acp_availability"],
t: (key: string) => string,
): string {
if (availability === "reachable") return t("settings:diagnosticSessionReachable");
if (availability === "host_retained") return t("settings:diagnosticSessionRetained");
return t("settings:diagnosticSessionUnavailable");
}
function validationMessage(key: string, t: (key: string) => string): string {
if (key === "diagnosticSourceRequired") return t("settings:diagnosticSourceRequired");
if (key === "diagnosticACPSelectionRequired") {
return t("settings:diagnosticACPSelectionRequired");
}
return t("settings:diagnosticACPSelectionLimit");
}