Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ vi.mock("./api", () => ({
createTask: vi.fn(),
updateTask: vi.fn(),
createTaskFollowUp: vi.fn(),
forceTaskFollowUp: vi.fn(),
deleteTask: vi.fn(),
settings: vi.fn(),
patchSettings: vi.fn(),
Expand Down
26 changes: 23 additions & 3 deletions src/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
ProviderStatus,
Task,
TaskCreateInput,
TaskFollowUpMode,
TaskStatus,
} from "../shared/types";
import { COLUMN_LABELS, TASK_STATUSES } from "../shared/types";
Expand Down Expand Up @@ -288,8 +289,12 @@ export function App() {
}
}

async function askTaskFollowUp(taskId: string, prompt: string) {
const result = await api.createTaskFollowUp(taskId, prompt);
async function askTaskFollowUp(
taskId: string,
prompt: string,
mode: TaskFollowUpMode = "queue",
) {
const result = await api.createTaskFollowUp(taskId, prompt, mode);
setTasks((current) => current.map((task) => (task.id === taskId ? result.task : task)));
setEditingTask((current) => (current?.id === taskId ? result.task : current));
}
Expand All @@ -300,6 +305,12 @@ export function App() {
setEditingTask((current) => (current?.id === taskId ? result.task : current));
}

async function forceTaskFollowUp(taskId: string, executionId: string) {
const result = await api.forceTaskFollowUp(taskId, executionId);
setTasks((current) => current.map((task) => (task.id === taskId ? result.task : task)));
setEditingTask((current) => (current?.id === taskId ? result.task : current));
}

async function createDraftTask(input: TaskCreateInput) {
const result = await api.createTask(input);
setTasks((current) => [...current, result.task]);
Expand Down Expand Up @@ -648,8 +659,17 @@ export function App() {
}
}}
onDelete={editingTask ? () => deleteTask(editingTask.id) : undefined}
onAskFollowUp={editingTask ? (prompt) => askTaskFollowUp(editingTask.id, prompt) : undefined}
onAskFollowUp={
editingTask
? (prompt, mode) => askTaskFollowUp(editingTask.id, prompt, mode)
: undefined
}
onAbortExecution={editingTask ? () => abortTaskRun(editingTask.id) : undefined}
onForceFollowUp={
editingTask
? (executionId) => forceTaskFollowUp(editingTask.id, executionId)
: undefined
}
onCreateDraft={editingTask ? createDraftTask : undefined}
onSave={createOrUpdateTask}
/>
Expand Down
12 changes: 10 additions & 2 deletions src/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type {
Task,
TaskCreateInput,
TaskExecution,
TaskFollowUpInput,
TaskFollowUpMode,
TaskUpdateInput,
} from "../shared/types";

Expand Down Expand Up @@ -56,15 +58,21 @@ export const api = {
body: JSON.stringify(input),
});
},
async createTaskFollowUp(id: string, prompt: string) {
async createTaskFollowUp(id: string, prompt: string, mode: TaskFollowUpMode = "queue") {
const input: TaskFollowUpInput = { prompt, mode };
return request<TaskMutationResponse>(`/api/tasks/${id}/follow-up`, {
method: "POST",
body: JSON.stringify({ prompt }),
body: JSON.stringify(input),
});
},
async abortTaskRun(id: string) {
return request<TaskMutationResponse>(`/api/tasks/${id}/abort`, { method: "POST" });
},
async forceTaskFollowUp(id: string, executionId: string) {
return request<TaskMutationResponse>(`/api/tasks/${id}/follow-up/${executionId}/force`, {
method: "POST",
});
},
async deleteTask(id: string) {
return request<void>(`/api/tasks/${id}`, { method: "DELETE" });
},
Expand Down
81 changes: 81 additions & 0 deletions src/client/components/TaskModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,81 @@ describe("TaskModal", () => {
expect(document.body.textContent).toContain("Run cancelled.");
});

it("offers force request on a queued follow-up while active AI work is running", async () => {
const onForceFollowUp = vi.fn().mockResolvedValue(undefined);
await renderTaskModal(
{
...task(),
execution: execution({
id: "execution-follow-up",
status: "queued",
endedAt: null,
requestKind: "follow_up",
requestPrompt: "Stop and use this correction.",
progressSummary: "Follow-up queued.",
previousExecutions: [
execution({
id: "execution-active",
status: "running",
endedAt: null,
progressSummary: "Preparing the task context.",
}),
],
}),
},
{ onForceFollowUp },
);

expect(findButton("Force request")).toBeTruthy();
expect(document.body.textContent).toContain("Stop and use this correction.");
await clickButton("Force request");
await flushReactPromises();

expect(onForceFollowUp).toHaveBeenCalledWith("execution-follow-up");
expect(document.body.textContent).toContain("Forced request queued.");
});

it("keeps queued follow-ups forceable when force fails", async () => {
const onForceFollowUp = vi
.fn()
.mockRejectedValueOnce(new Error("Network offline"))
.mockResolvedValueOnce(undefined);
await renderTaskModal(
{
...task(),
execution: execution({
id: "execution-follow-up",
status: "queued",
endedAt: null,
requestKind: "follow_up",
requestPrompt: "Force with retry context.",
progressSummary: "Follow-up queued.",
previousExecutions: [
execution({
id: "execution-active",
status: "running",
endedAt: null,
progressSummary: "Preparing the task context.",
}),
],
}),
},
{ onForceFollowUp },
);

await clickButton("Force request");
await flushReactPromises();

expect(document.body.textContent).toContain("Network offline");
expect(findButton("Force request")).toBeTruthy();

await clickButton("Force request");
await flushReactPromises();

expect(onForceFollowUp).toHaveBeenNthCalledWith(2, "execution-follow-up");
expect(document.body.textContent).toContain("Forced request queued.");
});

it("shows cancelled runs as terminal task history", async () => {
await renderTaskModal({
...task(),
Expand Down Expand Up @@ -608,6 +683,12 @@ async function changeField(selector: string, value: string) {
});
}

async function flushReactPromises() {
await act(async () => {
await Promise.resolve();
});
}

function getButton(label: string) {
const button = findButton(label);
expect(button).toBeTruthy();
Expand Down
Loading