Files
kandev/apps/web/components/task/ensure-session-error.tsx
T
2026-09-13 12:14:45 +01:00

350 lines
11 KiB
TypeScript

"use client";
import Link from "@/components/routing/app-link";
import { IconAlertTriangle, IconInfoCircle, IconRefresh } from "@tabler/icons-react";
import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert";
import { Button } from "@kandev/ui/button";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
import { cn } from "@/lib/utils";
import type { SessionRecoveryFailure } from "@/hooks/domains/session/use-session-resumption";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
// EnsureSessionErrorInfo wraps a parsed ensure error so UI can offer a targeted action for the missing-agent-profile case.
export type EnsureSessionErrorInfo = {
title: string;
detail: string;
isAgentProfileMissing: boolean;
action: { label: string; href: string } | null;
};
// Matches the backend's exact validation message in task_http_handlers.go / task_ws_handlers.go.
// i18n-exempt: matches the backend's exact validation message. See the comment above.
const AGENT_PROFILE_MISSING_HINT = "agent_profile_id is required";
export function describeEnsureError(
error: Error | null,
workspaceId?: string | null,
): EnsureSessionErrorInfo | null {
if (!error) return null;
const message = error.message ?? "";
const isAgentProfileMissing = message.toLowerCase().includes(AGENT_PROFILE_MISSING_HINT);
if (isAgentProfileMissing) {
return {
title: t("task:noAgentProfileConfigured"),
detail: t("task:noAgentProfileConfiguredDetail"),
isAgentProfileMissing: true,
action: workspaceId
? {
label: t("task:openWorkspaceSettings"),
href: `/settings/workspaces/${workspaceId}`,
}
: null,
};
}
return {
title: t("task:couldnTStartASession"),
detail: message || t("task:backendRejectedSessionRequest"),
isAgentProfileMissing: false,
action: null,
};
}
type RecoveryAction = {
label: string;
onClick: () => void;
testId: string;
disabled?: boolean;
};
export function getSessionRecoveryRetry(source: {
recoveryFailure: SessionRecoveryFailure | null;
resumeSession: () => Promise<boolean>;
retrySessionStatus: () => Promise<void>;
}): () => void {
if (source.recoveryFailure?.outcome === "status_unavailable") {
return () => {
void source.retrySessionStatus();
};
}
return () => {
void source.resumeSession();
};
}
type BannerProps = {
error: Error | null;
onRetry: () => void;
workspaceId?: string | null;
action?: RecoveryAction;
secondaryAction?: RecoveryAction;
retryDisabled?: boolean;
recoveryFailure?: SessionRecoveryFailure | null;
testId?: string;
compact?: boolean;
};
function RecoveryFailureDetails({ failure }: { failure: SessionRecoveryFailure }) {
const { t } = useTranslation();
if (failure.outcome === "status_unavailable") return null;
return (
<details className="mt-2 min-w-0 text-xs" data-testid="session-recovery-details">
<summary
className="block min-h-11 max-w-full cursor-pointer select-none rounded-sm py-3 underline underline-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-testid="session-recovery-details-summary"
>
{t("task:sessionRecoveryDetails")}
</summary>
<dl className="mt-2 grid min-w-0 gap-2">
<div className="min-w-0">
<dt className="font-medium">{t("task:sessionRecoveryResumeAttempt")}</dt>
<dd className="mt-0.5 break-words text-muted-foreground">{failure.resumeError}</dd>
</div>
{failure.outcome === "recovery_failed" ? (
<div className="min-w-0">
<dt className="font-medium">{t("task:sessionRecoveryRestoreAttempt")}</dt>
<dd className="mt-0.5 break-words text-muted-foreground">{failure.restoreError}</dd>
</div>
) : null}
</dl>
</details>
);
}
function SessionStatusUnavailableNotice({
failure,
onRetry,
retryDisabled,
}: {
failure: Extract<SessionRecoveryFailure, { outcome: "status_unavailable" }>;
onRetry: () => void;
retryDisabled?: boolean;
}) {
const { t } = useTranslation();
const { isFinePointer } = useResponsiveBreakpoint();
return (
<div className="px-3 pt-2" data-testid="session-status-unavailable">
<Alert>
<IconInfoCircle />
<AlertTitle>{t("task:sessionStatusUnavailable")}</AlertTitle>
<AlertDescription>
<span>{t("task:sessionStatusUnavailableDetail")}</span>
<details className="mt-2 min-w-0 text-xs" data-testid="session-status-details">
<summary
className={cn(
"block max-w-full cursor-pointer select-none rounded-sm py-1 underline underline-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isFinePointer ? "min-h-7" : "min-h-11 py-3",
)}
data-testid="session-status-details-summary"
>
{t("task:details")}
</summary>
<p className="mt-2 break-words text-muted-foreground">{failure.statusError}</p>
</details>
<Button
variant="outline"
size="sm"
className={cn("mt-2 cursor-pointer px-2 text-xs", !isFinePointer && "min-h-11")}
onClick={onRetry}
disabled={retryDisabled}
data-testid="session-status-retry"
>
<IconRefresh className="size-3" aria-hidden="true" />
{t("task:retry")}
</Button>
</AlertDescription>
</Alert>
</div>
);
}
/** Slim banner for the task page, rendered above the layout. */
export function EnsureSessionErrorBanner({
error,
onRetry,
workspaceId,
action,
secondaryAction,
retryDisabled,
recoveryFailure,
testId = "ensure-session-error-banner",
compact = false,
}: BannerProps) {
const { t } = useTranslation();
const info = describeEnsureError(error, workspaceId);
if (!info) return null;
const failedRecovery = recoveryFailure?.outcome === "recovery_failed" ? recoveryFailure : null;
return (
<div className={cn(!compact && "px-3 pt-2")} data-testid={testId}>
<Alert variant="destructive">
<IconAlertTriangle />
<AlertTitle>{failedRecovery ? t("task:sessionRecoveryFailed") : info.title}</AlertTitle>
<AlertDescription>
<span>{failedRecovery ? t("task:sessionRecoveryFailedDetail") : info.detail}</span>
{failedRecovery ? <RecoveryFailureDetails failure={failedRecovery} /> : null}
<span className="mt-1 flex flex-wrap items-center gap-2">
{info.action ? (
<Link
href={info.action.href}
className="cursor-pointer underline underline-offset-2 hover:text-foreground"
data-testid="ensure-session-error-action"
>
{info.action.label}
</Link>
) : null}
{action ? (
<Button
variant="outline"
size="sm"
className="min-h-11 cursor-pointer px-2 text-xs"
onClick={action.onClick}
disabled={action.disabled}
data-testid={action.testId}
>
{action.label}
</Button>
) : null}
{secondaryAction ? (
<Button
variant="outline"
size="sm"
className="min-h-11 cursor-pointer px-2 text-xs"
onClick={secondaryAction.onClick}
disabled={secondaryAction.disabled}
data-testid={secondaryAction.testId}
>
{secondaryAction.label}
</Button>
) : null}
<Button
variant="outline"
size="sm"
className="min-h-11 cursor-pointer px-2 text-xs"
onClick={onRetry}
disabled={retryDisabled}
data-testid="ensure-session-error-retry"
>
<IconRefresh className="size-3" />
{t("task:retry")}
</Button>
</span>
</AlertDescription>
</Alert>
</div>
);
}
/** Non-blocking result notice used when the workspace remains available read-only. */
export function SessionRecoveryNotice({
message,
recoveryFailure,
}: {
message: string;
recoveryFailure?: Extract<SessionRecoveryFailure, { outcome: "workspace_read_only" }> | null;
}) {
return (
<div className="px-3 pt-2" data-testid="session-recovery-notice">
<Alert>
<IconInfoCircle />
<AlertDescription>
<span>{message}</span>
{recoveryFailure ? <RecoveryFailureDetails failure={recoveryFailure} /> : null}
</AlertDescription>
</Alert>
</div>
);
}
/** Shared inline rendering for automatic resume failures and read-only notices. */
export function SessionRecoveryFeedback({
error,
notice,
onRetry,
workspaceId,
action,
secondaryAction,
retryDisabled,
recoveryFailure,
testId = "session-recovery-error",
}: {
error: string | null;
notice: string | null;
onRetry: () => void;
workspaceId?: string | null;
action?: RecoveryAction;
secondaryAction?: RecoveryAction;
retryDisabled?: boolean;
recoveryFailure?: SessionRecoveryFailure | null;
testId?: string;
}) {
const readOnlyRecovery =
recoveryFailure?.outcome === "workspace_read_only" ? recoveryFailure : null;
const statusFailure = recoveryFailure?.outcome === "status_unavailable" ? recoveryFailure : null;
return (
<>
{statusFailure ? (
<SessionStatusUnavailableNotice
failure={statusFailure}
onRetry={onRetry}
retryDisabled={retryDisabled}
/>
) : null}
<EnsureSessionErrorBanner
error={error ? new Error(error) : null}
onRetry={onRetry}
workspaceId={workspaceId}
action={action}
secondaryAction={secondaryAction}
retryDisabled={retryDisabled}
recoveryFailure={recoveryFailure}
testId={testId}
/>
{notice ? (
<SessionRecoveryNotice message={notice} recoveryFailure={readOnlyRecovery} />
) : null}
</>
);
}
/** Full-panel centered state for the kanban preview's empty-sessions slot. */
export function EnsureSessionErrorEmptyState({
error,
onRetry,
workspaceId,
retryDisabled,
}: BannerProps) {
const { t } = useTranslation();
const info = describeEnsureError(error, workspaceId);
if (!info) return null;
return (
<div
className="flex h-full flex-col items-center justify-center gap-3 px-4 text-center text-sm"
data-testid="preview-ensure-error"
>
<span className="font-medium text-foreground">{info.title}</span>
<span className="max-w-xs text-muted-foreground">{info.detail}</span>
<span className="flex flex-wrap items-center justify-center gap-2">
{info.action ? (
<Link
href={info.action.href}
className="cursor-pointer underline underline-offset-2 hover:text-foreground"
data-testid="ensure-session-error-action"
>
{info.action.label}
</Link>
) : null}
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={onRetry}
disabled={retryDisabled}
data-testid="ensure-session-error-retry"
>
{t("task:retry")}
</Button>
</span>
</div>
);
}