586 lines
19 KiB
TypeScript
586 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useMemo, memo, type ReactElement } from "react";
|
|
import { Trans, useTranslation } from "react-i18next";
|
|
import { IconAlertTriangle } from "@tabler/icons-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { useActionMessageSession, useAgentBootOutcomeAfterMessage } from "./action-message-state";
|
|
import type { Message, TaskSessionState } from "@/lib/types/http";
|
|
import type { MessageAction } from "@/components/task/chat/types";
|
|
import { ActionMessageDetails, type ActionMeta } from "./action-message-details";
|
|
import { formatDateTime } from "@/lib/i18n/formats";
|
|
import { parseRetryAt, retryCountdownLabel } from "./transient-retry";
|
|
import { hasSessionRecoveryResolutionAfter } from "@/hooks/processed-message-filtering";
|
|
import { ActionButtons } from "./action-message-actions";
|
|
import { SessionRecoveryActionButtons, sessionRecoveryAction } from "./action-message-recovery";
|
|
import {
|
|
lastAgentErrorStamp,
|
|
readLastAgentError,
|
|
type LastAgentError,
|
|
} from "@/lib/session-last-agent-error";
|
|
import { legacyRecoveryMessageMatchesError } from "@/lib/session-recovery-presentation";
|
|
|
|
function isSessionActive(state?: TaskSessionState) {
|
|
return state === "RUNNING" || state === "STARTING" || state === "COMPLETED";
|
|
}
|
|
|
|
function currentSessionRecoveryError(sessionMetadata: Record<string, unknown> | null) {
|
|
if (!sessionMetadata) return null;
|
|
return readLastAgentError(sessionMetadata);
|
|
}
|
|
|
|
function isCurrentRecoveryMessage(
|
|
isRecoveryMessage: boolean,
|
|
messageRecoveryStamp: string | undefined,
|
|
currentRecoveryError: LastAgentError | null,
|
|
comment: Message,
|
|
) {
|
|
if (!isRecoveryMessage) return true;
|
|
if (!currentRecoveryError) return true;
|
|
const currentRecoveryStamp = lastAgentErrorStamp(currentRecoveryError);
|
|
if (messageRecoveryStamp) return currentRecoveryStamp === messageRecoveryStamp;
|
|
return legacyRecoveryMessageMatchesError(
|
|
comment.content,
|
|
comment.created_at,
|
|
currentRecoveryError,
|
|
);
|
|
}
|
|
|
|
function shouldShowRecoveryActions({
|
|
isRecoveryMessage,
|
|
isCurrentRecovery,
|
|
recoveryResolvedDurably,
|
|
agentRebooted,
|
|
recoveryRequested,
|
|
recoveryFailedAgain,
|
|
sessionState,
|
|
}: {
|
|
isRecoveryMessage: boolean;
|
|
isCurrentRecovery: boolean;
|
|
recoveryResolvedDurably: boolean;
|
|
agentRebooted: boolean;
|
|
recoveryRequested: boolean;
|
|
recoveryFailedAgain: boolean;
|
|
sessionState?: TaskSessionState;
|
|
}) {
|
|
if (!isRecoveryMessage) return true;
|
|
if (!isCurrentRecovery || recoveryResolvedDurably || agentRebooted) return false;
|
|
if (recoveryRequested && !recoveryFailedAgain) return false;
|
|
return !isSessionActive(sessionState);
|
|
}
|
|
|
|
export const ActionMessage = memo(function ActionMessage({ comment }: { comment: Message }) {
|
|
// Read session state from the store instead of receiving it as a prop, so a
|
|
// state transition doesn't re-render every message in the list (only the
|
|
// rare action messages that actually depend on it).
|
|
const { t } = useTranslation();
|
|
const { sessionState, sessionError, sessionMetadata, activeTurnId } = useActionMessageSession(
|
|
comment.session_id,
|
|
);
|
|
const metadata = comment.metadata as ActionMeta | undefined;
|
|
const message = comment.content || t("task:anErrorOccurred");
|
|
const isRecoveryMessage = metadata?.recovery_actions === true;
|
|
// The recovery acknowledgment lives here, on the message row that stays
|
|
// mounted, not on SettledFailureMessage: a successful resume drives the
|
|
// session through STARTING/RUNNING (which unmounts the card via
|
|
// isSessionActive) and back to WAITING_FOR_INPUT once the agent is idle.
|
|
// Local state on the card would reset on that remount and the recovery banner
|
|
// would reappear until the next user message.
|
|
const [recoveryRequested, setRecoveryRequested] = useState(false);
|
|
// Durable counterpart to the click acknowledgment: the transcript itself
|
|
// records that the agent booted again after this failure. It survives a
|
|
// reload or task switch and also covers auto-resume-on-open, where the card
|
|
// would otherwise linger until the next prompt flipped the session to RUNNING.
|
|
const { agentRebooted, agentBootFailed } = useAgentBootOutcomeAfterMessage(
|
|
comment,
|
|
isRecoveryMessage,
|
|
);
|
|
const recoveryResolvedDurably = isRecoveryMessage
|
|
? hasSessionRecoveryResolutionAfter(sessionMetadata, comment.created_at)
|
|
: false;
|
|
// The click acknowledgment only covers the wait for that outcome. A recovery
|
|
// that came back failed — a failed boot row, or a session driven to FAILED —
|
|
// must surface its card again, buttons included, or the retry is unreachable.
|
|
const recoveryFailedAgain = agentBootFailed || sessionState === "FAILED";
|
|
const currentRecoveryError = currentSessionRecoveryError(sessionMetadata ?? null);
|
|
const messageRecoveryStamp = metadata?.recovery_stamp ?? metadata?.error_stamp;
|
|
const isCurrentRecovery = isCurrentRecoveryMessage(
|
|
isRecoveryMessage,
|
|
messageRecoveryStamp,
|
|
currentRecoveryError,
|
|
comment,
|
|
);
|
|
const recoveryActionsVisible = shouldShowRecoveryActions({
|
|
isRecoveryMessage,
|
|
isCurrentRecovery,
|
|
recoveryResolvedDurably,
|
|
agentRebooted,
|
|
recoveryRequested,
|
|
recoveryFailedAgain,
|
|
sessionState,
|
|
});
|
|
|
|
if (metadata?.action_visibility === "running") {
|
|
if (sessionState === "RUNNING" && comment.turn_id && activeTurnId === comment.turn_id) {
|
|
return (
|
|
<RunningActionNotice
|
|
actions={metadata.actions}
|
|
message={message}
|
|
taskId={comment.task_id}
|
|
/>
|
|
);
|
|
}
|
|
// A terminal error may have been persisted with the old running metadata
|
|
// shape. Let it use the settled renderer instead of hiding the diagnostic.
|
|
if (comment.type !== "error") {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<SettledActionMessage
|
|
metadata={metadata}
|
|
message={message}
|
|
sessionError={sessionError}
|
|
sessionState={sessionState}
|
|
taskId={comment.task_id}
|
|
sessionId={comment.session_id}
|
|
recoveryActionsVisible={recoveryActionsVisible}
|
|
onRecoveryRequested={() => setRecoveryRequested(true)}
|
|
/>
|
|
);
|
|
});
|
|
|
|
function SettledActionMessage({
|
|
metadata,
|
|
message,
|
|
sessionError,
|
|
sessionState,
|
|
taskId,
|
|
sessionId,
|
|
recoveryActionsVisible,
|
|
onRecoveryRequested,
|
|
}: {
|
|
metadata: ActionMeta | undefined;
|
|
message: string;
|
|
sessionError?: string;
|
|
sessionState?: TaskSessionState;
|
|
taskId?: string;
|
|
sessionId?: string;
|
|
recoveryActionsVisible: boolean;
|
|
onRecoveryRequested: () => void;
|
|
}) {
|
|
// A retry card is persisted against the failed turn, so hide it while the
|
|
// replacement turn is starting or running to avoid showing stale progress.
|
|
if (isSessionActive(sessionState) && metadata?.recovery_actions !== true) return null;
|
|
|
|
if (metadata?.retrying) {
|
|
return <TransientRetryNotice metadata={metadata} taskId={taskId} />;
|
|
}
|
|
|
|
return (
|
|
<SettledFailureMessage
|
|
metadata={metadata}
|
|
message={message}
|
|
sessionError={sessionError}
|
|
taskId={taskId}
|
|
sessionId={sessionId}
|
|
recoveryActionsVisible={recoveryActionsVisible}
|
|
onRecoveryRequested={onRecoveryRequested}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SettledFailureMessage({
|
|
metadata,
|
|
message,
|
|
sessionError,
|
|
taskId,
|
|
sessionId,
|
|
recoveryActionsVisible,
|
|
onRecoveryRequested,
|
|
}: {
|
|
metadata: ActionMeta | undefined;
|
|
message: string;
|
|
sessionError?: string;
|
|
taskId?: string;
|
|
sessionId?: string;
|
|
recoveryActionsVisible: boolean;
|
|
onRecoveryRequested: () => void;
|
|
}) {
|
|
const renderedMetadata = recoveryActionsVisible ? metadata : withoutRecoveryActions(metadata);
|
|
|
|
const specialRecovery = renderSpecialRecovery({
|
|
metadata: renderedMetadata,
|
|
message,
|
|
sessionError,
|
|
taskId,
|
|
onRecoveryRequested,
|
|
});
|
|
if (specialRecovery) return specialRecovery;
|
|
|
|
const iconClass = metadata?.variant === "warning" ? "text-amber-500" : "text-red-500";
|
|
const textClass =
|
|
metadata?.variant === "warning"
|
|
? "text-amber-600 dark:text-amber-400"
|
|
: "text-red-600 dark:text-red-400";
|
|
return (
|
|
<div className="w-full" data-testid="session-recovery-action-message">
|
|
<div className="flex items-start gap-3 w-full rounded px-2 py-1 -mx-2">
|
|
<div className="flex-shrink-0 mt-0.5">
|
|
<IconAlertTriangle className={cn("h-4 w-4", iconClass)} />
|
|
</div>
|
|
<div className="flex-1 min-w-0 pt-0.5">
|
|
<div className={cn("text-xs break-words", textClass)}>{message}</div>
|
|
<ActionMessageDetails metadata={renderedMetadata} />
|
|
{renderSettledActionButtons({
|
|
actions: renderedMetadata?.actions,
|
|
taskId,
|
|
sessionId,
|
|
isRecoveryMessage: metadata?.recovery_actions === true,
|
|
onRecoveryRequested,
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function withoutRecoveryActions(metadata: ActionMeta | undefined): ActionMeta | undefined {
|
|
if (!metadata) return undefined;
|
|
return { ...metadata, actions: undefined };
|
|
}
|
|
|
|
function renderSettledActionButtons({
|
|
actions,
|
|
taskId,
|
|
sessionId,
|
|
isRecoveryMessage,
|
|
onRecoveryRequested,
|
|
}: {
|
|
actions?: MessageAction[];
|
|
taskId?: string;
|
|
sessionId?: string;
|
|
isRecoveryMessage: boolean;
|
|
onRecoveryRequested: () => void;
|
|
}): ReactElement | null {
|
|
if (!actions || actions.length === 0) return null;
|
|
const hasSessionRecoveryAction = actions.some((action) => sessionRecoveryAction(action));
|
|
if (hasSessionRecoveryAction && taskId && sessionId) {
|
|
return (
|
|
<SessionRecoveryActionButtons
|
|
actions={actions}
|
|
taskId={taskId}
|
|
sessionId={sessionId}
|
|
onRecoveryRequested={onRecoveryRequested}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<ActionButtons
|
|
actions={actions}
|
|
taskId={taskId}
|
|
onRecoveryRequested={isRecoveryMessage ? onRecoveryRequested : undefined}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function transientRetryReasonKey(failureCode?: string) {
|
|
switch (failureCode) {
|
|
case "model_capacity":
|
|
return "chat:transientRetryReasonModelCapacity";
|
|
case "network_unavailable":
|
|
return "chat:transientRetryReasonNetworkUnavailable";
|
|
case "provider_overloaded":
|
|
return "chat:transientRetryReasonProviderOverloaded";
|
|
case "provider_unavailable":
|
|
return "chat:transientRetryReasonProviderUnavailable";
|
|
case "rate_limited":
|
|
return "chat:transientRetryReasonRateLimited";
|
|
case "agent_transport_lost":
|
|
return "chat:transientRetryReasonAgentTransportLost";
|
|
default:
|
|
return "chat:transientRetryReasonGeneric";
|
|
}
|
|
}
|
|
|
|
function transientRetryContext(
|
|
t: ReturnType<typeof useTranslation>["t"],
|
|
provider?: string,
|
|
model?: string,
|
|
) {
|
|
if (!provider && !model) return null;
|
|
if (provider && model) return t("chat:transientRetryProviderModel", { provider, model });
|
|
if (provider) return t("chat:transientRetryProvider", { provider });
|
|
return t("chat:transientRetryModel", { model });
|
|
}
|
|
|
|
function TransientRetryNotice({ metadata, taskId }: { metadata: ActionMeta; taskId?: string }) {
|
|
const { t } = useTranslation();
|
|
const retryAt = parseRetryAt(metadata.retry_at);
|
|
const fallbackDeadline = useMemo(
|
|
() => Date.now() + Math.max(0, metadata.retry_in_seconds ?? 0) * 1_000,
|
|
[metadata.attempt, metadata.retry_in_seconds],
|
|
);
|
|
const deadline = retryAt ?? fallbackDeadline;
|
|
const [now, setNow] = useState(() => Date.now());
|
|
|
|
useEffect(() => {
|
|
setNow(Date.now());
|
|
const timer = window.setInterval(() => setNow(Date.now()), 1_000);
|
|
return () => window.clearInterval(timer);
|
|
}, [retryAt, metadata.retry_in_seconds]);
|
|
|
|
const remaining = Math.max(0, deadline - now);
|
|
const reasonKey = transientRetryReasonKey(metadata.failure_code);
|
|
const provider = metadata.provider_name?.trim();
|
|
const model = metadata.model_id?.trim();
|
|
const context = transientRetryContext(t, provider, model);
|
|
const countdown =
|
|
remaining > 0
|
|
? t("chat:transientRetryIn", { countdown: retryCountdownLabel(remaining) })
|
|
: t("chat:transientRetryNow");
|
|
|
|
return (
|
|
<section
|
|
data-testid="transient-retry-card"
|
|
role="status"
|
|
aria-live="polite"
|
|
className="flex w-full min-w-0 items-center gap-2 rounded-md border border-amber-500/25 bg-amber-500/[0.06] px-2 py-1.5 sm:gap-3 sm:px-3"
|
|
>
|
|
<IconAlertTriangle className="h-4 w-4 flex-shrink-0 text-amber-500" aria-hidden="true" />
|
|
<div className="min-w-0 flex-1 text-xs">
|
|
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-amber-600 dark:text-amber-400">
|
|
<span>{t(reasonKey)}</span>
|
|
<span aria-label={t("chat:transientRetryCountdownLabel")}>{countdown}</span>
|
|
</div>
|
|
{context && <div className="mt-0.5 truncate text-muted-foreground">{context}</div>}
|
|
<div className="mt-0.5 text-muted-foreground">
|
|
{t("chat:transientRetryAttempt", {
|
|
attempt: metadata.attempt ?? 1,
|
|
maxAttempts: metadata.max_attempts ?? 1,
|
|
})}
|
|
</div>
|
|
</div>
|
|
{metadata.actions && metadata.actions.length > 0 && (
|
|
<ActionButtons actions={metadata.actions} taskId={taskId} compact />
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function renderSpecialRecovery({
|
|
metadata,
|
|
message,
|
|
sessionError,
|
|
taskId,
|
|
onRecoveryRequested,
|
|
}: {
|
|
metadata: ActionMeta | undefined;
|
|
message: string;
|
|
sessionError?: string;
|
|
taskId?: string;
|
|
onRecoveryRequested: () => void;
|
|
}): ReactElement | null {
|
|
if (metadata?.failure_kind === "missing_pr_branch") {
|
|
return (
|
|
<MissingBranchRecovery
|
|
metadata={metadata}
|
|
taskId={taskId}
|
|
fallbackMessage={message}
|
|
technicalDetails={sessionError}
|
|
/>
|
|
);
|
|
}
|
|
if (metadata?.failure_kind === "provider_quota_limited") {
|
|
return (
|
|
<ProviderQuotaRecovery
|
|
metadata={metadata}
|
|
taskId={taskId}
|
|
onRecoveryRequested={onRecoveryRequested}
|
|
/>
|
|
);
|
|
}
|
|
if (
|
|
metadata?.failure_kind === "managed_runtime_npm_resolution" ||
|
|
metadata?.failure_kind === "managed_runtime_npm_policy"
|
|
) {
|
|
return (
|
|
<ManagedRuntimeNpmRecovery
|
|
metadata={metadata}
|
|
taskId={taskId}
|
|
onRecoveryRequested={onRecoveryRequested}
|
|
/>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function ManagedRuntimeNpmRecovery({
|
|
metadata,
|
|
taskId,
|
|
onRecoveryRequested,
|
|
}: {
|
|
metadata: ActionMeta;
|
|
taskId?: string;
|
|
onRecoveryRequested: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const isPolicyFailure = metadata.failure_kind === "managed_runtime_npm_policy";
|
|
const actions = metadata.actions?.slice(0, 1) ?? [];
|
|
return (
|
|
<section
|
|
data-testid="managed-runtime-npm-recovery"
|
|
role="alert"
|
|
className="w-full min-w-0 rounded-md border border-amber-500/25 bg-amber-500/[0.06] p-3 sm:p-4"
|
|
>
|
|
<div className="flex min-w-0 items-start gap-3">
|
|
<IconAlertTriangle
|
|
className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-500"
|
|
aria-hidden="true"
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<h3 className="text-sm font-medium text-foreground">
|
|
{t(
|
|
isPolicyFailure ? "chat:managedRuntimeNpmPolicyTitle" : "chat:managedRuntimeNpmTitle",
|
|
)}
|
|
</h3>
|
|
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
|
{t(isPolicyFailure ? "chat:managedRuntimeNpmPolicyBody" : "chat:managedRuntimeNpmBody")}
|
|
</p>
|
|
<ActionMessageDetails metadata={metadata} />
|
|
{actions.length > 0 && (
|
|
<ActionButtons
|
|
actions={actions}
|
|
taskId={taskId}
|
|
onRecoveryRequested={onRecoveryRequested}
|
|
labelOverride={t("chat:managedRuntimeRetry")}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function ProviderQuotaRecovery({
|
|
metadata,
|
|
taskId,
|
|
onRecoveryRequested,
|
|
}: {
|
|
metadata: ActionMeta;
|
|
taskId?: string;
|
|
onRecoveryRequested: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const provider = metadata.provider_name?.trim() || t("chat:providerQuotaProviderFallback");
|
|
const model = metadata.model_id?.trim();
|
|
const resetDate = metadata.reset_at ? new Date(metadata.reset_at) : undefined;
|
|
const resetAt = resetDate && !Number.isNaN(resetDate.getTime()) ? formatDateTime(resetDate) : "";
|
|
|
|
return (
|
|
<section
|
|
data-testid="provider-quota-recovery"
|
|
role="alert"
|
|
className="w-full min-w-0 rounded-md border border-amber-500/25 bg-amber-500/[0.06] p-3 sm:p-4"
|
|
>
|
|
<div className="flex min-w-0 items-start gap-3">
|
|
<IconAlertTriangle
|
|
className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-500"
|
|
aria-hidden="true"
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<h3 className="text-sm font-medium text-foreground">
|
|
{t("chat:providerQuotaTitle", { provider })}
|
|
</h3>
|
|
<div className="mt-1 space-y-1 text-xs leading-relaxed text-muted-foreground">
|
|
{model && <p>{t("chat:providerQuotaModel", { model })}</p>}
|
|
<p>
|
|
{resetAt
|
|
? t("chat:providerQuotaReset", { resetAt })
|
|
: t("chat:providerQuotaResetUnknown")}
|
|
</p>
|
|
</div>
|
|
<ActionMessageDetails metadata={metadata} />
|
|
{metadata.actions && metadata.actions.length > 0 && (
|
|
<ActionButtons
|
|
actions={metadata.actions}
|
|
taskId={taskId}
|
|
onRecoveryRequested={onRecoveryRequested}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function RunningActionNotice({
|
|
actions,
|
|
message,
|
|
taskId,
|
|
}: {
|
|
actions?: MessageAction[];
|
|
message: string;
|
|
taskId?: string;
|
|
}) {
|
|
return (
|
|
<div
|
|
data-testid="running-action-notice"
|
|
className="flex min-w-0 items-center gap-2 py-1 text-muted-foreground"
|
|
>
|
|
<span className="min-w-0 flex-1 truncate text-xs">{message}</span>
|
|
{actions && actions.length > 0 && <ActionButtons actions={actions} taskId={taskId} compact />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MissingBranchRecovery({
|
|
metadata,
|
|
taskId,
|
|
fallbackMessage,
|
|
technicalDetails,
|
|
}: {
|
|
metadata: ActionMeta;
|
|
taskId?: string;
|
|
fallbackMessage: string;
|
|
technicalDetails?: string;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const branch = metadata.missing_branch?.trim();
|
|
return (
|
|
<section
|
|
data-testid="missing-branch-recovery"
|
|
role="alert"
|
|
className="w-full min-w-0 rounded-md border border-amber-500/25 bg-amber-500/[0.06] p-3 sm:p-4"
|
|
>
|
|
<div className="flex min-w-0 items-start gap-3">
|
|
<IconAlertTriangle
|
|
className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-500"
|
|
aria-hidden="true"
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<h3 className="text-sm font-medium text-foreground">
|
|
{t("task:branchIsNoLongerAvailable")}
|
|
</h3>
|
|
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
|
{branch ? (
|
|
<Trans i18nKey="task:thisTaskPointsToBranch" values={{ branch }}>
|
|
This task points to <code className="break-all text-foreground">{branch}</code>, but
|
|
that branch could not be found on the remote repository. It may have been merged or
|
|
deleted.
|
|
</Trans>
|
|
) : (
|
|
fallbackMessage
|
|
)}
|
|
</p>
|
|
<ActionMessageDetails metadata={metadata} technicalDetails={technicalDetails} />
|
|
{metadata.actions && metadata.actions.length > 0 && (
|
|
<ActionButtons actions={metadata.actions} taskId={taskId} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|