fix: recover failed task launches safely (#3437)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildRunErrorsFromSessions,
|
||||
filterVisibleRunErrors,
|
||||
hasMatchingSessionLaunchError,
|
||||
liveSessionMetadataFromStore,
|
||||
mergeLiveSessionMetadata,
|
||||
@@ -137,6 +138,32 @@ describe("hasMatchingSessionLaunchError", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterVisibleRunErrors", () => {
|
||||
it("keeps ordinary provider errors when the active summary is not a launch error", () => {
|
||||
const errors = buildRunErrorsFromSessions([
|
||||
session({
|
||||
metadata: {
|
||||
last_agent_error: {
|
||||
message: "The provider rejected the request.",
|
||||
code: "provider_auth_required",
|
||||
stamp: "provider-stamp-1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
filterVisibleRunErrors(errors, {
|
||||
session_id: "s1",
|
||||
stamp: "provider-stamp-1",
|
||||
occurred_at: "2026-08-02T15:00:01Z",
|
||||
preview: "The provider rejected the request.",
|
||||
category: "provider_auth_required",
|
||||
}),
|
||||
).toEqual(errors);
|
||||
});
|
||||
});
|
||||
|
||||
describe("liveSessionMetadataFromStore", () => {
|
||||
const metadata = { last_agent_error: { message: "boom", remediation_url: URL } };
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { CommentTurnContext } from "./turn-context";
|
||||
import { groupSortKey, type SessionGroup } from "./session-groups";
|
||||
import { normalizeRemediationUrl } from "@/lib/remediation-url";
|
||||
import { lastAgentErrorStamp, readLastAgentError } from "@/lib/session-last-agent-error";
|
||||
import { isTypedTaskLaunchError } from "./components/task-launch-error-entry";
|
||||
import { isMatchingTaskLaunchError } from "@/components/task/chat/types";
|
||||
import type {
|
||||
RunError,
|
||||
TaskComment,
|
||||
@@ -9,6 +11,7 @@ import type {
|
||||
TaskSession,
|
||||
TimelineEvent,
|
||||
} from "@/app/office/tasks/[id]/types";
|
||||
import type { TaskStatusSummary } from "@/lib/types/task-status-summary";
|
||||
|
||||
/**
|
||||
* Discriminated union of every row that can appear in the task chat
|
||||
@@ -85,9 +88,25 @@ export function hasMatchingSessionLaunchError(
|
||||
runErrors: RunError[],
|
||||
): boolean {
|
||||
if (!summarySessionId) return false;
|
||||
return runErrors.some(
|
||||
return runErrors.some((error) =>
|
||||
isMatchingTaskLaunchError(
|
||||
{ session_id: summarySessionId, stamp: summaryStamp ?? "" },
|
||||
{ sessionId: error.sessionId, errorStamp: error.errorStamp },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function filterVisibleRunErrors(
|
||||
runErrors: RunError[],
|
||||
activeError: TaskStatusSummary["active_error"],
|
||||
): RunError[] {
|
||||
if (!isTypedTaskLaunchError(activeError)) return runErrors;
|
||||
return runErrors.filter(
|
||||
(error) =>
|
||||
error.sessionId === summarySessionId && (!summaryStamp || error.errorStamp === summaryStamp),
|
||||
!isMatchingTaskLaunchError(activeError, {
|
||||
sessionId: error.sessionId,
|
||||
errorStamp: error.errorStamp,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ function TypedRunLaunchErrorEntry({
|
||||
stamp: error.errorStamp ?? "",
|
||||
occurred_at: error.failedAt,
|
||||
preview,
|
||||
details: error.failureDetails,
|
||||
category: error.failureCode,
|
||||
recovery_actions: error.recoveryActions,
|
||||
}}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { TaskRepository } from "@/lib/types/http";
|
||||
import type { TaskStatusSummary } from "@/lib/types/task-status-summary";
|
||||
import type { RunError } from "@/app/office/tasks/[id]/types";
|
||||
import { hasMatchingSessionLaunchError } from "../chat-entries";
|
||||
import { isTaskLaunchErrorVisibleForSession } from "@/components/task/chat/types";
|
||||
import { isTypedTaskLaunchError, TaskLaunchErrorEntry } from "./task-launch-error-entry";
|
||||
|
||||
type TaskChatLaunchErrorProps = {
|
||||
taskId: string;
|
||||
workspaceId: string;
|
||||
statusSummary?: TaskStatusSummary | null;
|
||||
runErrors: RunError[];
|
||||
/** When supplied, only render the error that belongs to this session. */
|
||||
sessionId?: string | null;
|
||||
repositories?: TaskRepository[];
|
||||
};
|
||||
|
||||
@@ -19,17 +18,14 @@ export function TaskChatLaunchError({
|
||||
taskId,
|
||||
workspaceId,
|
||||
statusSummary,
|
||||
runErrors,
|
||||
sessionId,
|
||||
repositories,
|
||||
}: TaskChatLaunchErrorProps) {
|
||||
const error = useMemo(() => {
|
||||
const candidate = statusSummary?.active_error;
|
||||
if (!isTypedTaskLaunchError(candidate)) return null;
|
||||
if (hasMatchingSessionLaunchError(candidate.session_id, candidate.stamp, runErrors)) {
|
||||
return null;
|
||||
}
|
||||
return candidate;
|
||||
}, [runErrors, statusSummary]);
|
||||
const candidate = statusSummary?.active_error;
|
||||
const error =
|
||||
isTypedTaskLaunchError(candidate) && isTaskLaunchErrorVisibleForSession(candidate, sessionId)
|
||||
? candidate
|
||||
: null;
|
||||
|
||||
if (!error) return null;
|
||||
return (
|
||||
|
||||
@@ -43,11 +43,15 @@ vi.mock("@/components/task/task-launch-branch-picker", () => ({
|
||||
const TASK_ID = "task-1";
|
||||
const TASK_REPOSITORY_ID = "task-repo-1";
|
||||
const ERROR_STAMP = "launch-stamp-1";
|
||||
const WORKSPACE_ID = "workspace-1";
|
||||
const OCCURRED_AT = "2026-08-19T10:00:00Z";
|
||||
const ERROR_ENTRY_TEST_ID = "task-launch-error-entry";
|
||||
const RETRY_LAUNCH_BUTTON_TEST_ID = "task-launch-retry_launch-button";
|
||||
|
||||
const error: TaskStatusSummaryActiveError = {
|
||||
task_repository_id: TASK_REPOSITORY_ID,
|
||||
stamp: ERROR_STAMP,
|
||||
occurred_at: "2026-08-19T10:00:00Z",
|
||||
occurred_at: OCCURRED_AT,
|
||||
preview: "The selected base branch is not available.",
|
||||
category: "base_branch_missing",
|
||||
recovery_actions: ["retry_default", "pick_base_branch", "mark_review_done"],
|
||||
@@ -78,6 +82,7 @@ describe("TaskLaunchErrorEntry", () => {
|
||||
"base_branch_missing",
|
||||
"default_branch_unresolved",
|
||||
"pr_already_closed",
|
||||
"workspace_checkout_failed",
|
||||
"generic_launch_failure",
|
||||
])("classifies launch category %s as a typed launch error", (category) => {
|
||||
expect(
|
||||
@@ -88,30 +93,121 @@ describe("TaskLaunchErrorEntry", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("renders checkout details and retries the launch without branch settings", async () => {
|
||||
const checkoutError: TaskStatusSummaryActiveError = {
|
||||
session_id: "session-1",
|
||||
stamp: "checkout-stamp-1",
|
||||
occurred_at: OCCURRED_AT,
|
||||
preview: "The workspace could not be prepared for this launch.",
|
||||
details: "The pull request head was fetched into its isolated ref.",
|
||||
category: "workspace_checkout_failed",
|
||||
recovery_actions: ["retry_launch"],
|
||||
};
|
||||
|
||||
render(
|
||||
<TaskLaunchErrorEntry taskId={TASK_ID} workspaceId={WORKSPACE_ID} error={checkoutError} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText(checkoutError.preview)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show details/i }));
|
||||
expect(screen.getByText(checkoutError.details!)).toBeTruthy();
|
||||
expect(screen.getByTestId(RETRY_LAUNCH_BUTTON_TEST_ID)).toBeTruthy();
|
||||
fireEvent.click(screen.getByTestId(RETRY_LAUNCH_BUTTON_TEST_ID));
|
||||
|
||||
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
|
||||
expect(requestMock).toHaveBeenCalledWith("task.launch.recover", {
|
||||
task_id: TASK_ID,
|
||||
session_id: "session-1",
|
||||
action: "retry_launch",
|
||||
error_stamp: "checkout-stamp-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps recovery failures inside the launch card", async () => {
|
||||
requestMock.mockRejectedValueOnce(new Error("recovery failed"));
|
||||
render(
|
||||
<TaskLaunchErrorEntry
|
||||
taskId={TASK_ID}
|
||||
workspaceId={WORKSPACE_ID}
|
||||
error={{ ...error, recovery_actions: ["retry_launch"] }}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId(RETRY_LAUNCH_BUTTON_TEST_ID));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("task-launch-recovery-error")).toBeTruthy());
|
||||
expect(toastMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears a recovery failure when a newer launch error arrives", async () => {
|
||||
requestMock.mockRejectedValueOnce(new Error("recovery failed"));
|
||||
const { rerender } = render(
|
||||
<TaskLaunchErrorEntry
|
||||
taskId={TASK_ID}
|
||||
workspaceId={WORKSPACE_ID}
|
||||
error={{ ...error, recovery_actions: ["retry_launch"] }}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId(RETRY_LAUNCH_BUTTON_TEST_ID));
|
||||
await waitFor(() => expect(screen.getByTestId("task-launch-recovery-error")).toBeTruthy());
|
||||
|
||||
rerender(
|
||||
<TaskLaunchErrorEntry
|
||||
taskId={TASK_ID}
|
||||
workspaceId={WORKSPACE_ID}
|
||||
error={{
|
||||
...error,
|
||||
stamp: "launch-stamp-2",
|
||||
recovery_actions: ["retry_launch"],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId("task-launch-recovery-error")).toBeNull());
|
||||
});
|
||||
|
||||
it("renders a typed summary error without recovery actions", () => {
|
||||
render(
|
||||
<TaskChatLaunchError
|
||||
taskId={TASK_ID}
|
||||
workspaceId="workspace-1"
|
||||
workspaceId={WORKSPACE_ID}
|
||||
statusSummary={{
|
||||
revision: 1,
|
||||
updated_at: "2026-08-19T10:00:00Z",
|
||||
updated_at: OCCURRED_AT,
|
||||
active_error: { ...error, recovery_actions: [] },
|
||||
}}
|
||||
runErrors={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("task-launch-error-entry")).toBeTruthy();
|
||||
expect(screen.getByTestId(ERROR_ENTRY_TEST_ID)).toBeTruthy();
|
||||
expect(screen.getByText(error.preview)).toBeTruthy();
|
||||
expect(screen.queryByTestId("task-launch-retry_default-button")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a task-wide launch error while a prior session is selected", () => {
|
||||
render(
|
||||
<TaskChatLaunchError
|
||||
taskId={TASK_ID}
|
||||
workspaceId={WORKSPACE_ID}
|
||||
sessionId="prior-session"
|
||||
statusSummary={{
|
||||
revision: 1,
|
||||
updated_at: OCCURRED_AT,
|
||||
active_error: { ...error, recovery_actions: ["mark_review_done"] },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId(ERROR_ENTRY_TEST_ID)).toBeTruthy();
|
||||
expect(screen.getByTestId("task-launch-mark_review_done-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the bounded preview and sends the exact task recovery payload", async () => {
|
||||
render(
|
||||
<TaskLaunchErrorEntry
|
||||
taskId={TASK_ID}
|
||||
workspaceId="workspace-1"
|
||||
workspaceId={WORKSPACE_ID}
|
||||
repositories={[
|
||||
{
|
||||
id: TASK_REPOSITORY_ID,
|
||||
@@ -127,7 +223,7 @@ describe("TaskLaunchErrorEntry", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("task-launch-error-entry")).toBeTruthy();
|
||||
expect(screen.getByTestId(ERROR_ENTRY_TEST_ID)).toBeTruthy();
|
||||
expect(screen.getByText(error.preview)).toBeTruthy();
|
||||
fireEvent.click(screen.getByTestId("task-launch-retry_default-button"));
|
||||
|
||||
@@ -144,7 +240,7 @@ describe("TaskLaunchErrorEntry", () => {
|
||||
render(
|
||||
<TaskLaunchErrorEntry
|
||||
taskId={TASK_ID}
|
||||
workspaceId="workspace-1"
|
||||
workspaceId={WORKSPACE_ID}
|
||||
repositories={[]}
|
||||
error={error}
|
||||
/>,
|
||||
@@ -165,7 +261,7 @@ describe("TaskLaunchErrorEntry", () => {
|
||||
render(
|
||||
<TaskLaunchErrorEntry
|
||||
taskId={TASK_ID}
|
||||
workspaceId="workspace-1"
|
||||
workspaceId={WORKSPACE_ID}
|
||||
repositories={[]}
|
||||
error={error}
|
||||
/>,
|
||||
@@ -187,13 +283,13 @@ describe("TaskLaunchErrorEntry", () => {
|
||||
<>
|
||||
<TaskLaunchErrorEntry
|
||||
taskId={TASK_ID}
|
||||
workspaceId="workspace-1"
|
||||
workspaceId={WORKSPACE_ID}
|
||||
repositories={[]}
|
||||
error={error}
|
||||
/>
|
||||
<TaskLaunchErrorEntry
|
||||
taskId={TASK_ID}
|
||||
workspaceId="workspace-1"
|
||||
workspaceId={WORKSPACE_ID}
|
||||
repositories={[]}
|
||||
error={error}
|
||||
/>
|
||||
@@ -210,6 +306,6 @@ describe("TaskLaunchErrorEntry", () => {
|
||||
expect(buttons[1].getAttribute("disabled")).not.toBeNull();
|
||||
});
|
||||
resolveRequest?.({ ok: true });
|
||||
await waitFor(() => expect(screen.queryAllByTestId("task-launch-error-entry")).toHaveLength(2));
|
||||
await waitFor(() => expect(screen.queryAllByTestId(ERROR_ENTRY_TEST_ID)).toHaveLength(2));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCheck,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
IconRefresh,
|
||||
} from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible";
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
import type { TaskRepository } from "@/lib/types/http";
|
||||
import type { TaskLaunchRecoveryAction } from "@/lib/types/task-launch-error";
|
||||
@@ -26,10 +26,64 @@ type TaskLaunchErrorEntryProps = {
|
||||
|
||||
const pendingRecoveryRequests = new Map<string, Promise<unknown>>();
|
||||
|
||||
function useTaskLaunchRecovery({
|
||||
taskId,
|
||||
error,
|
||||
}: Pick<TaskLaunchErrorEntryProps, "taskId" | "error">) {
|
||||
const [pendingAction, setPendingAction] = useState<TaskLaunchRecoveryAction | null>(null);
|
||||
const [recoveryError, setRecoveryError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setRecoveryError(false);
|
||||
}, [error.stamp]);
|
||||
|
||||
const sendRecovery = async (action: TaskLaunchRecoveryAction, baseBranch?: string) => {
|
||||
const client = getWebSocketClient();
|
||||
if (!client || pendingAction) return;
|
||||
setRecoveryError(false);
|
||||
const requestKey = JSON.stringify({ taskId, stamp: error.stamp, action, baseBranch });
|
||||
const existingRequest = pendingRecoveryRequests.get(requestKey);
|
||||
if (existingRequest) {
|
||||
setPendingAction(action);
|
||||
try {
|
||||
await existingRequest;
|
||||
} catch {
|
||||
// The request owner reports the recovery error to avoid duplicate toasts.
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setPendingAction(action);
|
||||
const request = Promise.resolve().then(() =>
|
||||
client.request("task.launch.recover", {
|
||||
task_id: taskId,
|
||||
...(error.session_id ? { session_id: error.session_id } : {}),
|
||||
...(error.task_repository_id ? { task_repository_id: error.task_repository_id } : {}),
|
||||
action,
|
||||
...(baseBranch ? { base_branch: baseBranch } : {}),
|
||||
error_stamp: error.stamp,
|
||||
}),
|
||||
);
|
||||
pendingRecoveryRequests.set(requestKey, request);
|
||||
try {
|
||||
await request;
|
||||
} catch {
|
||||
setRecoveryError(true);
|
||||
} finally {
|
||||
pendingRecoveryRequests.delete(requestKey);
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
return { pendingAction, recoveryError, sendRecovery };
|
||||
}
|
||||
|
||||
const TASK_LAUNCH_ERROR_CATEGORIES = new Set([
|
||||
"base_branch_missing",
|
||||
"default_branch_unresolved",
|
||||
"pr_already_closed",
|
||||
"workspace_checkout_failed",
|
||||
"generic_launch_failure",
|
||||
]);
|
||||
|
||||
@@ -51,6 +105,8 @@ function categoryTitle(category: string | undefined, t: (key: string) => string)
|
||||
return t("task:launchErrorDefaultBranchUnresolved");
|
||||
case "pr_already_closed":
|
||||
return t("task:launchErrorPrAlreadyClosed");
|
||||
case "workspace_checkout_failed":
|
||||
return t("task:launchErrorWorkspaceCheckoutFailed");
|
||||
default:
|
||||
return t("task:launchErrorGeneric");
|
||||
}
|
||||
@@ -64,6 +120,8 @@ function actionLabel(action: TaskLaunchRecoveryAction, t: (key: string) => strin
|
||||
return t("task:pickBaseBranch");
|
||||
case "mark_review_done":
|
||||
return t("task:markReviewDone");
|
||||
case "retry_launch":
|
||||
return t("task:retryLaunch");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +134,7 @@ function RecoveryActionIcon({
|
||||
}) {
|
||||
if (pending) return <IconLoader2 className="h-3 w-3 animate-spin" />;
|
||||
if (action === "retry_default") return <IconRefresh className="h-3 w-3" />;
|
||||
if (action === "retry_launch") return <IconRefresh className="h-3 w-3" />;
|
||||
if (action === "pick_base_branch") return <IconGitBranch className="h-3 w-3" />;
|
||||
return <IconCheck className="h-3 w-3" />;
|
||||
}
|
||||
@@ -99,14 +158,14 @@ function TaskLaunchRecoveryActions({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-2">
|
||||
<div className="mt-3 flex min-w-0 flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
{actions.map((action) => {
|
||||
const opensBranchPicker = action === "pick_base_branch";
|
||||
const button = (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto min-h-11 max-w-full cursor-pointer gap-1.5 text-xs sm:min-h-8"
|
||||
className="h-auto min-h-11 w-full cursor-pointer justify-start gap-1.5 text-xs sm:w-auto sm:min-h-8"
|
||||
disabled={pendingAction !== null}
|
||||
onClick={opensBranchPicker ? undefined : () => void onRecover(action)}
|
||||
data-testid={`task-launch-${action}-button`}
|
||||
@@ -139,58 +198,16 @@ export function TaskLaunchErrorEntry({
|
||||
repositories,
|
||||
}: TaskLaunchErrorEntryProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [pendingAction, setPendingAction] = useState<TaskLaunchRecoveryAction | null>(null);
|
||||
const { pendingAction, recoveryError, sendRecovery } = useTaskLaunchRecovery({ taskId, error });
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const taskRepository = repositories?.find(
|
||||
(repository) => repository.id === error.task_repository_id,
|
||||
);
|
||||
const currentBase = taskRepository?.base_branch ?? t("task:baseBranchNotSet");
|
||||
|
||||
const sendRecovery = async (action: TaskLaunchRecoveryAction, baseBranch?: string) => {
|
||||
const client = getWebSocketClient();
|
||||
if (!client || pendingAction) return;
|
||||
const requestKey = JSON.stringify({ taskId, stamp: error.stamp, action, baseBranch });
|
||||
const existingRequest = pendingRecoveryRequests.get(requestKey);
|
||||
if (existingRequest) {
|
||||
setPendingAction(action);
|
||||
try {
|
||||
await existingRequest;
|
||||
} catch {
|
||||
// The request owner reports the recovery error to avoid duplicate toasts.
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setPendingAction(action);
|
||||
const request = Promise.resolve().then(() =>
|
||||
client.request("task.launch.recover", {
|
||||
task_id: taskId,
|
||||
...(error.session_id ? { session_id: error.session_id } : {}),
|
||||
...(error.task_repository_id ? { task_repository_id: error.task_repository_id } : {}),
|
||||
action,
|
||||
...(baseBranch ? { base_branch: baseBranch } : {}),
|
||||
error_stamp: error.stamp,
|
||||
}),
|
||||
);
|
||||
pendingRecoveryRequests.set(requestKey, request);
|
||||
try {
|
||||
await request;
|
||||
} catch {
|
||||
toast({
|
||||
title: t("task:launchRecoveryFailed"),
|
||||
description: t("task:tryRecoveryAgain"),
|
||||
variant: "error",
|
||||
});
|
||||
} finally {
|
||||
pendingRecoveryRequests.delete(requestKey);
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-0 gap-3 border-b border-border/50 py-3"
|
||||
className="flex min-w-0 gap-3 rounded-md border border-destructive/30 bg-destructive/5 p-3 sm:p-4"
|
||||
data-testid="task-launch-error-entry"
|
||||
role="alert"
|
||||
>
|
||||
@@ -205,6 +222,27 @@ export function TaskLaunchErrorEntry({
|
||||
<p className="mt-1 max-w-prose break-words text-sm text-muted-foreground">
|
||||
{error.preview}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground" data-testid="task-launch-no-change">
|
||||
{t("task:launchErrorNoChanges")}
|
||||
</p>
|
||||
{error.details && (
|
||||
<Collapsible open={showDetails} onOpenChange={setShowDetails} className="mt-3">
|
||||
<CollapsibleTrigger className="flex min-h-11 items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground cursor-pointer sm:min-h-8">
|
||||
<IconRefresh
|
||||
className={`h-3.5 w-3.5 transition-transform ${showDetails ? "rotate-90" : ""}`}
|
||||
/>
|
||||
{t("task:showDetails")}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<pre
|
||||
className="mt-2 max-w-prose whitespace-pre-wrap break-words rounded bg-muted/50 p-2 font-mono text-[11px] leading-relaxed text-muted-foreground"
|
||||
data-testid="task-launch-error-details"
|
||||
>
|
||||
{error.details}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
<TaskLaunchRecoveryActions
|
||||
actions={error.recovery_actions ?? []}
|
||||
workspaceId={workspaceId}
|
||||
@@ -214,6 +252,15 @@ export function TaskLaunchErrorEntry({
|
||||
pendingAction={pendingAction}
|
||||
onRecover={sendRecovery}
|
||||
/>
|
||||
{recoveryError && (
|
||||
<p
|
||||
className="mt-2 text-xs text-destructive"
|
||||
data-testid="task-launch-recovery-error"
|
||||
role="alert"
|
||||
>
|
||||
{t("task:launchRecoveryFailed")} {t("task:tryRecoveryAgain")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -35,6 +35,7 @@ import type {
|
||||
import {
|
||||
buildLaterAgentReplyMap,
|
||||
buildRunErrorsFromSessions,
|
||||
filterVisibleRunErrors,
|
||||
mergeChatEntries,
|
||||
type ChatEntry,
|
||||
} from "./chat-entries";
|
||||
@@ -615,6 +616,10 @@ export function TaskChat({
|
||||
);
|
||||
const turnCtx = useMemo(() => buildCommentTurnContext(comments, sessions), [comments, sessions]);
|
||||
const runErrors = useMemo(() => buildRunErrorsFromSessions(sessions), [sessions]);
|
||||
const visibleRunErrors = useMemo(
|
||||
() => filterVisibleRunErrors(runErrors, statusSummary?.active_error),
|
||||
[runErrors, statusSummary?.active_error],
|
||||
);
|
||||
const laterAgentReplyMap = useMemo(() => buildLaterAgentReplyMap(comments), [comments]);
|
||||
const entries = useMemo(
|
||||
() =>
|
||||
@@ -624,10 +629,10 @@ export function TaskChat({
|
||||
groups: renderedGroups,
|
||||
decisions,
|
||||
turnCtx,
|
||||
runErrors,
|
||||
runErrors: visibleRunErrors,
|
||||
laterAgentReplyMap,
|
||||
}),
|
||||
[comments, timeline, renderedGroups, decisions, turnCtx, runErrors, laterAgentReplyMap],
|
||||
[comments, timeline, renderedGroups, decisions, turnCtx, visibleRunErrors, laterAgentReplyMap],
|
||||
);
|
||||
|
||||
useChatAutoScroll(scrollParent ?? null, sessions, taskId);
|
||||
@@ -652,7 +657,6 @@ export function TaskChat({
|
||||
taskId={taskId}
|
||||
workspaceId={workspaceId}
|
||||
statusSummary={statusSummary}
|
||||
runErrors={runErrors}
|
||||
repositories={repositories}
|
||||
/>
|
||||
{isEmpty ? (
|
||||
|
||||
Reference in New Issue
Block a user