Skip to content

Commit e7bbfe2

Browse files
feat(site): allow modifying task prompts for starting tasks (#20812)
Closes coder/internal#1084 This PR adds the frontend implementation for modifying task prompts. --- 🤖 PR was initially written by Claude Sonnet 4.5 Thinking using [Coder Mux](https://github.com/coder/cmux) and then heavily modified by a human 👩
1 parent 36289d8 commit e7bbfe2

File tree

4 files changed

+495
-5
lines changed

4 files changed

+495
-5
lines changed

site/src/api/api.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2716,6 +2716,16 @@ class ApiMethods {
27162716
await this.axios.delete(`/api/v2/tasks/${user}/${id}`);
27172717
};
27182718

2719+
updateTaskInput = async (
2720+
user: string,
2721+
id: string,
2722+
input: string,
2723+
): Promise<void> => {
2724+
await this.axios.patch(`/api/v2/tasks/${user}/${id}/input`, {
2725+
input,
2726+
} satisfies TypesGen.UpdateTaskInputRequest);
2727+
};
2728+
27192729
createTaskFeedback = async (
27202730
_taskId: string,
27212731
_req: CreateTaskFeedbackRequest,
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import {
2+
MockTask,
3+
MockTaskWorkspace,
4+
mockApiError,
5+
} from "testHelpers/entities";
6+
import type { Meta, StoryObj } from "@storybook/react-vite";
7+
import { API } from "api/api";
8+
import { workspaceBuildParametersKey } from "api/queries/workspaceBuilds";
9+
import type { Workspace, WorkspaceBuildParameter } from "api/typesGenerated";
10+
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
11+
import { ModifyPromptDialog } from "./ModifyPromptDialog";
12+
13+
const mockTaskWorkspaceStarting: Workspace = {
14+
...MockTaskWorkspace,
15+
latest_build: {
16+
...MockTaskWorkspace.latest_build,
17+
status: "starting",
18+
},
19+
};
20+
21+
const mockBuildParameters: WorkspaceBuildParameter[] = [
22+
{
23+
name: "region",
24+
value: "us-east-1",
25+
},
26+
];
27+
28+
const meta: Meta<typeof ModifyPromptDialog> = {
29+
title: "pages/TaskPage/ModifyPromptDialog",
30+
component: ModifyPromptDialog,
31+
args: {
32+
task: MockTask,
33+
workspace: mockTaskWorkspaceStarting,
34+
open: true,
35+
onOpenChange: () => {},
36+
},
37+
parameters: {
38+
queries: [
39+
{
40+
key: workspaceBuildParametersKey(MockTaskWorkspace.latest_build.id),
41+
data: mockBuildParameters,
42+
},
43+
],
44+
},
45+
};
46+
47+
export default meta;
48+
type Story = StoryObj<typeof ModifyPromptDialog>;
49+
50+
export const WithModifiedPrompt: Story = {
51+
play: async ({ canvasElement }) => {
52+
const body = within(canvasElement.ownerDocument.body);
53+
const promptTextarea = body.getByLabelText("Prompt");
54+
55+
// Given: The user modifies the prompt
56+
await userEvent.clear(promptTextarea);
57+
await userEvent.type(promptTextarea, "Build a web server in Go");
58+
59+
// Then: We expect the submit button to not be disabled
60+
const submitButton = body.getByRole("button", {
61+
name: /update and restart build/i,
62+
});
63+
expect(submitButton).not.toBeDisabled();
64+
},
65+
};
66+
67+
export const EmptyPrompt: Story = {
68+
play: async ({ canvasElement }) => {
69+
const body = within(canvasElement.ownerDocument.body);
70+
const promptTextarea = body.getByLabelText("Prompt");
71+
72+
// Given: The prompt is empty
73+
await userEvent.clear(promptTextarea);
74+
75+
// Then: We expect the submit button to be disabled
76+
const submitButton = body.getByRole("button", {
77+
name: /update and restart build/i,
78+
});
79+
expect(submitButton).toBeDisabled();
80+
},
81+
};
82+
83+
export const UnchangedPrompt: Story = {
84+
play: async ({ canvasElement }) => {
85+
const body = within(canvasElement.ownerDocument.body);
86+
87+
// Given: The prompt is unchanged
88+
89+
// Then: We expect the submit button to be disabled
90+
const submitButton = body.getByRole("button", {
91+
name: /update and restart build/i,
92+
});
93+
expect(submitButton).toBeDisabled();
94+
},
95+
};
96+
97+
export const Submitting: Story = {
98+
beforeEach: async () => {
99+
spyOn(API, "getWorkspaceBuildByNumber").mockResolvedValue({
100+
...MockTaskWorkspace.latest_build,
101+
status: "canceled",
102+
job: {
103+
...MockTaskWorkspace.latest_build.job,
104+
completed_at: undefined,
105+
},
106+
});
107+
spyOn(API, "cancelWorkspaceBuild").mockResolvedValue({
108+
message: "Workspace build canceled",
109+
});
110+
spyOn(API, "waitForBuild").mockResolvedValue(undefined);
111+
spyOn(API, "stopWorkspace").mockResolvedValue(
112+
MockTaskWorkspace.latest_build,
113+
);
114+
spyOn(API, "updateTaskInput").mockImplementation(() => {
115+
return new Promise(() => {});
116+
});
117+
spyOn(API, "startWorkspace").mockResolvedValue(
118+
MockTaskWorkspace.latest_build,
119+
);
120+
},
121+
play: async ({ canvasElement, step }) => {
122+
const body = within(canvasElement.ownerDocument.body);
123+
124+
await step("Modify and submit the form", async () => {
125+
const promptTextarea = body.getByLabelText("Prompt");
126+
await userEvent.clear(promptTextarea);
127+
await userEvent.type(promptTextarea, "Create a REST API");
128+
129+
const submitButton = body.getByRole("button", {
130+
name: /update and restart build/i,
131+
});
132+
await userEvent.click(submitButton);
133+
});
134+
135+
await step("Shows loading state with spinner", async () => {
136+
const spinner = await body.findByTitle("Loading spinner");
137+
expect(spinner).toBeInTheDocument();
138+
139+
const submitButton = body.getByRole("button", {
140+
name: /update and restart build/i,
141+
});
142+
expect(submitButton).toBeDisabled();
143+
});
144+
},
145+
};
146+
147+
export const Success: Story = {
148+
beforeEach: async () => {
149+
spyOn(API, "getWorkspaceBuildByNumber").mockResolvedValue({
150+
...MockTaskWorkspace.latest_build,
151+
status: "canceled",
152+
job: {
153+
...MockTaskWorkspace.latest_build.job,
154+
completed_at: undefined,
155+
},
156+
});
157+
spyOn(API, "cancelWorkspaceBuild").mockResolvedValue({
158+
message: "Workspace build canceled",
159+
});
160+
spyOn(API, "waitForBuild").mockResolvedValue(undefined);
161+
spyOn(API, "stopWorkspace").mockResolvedValue(
162+
MockTaskWorkspace.latest_build,
163+
);
164+
spyOn(API, "updateTaskInput").mockResolvedValue();
165+
spyOn(API, "startWorkspace").mockResolvedValue(
166+
MockTaskWorkspace.latest_build,
167+
);
168+
},
169+
play: async ({ canvasElement, step }) => {
170+
const body = within(canvasElement.ownerDocument.body);
171+
172+
await step("Modify and submit the form", async () => {
173+
const promptTextarea = body.getByLabelText("Prompt");
174+
await userEvent.clear(promptTextarea);
175+
await userEvent.type(promptTextarea, "Create a REST API in Python");
176+
177+
const submitButton = body.getByRole("button", {
178+
name: /update and restart build/i,
179+
});
180+
await userEvent.click(submitButton);
181+
});
182+
183+
await step("API calls are made", async () => {
184+
await waitFor(() => {
185+
expect(API.cancelWorkspaceBuild).toHaveBeenCalledWith(
186+
mockTaskWorkspaceStarting.latest_build.id,
187+
);
188+
expect(API.stopWorkspace).toHaveBeenCalledWith(
189+
mockTaskWorkspaceStarting.id,
190+
);
191+
expect(API.updateTaskInput).toHaveBeenCalledWith(
192+
MockTask.owner_name,
193+
MockTask.id,
194+
"Create a REST API in Python",
195+
);
196+
expect(API.startWorkspace).toHaveBeenCalledWith(
197+
mockTaskWorkspaceStarting.id,
198+
MockTask.template_version_id,
199+
undefined,
200+
[
201+
{
202+
name: "region",
203+
value: "us-east-1",
204+
},
205+
],
206+
);
207+
});
208+
});
209+
},
210+
};
211+
212+
export const Failure: Story = {
213+
beforeEach: async () => {
214+
spyOn(API, "getWorkspaceBuildByNumber").mockResolvedValue({
215+
...MockTaskWorkspace.latest_build,
216+
status: "canceled",
217+
job: {
218+
...MockTaskWorkspace.latest_build.job,
219+
completed_at: undefined,
220+
},
221+
});
222+
spyOn(API, "cancelWorkspaceBuild").mockResolvedValue({
223+
message: "Workspace build canceled",
224+
});
225+
spyOn(API, "waitForBuild").mockResolvedValue(undefined);
226+
spyOn(API, "stopWorkspace").mockResolvedValue(
227+
MockTaskWorkspace.latest_build,
228+
);
229+
spyOn(API, "updateTaskInput").mockRejectedValue(
230+
mockApiError({
231+
message: "Failed to update task prompt",
232+
detail: "Build is not in a valid state for modification",
233+
}),
234+
);
235+
},
236+
play: async ({ canvasElement, step }) => {
237+
const body = within(canvasElement.ownerDocument.body);
238+
239+
await step("Modify and submit the form", async () => {
240+
const promptTextarea = body.getByLabelText("Prompt");
241+
await userEvent.clear(promptTextarea);
242+
await userEvent.type(promptTextarea, "Create a REST API");
243+
244+
const submitButton = body.getByRole("button", {
245+
name: /update and restart build/i,
246+
});
247+
await userEvent.click(submitButton);
248+
});
249+
250+
await step("Shows error message", async () => {
251+
await body.findByText(/Failed to update task prompt/i);
252+
});
253+
},
254+
};
255+
256+
export const RunningBuild: Story = {
257+
args: {
258+
workspace: {
259+
...MockTaskWorkspace,
260+
latest_build: {
261+
...MockTaskWorkspace.latest_build,
262+
status: "running",
263+
},
264+
},
265+
},
266+
play: async ({ canvasElement }) => {
267+
const body = within(canvasElement.ownerDocument.body);
268+
269+
// Verify error message is displayed
270+
expect(
271+
body.getByText(/Cannot modify the prompt of a running task/i),
272+
).toBeInTheDocument();
273+
274+
// Verify submit button is disabled
275+
const submitButton = body.getByRole("button", {
276+
name: /update and restart build/i,
277+
});
278+
expect(submitButton).toBeDisabled();
279+
},
280+
};

0 commit comments

Comments
 (0)