mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: allow sending follow-up prompts to a task when resuming (#22302)
## Summary This PR adds a follow-up flow for paused Tasks so users can submit another prompt as part of resuming the same task/session. https://github.com/user-attachments/assets/eabe5e91-704c-44ad-9e28-39f55e6c5923 ## What changed - **Task page UX** - Added a new **Follow-up** action in paused task state. - Added `FollowUpDialog` to collect and submit follow-up input. - **Follow-up flow behavior** - If task is paused/stopped: call resume first, then wait for polling to observe task `active`, then send input. - Dialog closes itself after successful send. - Added clear error handling for: - resume failure - build failure/canceled while resuming - send failure - **Stable API route parity** - Added `POST /tasks/{user}/{task}/pause` and `POST /tasks/{user}/{task}/resume` to the stable `/api/v2/tasks` router block. - **SDK alignment** - Updated `codersdk` pause/resume methods to use stable `/api/v2/tasks/...` endpoints instead of `/api/experimental/...`. - **Frontend API/query alignment** - `site` task pause/resume/send paths are on stable `/api/v2/tasks/...`. - Updated task query helpers accordingly. - **Storybook coverage** - Added follow-up dialog stories for key states: - open dialog - active direct send - auto-resume then send - resuming progress visible - resume build failure - send failure - empty message disabled - Added/updated mocks for task logs and new follow-up flows. Closes https://github.com/coder/internal/issues/1269
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import type { Task } from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "components/Dialog/Dialog";
|
||||
import { Textarea } from "components/Textarea/Textarea";
|
||||
import { useFormik } from "formik";
|
||||
import type { FC } from "react";
|
||||
import { useId } from "react";
|
||||
|
||||
type FollowUpDialogProps = {
|
||||
task: Task;
|
||||
initialMessage: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (message: string) => void;
|
||||
};
|
||||
|
||||
export const FollowUpDialog: FC<FollowUpDialogProps> = ({
|
||||
task,
|
||||
initialMessage,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const formId = useId();
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
message: initialMessage,
|
||||
},
|
||||
enableReinitialize: true,
|
||||
onSubmit: (values) => {
|
||||
const message = values.message.trim();
|
||||
if (message.length === 0) {
|
||||
return;
|
||||
}
|
||||
onSubmit(message);
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Send Follow-up Message</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add another message to this task. The task will resume and send this
|
||||
follow-up automatically.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form id={formId} className="space-y-4" onSubmit={formik.handleSubmit}>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`${formId}-message`}
|
||||
className="block text-sm font-medium text-content-primary mb-2"
|
||||
>
|
||||
Follow-up message
|
||||
</label>
|
||||
<Textarea
|
||||
id={`${formId}-message`}
|
||||
name="message"
|
||||
value={formik.values.message}
|
||||
onChange={formik.handleChange}
|
||||
rows={10}
|
||||
className="w-full"
|
||||
placeholder={`Continue "${task.display_name}" after resume by asking for the next step...`}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={formik.values.message.trim().length === 0}
|
||||
>
|
||||
Send Follow-up
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -136,6 +136,22 @@ const MockTaskLogsResponse: TaskLogsResponse = {
|
||||
snapshot_at: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
};
|
||||
|
||||
const getFollowUpDialog = async (canvasElement: HTMLElement) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const dialogs = await body.findAllByRole("dialog", {
|
||||
name: /send follow-up message/i,
|
||||
});
|
||||
// Radix dialog content can linger during transitions; use the newest instance.
|
||||
const dialog = dialogs.at(-1);
|
||||
if (!dialog) {
|
||||
throw new Error("Follow-up dialog was not found.");
|
||||
}
|
||||
return {
|
||||
body,
|
||||
dialog: within(dialog),
|
||||
};
|
||||
};
|
||||
|
||||
const meta: Meta<typeof TaskPage> = {
|
||||
title: "pages/TaskPage",
|
||||
component: TaskPage,
|
||||
@@ -369,6 +385,311 @@ export const TaskPausedSnapshotTooltip: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const TaskPausedWithFollowUpDialog: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTask").mockResolvedValue({
|
||||
...MockTask,
|
||||
status: "paused",
|
||||
});
|
||||
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
|
||||
MockStoppedWorkspace,
|
||||
);
|
||||
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogsResponse);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const followUpButton = await canvas.findByRole("button", {
|
||||
name: /follow-up/i,
|
||||
});
|
||||
await userEvent.click(followUpButton);
|
||||
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const dialogTitle = await body.findByText("Send Follow-up Message");
|
||||
expect(dialogTitle).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const TaskFollowUpAutoResumeSuccess: Story = {
|
||||
beforeEach: () => {
|
||||
let isTaskActive = false;
|
||||
spyOn(API, "getTask").mockImplementation(async () => ({
|
||||
...MockTask,
|
||||
status: isTaskActive ? "active" : "paused",
|
||||
}));
|
||||
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
|
||||
MockStoppedWorkspace,
|
||||
);
|
||||
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogsResponse);
|
||||
spyOn(API, "sendTaskInput").mockImplementation(async () => {
|
||||
if (!isTaskActive) {
|
||||
throw {
|
||||
...mockApiError({
|
||||
message: "Task is paused",
|
||||
detail: "Resume required before sending",
|
||||
}),
|
||||
status: 409,
|
||||
};
|
||||
}
|
||||
});
|
||||
spyOn(API, "resumeTask").mockImplementation(async () => {
|
||||
isTaskActive = true;
|
||||
return {
|
||||
workspace_build: MockStartingWorkspace.latest_build,
|
||||
};
|
||||
});
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await canvas.findByRole("button", { name: /follow-up/i }),
|
||||
);
|
||||
const { dialog } = await getFollowUpDialog(canvasElement);
|
||||
await userEvent.type(
|
||||
await dialog.findByLabelText(/follow-up message/i),
|
||||
"Continue from where you left off",
|
||||
);
|
||||
await userEvent.click(
|
||||
await dialog.findByRole("button", { name: /send follow-up/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.resumeTask).toHaveBeenCalled();
|
||||
expect(API.sendTaskInput).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const TaskFollowUpActiveTaskDirectSend: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTask").mockResolvedValue({
|
||||
...MockTask,
|
||||
status: "active",
|
||||
});
|
||||
// Keep paused UI visible (for Follow-up button) while simulating an already-active task.
|
||||
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
|
||||
MockStoppedWorkspace,
|
||||
);
|
||||
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogsResponse);
|
||||
spyOn(API, "sendTaskInput").mockResolvedValue();
|
||||
spyOn(API, "resumeTask").mockResolvedValue({
|
||||
workspace_build: MockWorkspace.latest_build,
|
||||
});
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await canvas.findByRole("button", { name: /follow-up/i }),
|
||||
);
|
||||
const { dialog } = await getFollowUpDialog(canvasElement);
|
||||
await userEvent.type(
|
||||
await dialog.findByLabelText(/follow-up message/i),
|
||||
"Please continue with the next step",
|
||||
);
|
||||
await userEvent.click(
|
||||
await dialog.findByRole("button", { name: /send follow-up/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.sendTaskInput).toHaveBeenCalledTimes(1);
|
||||
expect(API.resumeTask).not.toHaveBeenCalled();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const TaskFollowUpEmptyMessageDisabled: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTask").mockResolvedValue({
|
||||
...MockTask,
|
||||
status: "paused",
|
||||
});
|
||||
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
|
||||
MockStoppedWorkspace,
|
||||
);
|
||||
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogsResponse);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await canvas.findByRole("button", { name: /follow-up/i }),
|
||||
);
|
||||
const { dialog } = await getFollowUpDialog(canvasElement);
|
||||
const submit = await dialog.findByRole("button", {
|
||||
name: /send follow-up/i,
|
||||
});
|
||||
expect(submit).toBeDisabled();
|
||||
},
|
||||
};
|
||||
|
||||
export const TaskFollowUpShowsResumingProgress: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTask").mockResolvedValue({
|
||||
...MockTask,
|
||||
status: "paused",
|
||||
});
|
||||
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
|
||||
MockStoppedWorkspace,
|
||||
);
|
||||
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogsResponse);
|
||||
// Keep resuming stage visible for assertions.
|
||||
spyOn(API, "resumeTask").mockImplementation(() => new Promise(() => {}));
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await canvas.findByRole("button", { name: /follow-up/i }),
|
||||
);
|
||||
const { body, dialog } = await getFollowUpDialog(canvasElement);
|
||||
const messageInput = await dialog.findByLabelText(/follow-up message/i);
|
||||
await userEvent.type(messageInput, "Continue task");
|
||||
await userEvent.click(
|
||||
await dialog.findByRole("button", { name: /send follow-up/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
body.queryByRole("heading", { name: /send follow-up message/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(canvas.getByText("Resuming task...")).toBeInTheDocument();
|
||||
const pendingLabel = canvas.getByText(/Pending follow-up:/i);
|
||||
expect(pendingLabel.parentElement).toHaveTextContent("Continue task");
|
||||
expect(
|
||||
canvas.getByText(/clears the pending follow-up message/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const TaskFollowUpRetrySendFailure: Story = {
|
||||
beforeEach: () => {
|
||||
let isTaskActive = false;
|
||||
spyOn(API, "getTask").mockImplementation(async () => ({
|
||||
...MockTask,
|
||||
status: isTaskActive ? "active" : "paused",
|
||||
}));
|
||||
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
|
||||
MockStoppedWorkspace,
|
||||
);
|
||||
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogsResponse);
|
||||
spyOn(API, "sendTaskInput").mockRejectedValue(
|
||||
new Error("Failed to send message"),
|
||||
);
|
||||
spyOn(API, "resumeTask").mockImplementation(async () => {
|
||||
isTaskActive = true;
|
||||
return {
|
||||
workspace_build: MockStartingWorkspace.latest_build,
|
||||
};
|
||||
});
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await canvas.findByRole("button", { name: /follow-up/i }),
|
||||
);
|
||||
const { dialog } = await getFollowUpDialog(canvasElement);
|
||||
const messageInput = await dialog.findByLabelText(/follow-up message/i);
|
||||
await userEvent.type(messageInput, "Please continue");
|
||||
await userEvent.click(
|
||||
await dialog.findByRole("button", { name: /send follow-up/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(canvas.getByText("Failed to send message")).toBeInTheDocument();
|
||||
const pendingLabel = canvas.getByText(/Pending follow-up:/i);
|
||||
expect(pendingLabel.parentElement).toHaveTextContent("Please continue");
|
||||
expect(
|
||||
canvas.getByRole("button", { name: /follow-up/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const TaskFollowUpResumeBuildFailure: Story = {
|
||||
beforeEach: () => {
|
||||
let hasBuildFailed = false;
|
||||
spyOn(API, "getTask").mockResolvedValue({
|
||||
...MockTask,
|
||||
status: "paused",
|
||||
});
|
||||
spyOn(API, "getWorkspaceByOwnerAndName").mockImplementation(async () => {
|
||||
if (!hasBuildFailed) {
|
||||
return MockStoppedWorkspace;
|
||||
}
|
||||
return {
|
||||
...MockStoppedWorkspace,
|
||||
latest_build: {
|
||||
...MockStoppedWorkspace.latest_build,
|
||||
status: "failed",
|
||||
},
|
||||
};
|
||||
});
|
||||
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogsResponse);
|
||||
spyOn(API, "resumeTask").mockImplementation(async () => {
|
||||
hasBuildFailed = true;
|
||||
return {
|
||||
workspace_build: MockStartingWorkspace.latest_build,
|
||||
};
|
||||
});
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await canvas.findByRole("button", { name: /follow-up/i }),
|
||||
);
|
||||
const { dialog } = await getFollowUpDialog(canvasElement);
|
||||
const messageInput = await dialog.findByLabelText(/follow-up message/i);
|
||||
await userEvent.type(messageInput, "Continue task");
|
||||
await userEvent.click(
|
||||
await dialog.findByRole("button", { name: /send follow-up/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.resumeTask).toHaveBeenCalled();
|
||||
});
|
||||
expect(await canvas.findByText("Task build failed")).toBeInTheDocument();
|
||||
expect(
|
||||
await canvas.findByText("Please check the logs for more details."),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const TaskFollowUpNon409SendFailure: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTask").mockResolvedValue({
|
||||
...MockTask,
|
||||
status: "active",
|
||||
});
|
||||
// Keep paused UI visible (for Follow-up button) while simulating active-task send behavior.
|
||||
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
|
||||
MockStoppedWorkspace,
|
||||
);
|
||||
spyOn(API, "getTaskLogs").mockResolvedValue(MockTaskLogsResponse);
|
||||
spyOn(API, "sendTaskInput").mockRejectedValue(
|
||||
new Error("Failed to send message"),
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await canvas.findByRole("button", { name: /follow-up/i }),
|
||||
);
|
||||
const { dialog } = await getFollowUpDialog(canvasElement);
|
||||
const messageInput = await dialog.findByLabelText(/follow-up message/i);
|
||||
await userEvent.type(messageInput, "Continue task");
|
||||
await userEvent.click(
|
||||
await dialog.findByRole("button", { name: /send follow-up/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(canvas.getByText("Failed to send message")).toBeInTheDocument();
|
||||
const pendingLabel = canvas.getByText(/Pending follow-up:/i);
|
||||
expect(pendingLabel.parentElement).toHaveTextContent("Continue task");
|
||||
expect(
|
||||
canvas.getByRole("button", { name: /follow-up/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const TaskPausedTimeout: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTask").mockResolvedValue({
|
||||
|
||||
@@ -2,7 +2,10 @@ import { API } from "api/api";
|
||||
import { getErrorDetail, getErrorMessage, isApiError } from "api/errors";
|
||||
import { pauseTask, resumeTask, taskLogs } from "api/queries/tasks";
|
||||
import { template as templateQueryOptions } from "api/queries/templates";
|
||||
import { workspaceByOwnerAndName } from "api/queries/workspaces";
|
||||
import {
|
||||
workspaceByOwnerAndName,
|
||||
workspaceByOwnerAndNameKey,
|
||||
} from "api/queries/workspaces";
|
||||
import type {
|
||||
Task,
|
||||
TaskLogEntry,
|
||||
@@ -37,6 +40,7 @@ import {
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -53,11 +57,19 @@ import {
|
||||
getActiveTransitionStats,
|
||||
WorkspaceBuildProgress,
|
||||
} from "../WorkspacePage/WorkspaceBuildProgress";
|
||||
import { FollowUpDialog } from "./FollowUpDialog";
|
||||
import { ModifyPromptDialog } from "./ModifyPromptDialog";
|
||||
import { TaskAppIFrame } from "./TaskAppIframe";
|
||||
import { TaskApps } from "./TaskApps";
|
||||
import { TaskTopbar } from "./TaskTopbar";
|
||||
|
||||
type FollowUpStage =
|
||||
| "idle"
|
||||
| "resuming"
|
||||
| "waitingForActive"
|
||||
| "sending"
|
||||
| "error";
|
||||
|
||||
const TaskPageLayout: FC<PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
<div className="flex items-stretch h-full">
|
||||
@@ -69,10 +81,29 @@ const TaskPageLayout: FC<PropsWithChildren> = ({ children }) => {
|
||||
|
||||
const TaskPage = () => {
|
||||
const [isModifyDialogOpen, setIsModifyDialogOpen] = useState(false);
|
||||
const [isFollowUpDialogOpen, setIsFollowUpDialogOpen] = useState(false);
|
||||
const [followUpDraft, setFollowUpDraft] = useState("");
|
||||
const [followUpStage, setFollowUpStage] = useState<FollowUpStage>("idle");
|
||||
const [followUpError, setFollowUpError] = useState<string>();
|
||||
const { taskId, username } = useParams() as {
|
||||
taskId: string;
|
||||
username: string;
|
||||
};
|
||||
const taskRouteKey = `${username}/${taskId}`;
|
||||
const prevTaskRouteKeyRef = useRef(taskRouteKey);
|
||||
const queryClient = useQueryClient();
|
||||
const resumeFollowUpMutation = useMutation({
|
||||
mutationFn: () => API.resumeTask(username, taskId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
});
|
||||
const sendFollowUpMutation = useMutation({
|
||||
mutationFn: (input: string) => API.sendTaskInput(username, taskId, input),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
});
|
||||
const { data: task, ...taskQuery } = useQuery({
|
||||
queryKey: ["tasks", username, taskId],
|
||||
queryFn: () => API.getTask(username, taskId),
|
||||
@@ -91,6 +122,116 @@ const TaskPage = () => {
|
||||
const error = taskQuery.error ?? workspaceQuery.error;
|
||||
const waitingStatuses: WorkspaceStatus[] = ["starting", "pending"];
|
||||
|
||||
useEffect(() => {
|
||||
if (prevTaskRouteKeyRef.current === taskRouteKey) {
|
||||
return;
|
||||
}
|
||||
prevTaskRouteKeyRef.current = taskRouteKey;
|
||||
// Reset in-memory follow-up state when navigating to another task route.
|
||||
setFollowUpDraft("");
|
||||
setFollowUpStage("idle");
|
||||
setFollowUpError(undefined);
|
||||
}, [taskRouteKey]);
|
||||
|
||||
const startSendingFollowUp = useCallback(
|
||||
async (message: string) => {
|
||||
setFollowUpStage("sending");
|
||||
try {
|
||||
await sendFollowUpMutation.mutateAsync(message);
|
||||
setFollowUpDraft("");
|
||||
setFollowUpError(undefined);
|
||||
setFollowUpStage("idle");
|
||||
} catch (error) {
|
||||
setFollowUpError(getErrorMessage(error, "Failed to send message."));
|
||||
setFollowUpStage("error");
|
||||
}
|
||||
},
|
||||
[sendFollowUpMutation],
|
||||
);
|
||||
|
||||
const queueFollowUp = useCallback(
|
||||
async (message: string) => {
|
||||
const trimmedMessage = message.trim();
|
||||
if (!trimmedMessage || !task || !workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFollowUpDraft(trimmedMessage);
|
||||
setFollowUpError(undefined);
|
||||
|
||||
if (task.status === "active") {
|
||||
void startSendingFollowUp(trimmedMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
followUpStage === "resuming" ||
|
||||
followUpStage === "waitingForActive" ||
|
||||
followUpStage === "sending"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFollowUpStage("resuming");
|
||||
try {
|
||||
await resumeFollowUpMutation.mutateAsync();
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["tasks", task.owner_name, task.id],
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: workspaceByOwnerAndNameKey(
|
||||
workspace.owner_name,
|
||||
workspace.name,
|
||||
),
|
||||
});
|
||||
setFollowUpStage("waitingForActive");
|
||||
} catch (error) {
|
||||
setFollowUpError(getErrorMessage(error, "Failed to resume task."));
|
||||
setFollowUpStage("error");
|
||||
}
|
||||
},
|
||||
[
|
||||
followUpStage,
|
||||
queryClient,
|
||||
resumeFollowUpMutation,
|
||||
startSendingFollowUp,
|
||||
task,
|
||||
workspace,
|
||||
],
|
||||
);
|
||||
|
||||
const openFollowUpDialog = useCallback(() => {
|
||||
setIsFollowUpDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (followUpStage !== "resuming" && followUpStage !== "waitingForActive") {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
workspace?.latest_build.status === "failed" ||
|
||||
workspace?.latest_build.status === "canceled"
|
||||
) {
|
||||
setFollowUpError(
|
||||
"Failed to resume task because the workspace build did not complete successfully.",
|
||||
);
|
||||
setFollowUpStage("error");
|
||||
}
|
||||
}, [followUpStage, workspace?.latest_build.status]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only auto-send a queued follow-up after we explicitly entered the
|
||||
// waiting stage and the task transitions back to active.
|
||||
if (
|
||||
!followUpDraft ||
|
||||
task?.status !== "active" ||
|
||||
followUpStage !== "waitingForActive"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void startSendingFollowUp(followUpDraft);
|
||||
}, [followUpDraft, followUpStage, startSendingFollowUp, task?.status]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<TaskPageLayout>
|
||||
@@ -159,6 +300,10 @@ const TaskPage = () => {
|
||||
task={task}
|
||||
workspace={workspace}
|
||||
onEditPrompt={() => setIsModifyDialogOpen(true)}
|
||||
onAddFollowUp={openFollowUpDialog}
|
||||
followUpDraft={followUpDraft}
|
||||
followUpStage={followUpStage}
|
||||
followUpError={followUpError}
|
||||
/>
|
||||
);
|
||||
} else if (workspace.latest_build.status === "canceling") {
|
||||
@@ -225,6 +370,15 @@ const TaskPage = () => {
|
||||
open={isModifyDialogOpen}
|
||||
onOpenChange={setIsModifyDialogOpen}
|
||||
/>
|
||||
<FollowUpDialog
|
||||
task={task}
|
||||
initialMessage={followUpDraft}
|
||||
open={isFollowUpDialogOpen}
|
||||
onOpenChange={setIsFollowUpDialogOpen}
|
||||
onSubmit={(message) => {
|
||||
void queueFollowUp(message);
|
||||
}}
|
||||
/>
|
||||
</TaskPageLayout>
|
||||
);
|
||||
};
|
||||
@@ -437,9 +591,21 @@ type TaskPausedProps = {
|
||||
task: Task;
|
||||
workspace: Workspace;
|
||||
onEditPrompt: () => void;
|
||||
onAddFollowUp: () => void;
|
||||
followUpDraft: string;
|
||||
followUpStage: FollowUpStage;
|
||||
followUpError?: string;
|
||||
};
|
||||
|
||||
const TaskPaused: FC<TaskPausedProps> = ({ task, workspace, onEditPrompt }) => {
|
||||
const TaskPaused: FC<TaskPausedProps> = ({
|
||||
task,
|
||||
workspace,
|
||||
onEditPrompt,
|
||||
onAddFollowUp,
|
||||
followUpDraft,
|
||||
followUpStage,
|
||||
followUpError,
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Use mutation config directly to customize error handling:
|
||||
@@ -470,6 +636,17 @@ const TaskPaused: FC<TaskPausedProps> = ({ task, workspace, onEditPrompt }) => {
|
||||
const apiError = isApiError(resumeMutation.error)
|
||||
? resumeMutation.error
|
||||
: undefined;
|
||||
const hasPendingFollowUp = followUpDraft.trim().length > 0;
|
||||
const isFollowUpSending =
|
||||
followUpStage === "resuming" ||
|
||||
followUpStage === "waitingForActive" ||
|
||||
followUpStage === "sending";
|
||||
const followUpStatusLabels: Record<string, string> = {
|
||||
resuming: "Resuming task...",
|
||||
waitingForActive: "Waiting for the task to become active...",
|
||||
sending: "Sending follow-up message...",
|
||||
};
|
||||
const followUpStatusLabel = followUpStatusLabels[followUpStage];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -494,18 +671,51 @@ const TaskPaused: FC<TaskPausedProps> = ({ task, workspace, onEditPrompt }) => {
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<div className="flex flex-row gap-4">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isWaitingForStart}
|
||||
onClick={() => resumeMutation.mutate()}
|
||||
>
|
||||
<Spinner loading={isWaitingForStart} />
|
||||
Resume
|
||||
</Button>
|
||||
<Button size="sm" onClick={onEditPrompt} variant="outline">
|
||||
Edit prompt
|
||||
</Button>
|
||||
<div className="flex flex-col gap-3 items-center">
|
||||
<div className="flex flex-row gap-4">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isWaitingForStart}
|
||||
onClick={() => resumeMutation.mutate()}
|
||||
>
|
||||
<Spinner loading={isWaitingForStart} />
|
||||
Resume
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onEditPrompt}>
|
||||
Edit prompt
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onAddFollowUp}>
|
||||
Follow-up
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasPendingFollowUp && (
|
||||
<div className="w-full max-w-xl rounded-md border border-border p-3 text-left text-sm">
|
||||
<p className="m-0 text-content-primary">
|
||||
<strong>Pending follow-up:</strong> {followUpDraft}
|
||||
</p>
|
||||
{followUpStatusLabel && (
|
||||
<p className="m-0 mt-2 text-content-secondary flex items-center gap-2">
|
||||
<Spinner loading />
|
||||
{followUpStatusLabel}
|
||||
</p>
|
||||
)}
|
||||
{followUpError && (
|
||||
<p className="m-0 mt-2 text-content-destructive">
|
||||
{followUpError}
|
||||
</p>
|
||||
)}
|
||||
<p className="m-0 mt-2 text-content-secondary">
|
||||
Refreshing or leaving this page clears the pending follow-up
|
||||
message.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!hasPendingFollowUp && isFollowUpSending && (
|
||||
<p className="m-0 text-content-secondary text-sm">
|
||||
<Spinner loading /> Processing follow-up message...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -517,7 +727,7 @@ const TaskPaused: FC<TaskPausedProps> = ({ task, workspace, onEditPrompt }) => {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
disabled={isWaitingForStart}
|
||||
disabled={isWaitingForStart || isFollowUpSending}
|
||||
onClick={() => resumeMutation.mutate()}
|
||||
>
|
||||
<Spinner loading={isWaitingForStart} />
|
||||
|
||||
Reference in New Issue
Block a user