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:
Sas Swart
2026-03-06 12:20:00 +00:00
committed by GitHub
parent ba05188934
commit 4e781c9323
3 changed files with 641 additions and 15 deletions
@@ -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({
+225 -15
View File
@@ -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} />