Files
kandev/apps/web/components/task/simple/components/run-error-entry.tsx
T
2026-09-07 11:19:09 +01:00

298 lines
9.2 KiB
TypeScript

"use client";
import { useState } from "react";
import {
IconAlertTriangle,
IconChevronDown,
IconPlayerPlay,
IconRefresh,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible";
import { useAppStore } from "@/components/state-provider";
import { selectOfficeAgentProfiles } from "@/lib/state/slices/office/selectors";
import { formatRelativeTime } from "@/lib/utils";
import { AgentAvatar } from "@/app/office/components/agent-avatar";
import { RemediationLink } from "@/components/task/remediation-link";
import {
EnsureSessionErrorBanner,
SessionRecoveryNotice,
} from "@/components/task/ensure-session-error";
import { useSessionRecoveryActions } from "@/hooks/domains/session/use-session-recovery-actions";
import type {
BranchRecoveryDetails,
SessionRecoveryAction,
} from "@/lib/services/session-recovery-service";
import type { RunError } from "@/app/office/tasks/[id]/types";
import type { TaskRepository } from "@/lib/types/http";
import { ManagedRuntimeNpmRunError } from "./managed-runtime-npm-run-error";
import { isLaunchErrorCategory, TaskLaunchErrorEntry } from "./task-launch-error-entry";
import { useTranslation } from "react-i18next";
type RunErrorEntryProps = {
taskId: string;
workspaceId?: string;
repositories?: TaskRepository[];
error: RunError;
};
function TypedRunLaunchErrorEntry({
taskId,
workspaceId,
repositories,
error,
preview,
}: RunErrorEntryProps & { preview: string }) {
return (
<TaskLaunchErrorEntry
taskId={taskId}
workspaceId={workspaceId ?? ""}
repositories={repositories}
error={{
session_id: error.sessionId,
task_repository_id: error.taskRepositoryId,
stamp: error.errorStamp ?? "",
occurred_at: error.failedAt,
preview,
details: error.failureDetails,
category: error.failureCode,
recovery_actions: error.recoveryActions,
}}
/>
);
}
function RunErrorRecoveryFeedback({
workspaceId,
recoveryError,
recoveryNotice,
branchDetails,
busyAction,
onRetry,
onRestore,
onNewBranch,
}: {
workspaceId: string;
recoveryError: Error | null;
recoveryNotice: string | null;
branchDetails: BranchRecoveryDetails | null;
busyAction: SessionRecoveryAction | "restore" | null;
onRetry: () => void;
onRestore: () => void;
onNewBranch: () => void;
}) {
const { t } = useTranslation();
if (!recoveryError && !recoveryNotice) return null;
return (
<>
{recoveryError ? (
<EnsureSessionErrorBanner
error={recoveryError}
onRetry={onRetry}
retryDisabled={busyAction !== null}
workspaceId={workspaceId}
compact
action={
branchDetails
? {
label: t("task:continueOnNewBranch"),
onClick: onNewBranch,
testId: "run-error-continue-new-branch-button",
disabled: busyAction !== null,
}
: {
label: t("task:restoreReadOnlyWorkspace"),
onClick: onRestore,
testId: "run-error-restore-workspace-button",
disabled: busyAction !== null,
}
}
secondaryAction={
branchDetails
? {
label: t("task:restoreReadOnlyWorkspace"),
onClick: onRestore,
testId: "run-error-restore-workspace-button",
disabled: busyAction !== null,
}
: undefined
}
testId="run-error-recovery-error"
/>
) : null}
{recoveryNotice ? <SessionRecoveryNotice message={recoveryNotice} /> : null}
</>
);
}
function LegacyRunErrorEntry({
agentName,
error,
onRecover,
onRetry,
onRestore,
onNewBranch,
workspaceId,
recoveryError,
recoveryNotice,
branchDetails,
busyAction,
}: {
agentName: string;
error: RunError;
onRecover: (action: SessionRecoveryAction) => Promise<boolean>;
onRetry: () => void;
onRestore: () => void;
onNewBranch: () => void;
workspaceId: string;
recoveryError: Error | null;
recoveryNotice: string | null;
branchDetails: BranchRecoveryDetails | null;
busyAction: SessionRecoveryAction | "restore" | null;
}) {
const { t } = useTranslation();
const [showDetails, setShowDetails] = useState(false);
return (
<div className="flex gap-3 py-3 border-b border-border/50">
<AgentAvatar name={agentName} size="md" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm">{agentName}</span>
<span className="inline-flex items-center gap-1 text-xs text-red-600 dark:text-red-400">
<IconAlertTriangle className="h-3.5 w-3.5" />
{t("task:stoppedWithAnError")}
</span>
<span className="text-xs text-muted-foreground">
{formatRelativeTime(error.failedAt)}
</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">{t("task:theAgentStoppedWithAnError")}</p>
<RunErrorRecoveryFeedback
workspaceId={workspaceId}
recoveryError={recoveryError}
recoveryNotice={recoveryNotice}
branchDetails={branchDetails}
busyAction={busyAction}
onRetry={onRetry}
onRestore={onRestore}
onNewBranch={onNewBranch}
/>
{error.rawPayload && (
<Collapsible open={showDetails} onOpenChange={setShowDetails} className="mt-2">
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground transition-colors">
<IconChevronDown
className={`h-3.5 w-3.5 transition-transform ${showDetails ? "rotate-180" : ""}`}
/>
{t("task:showDetails")}
</CollapsibleTrigger>
<CollapsibleContent>
<pre
className="mt-1 text-[11px] font-mono text-muted-foreground bg-muted/50 rounded p-2 overflow-auto max-h-[300px] whitespace-pre-wrap break-words"
data-testid="run-error-raw-payload"
>
{error.rawPayload}
</pre>
</CollapsibleContent>
</Collapsible>
)}
<div className="mt-2 flex items-center gap-2 flex-wrap">
<RemediationLink url={error.remediationUrl} />
<Button
variant="outline"
size="sm"
className="h-auto min-h-11 cursor-pointer gap-1.5 text-xs sm:min-h-8"
onClick={() => onRecover("resume")}
disabled={busyAction !== null}
data-testid="run-error-resume-button"
>
<IconRefresh className="h-3 w-3" />
{t("task:resumeSession")}
</Button>
<Button
variant="outline"
size="sm"
className="h-auto min-h-11 cursor-pointer gap-1.5 text-xs sm:min-h-8"
onClick={() => onRecover("fresh_start")}
disabled={busyAction !== null}
data-testid="run-error-fresh-button"
>
<IconPlayerPlay className="h-3 w-3" />
{t("task:startFreshSession")}
</Button>
</div>
</div>
</div>
);
}
/**
* Top-level chat entry rendered when an office session is in FAILED
* state. Replaces the legacy red action-message banner: shows a short
* generic header, a Show details collapsible exposing the verbatim
* raw payload (for bug reports), and the Resume / Start fresh
* buttons. Click handlers wire to the existing `session.recover` WS
* request so the recovery semantics are unchanged.
*/
export function RunErrorEntry({
taskId,
workspaceId = "",
repositories,
error,
}: RunErrorEntryProps) {
const { t } = useTranslation();
const agentName = useAppStore(
(s) =>
selectOfficeAgentProfiles(s).find((a) => a.id === error.agentProfileId)?.name ??
t("task:agent"),
);
const {
busyAction,
recoveryError,
branchDetails,
recoveryNotice,
handleRecover,
handleRestore,
handleRetry,
handleNewBranch,
} = useSessionRecoveryActions({ taskId, sessionId: error.sessionId });
if (isLaunchErrorCategory(error.failureCode) && error.errorStamp) {
return (
<TypedRunLaunchErrorEntry
taskId={taskId}
workspaceId={workspaceId}
repositories={repositories}
error={error}
preview={error.message ?? t("task:launchErrorSessionPreview")}
/>
);
}
if (error.failureCode === "managed_runtime_npm_resolution") {
return (
<ManagedRuntimeNpmRunError
error={error}
agentName={agentName}
onRetry={() => void handleRecover("runtime_retry")}
/>
);
}
return (
<LegacyRunErrorEntry
agentName={agentName}
error={error}
onRecover={handleRecover}
onRetry={handleRetry}
onRestore={() => void handleRestore()}
onNewBranch={handleNewBranch}
workspaceId={workspaceId}
recoveryError={recoveryError}
recoveryNotice={recoveryNotice}
branchDetails={branchDetails}
busyAction={busyAction}
/>
);
}