feat(web): wire up MCP task support — Tasks screen, Run-as-task, status toasts (#1422) (#1424)

Wires the ported Tasks screen + core task subsystem through App.tsx: sticky Clear Completed, cancel/refresh error toasts, core-side per-task progress correlation, Run-as-task on the Tools screen (gated on server support), per-server Task TTL, and live task-status toasts.
This commit is contained in:
Cliff Hall 2026-06-04 08:49:47 -04:00 • committed by GitHub
commit ed6eda7492
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1192 additions and 40 deletions

View file

@ -341,6 +341,7 @@ describe("RemoteClientTransport", () => {
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 0,
};
const transport = new RemoteClientTransport(
{

View file

@ -201,6 +201,8 @@ describe("serverEntriesToMcpConfig", () => {
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
// Absent taskTtl on disk → product default in memory (for the form)
taskTtl: 60000,
// Nested oauth on disk → flat oauthClientId in memory
oauthClientId: "the-client",
});
@ -272,6 +274,7 @@ describe("serverEntriesToMcpConfig", () => {
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 0,
},
connection: { status: "disconnected" },
},
@ -295,6 +298,7 @@ describe("serverEntriesToMcpConfig", () => {
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 0,
},
connection: { status: "disconnected" },
},
@ -302,6 +306,7 @@ describe("serverEntriesToMcpConfig", () => {
const stored = serverEntriesToMcpConfig(entries).mcpServers.alpha;
expect(stored).not.toHaveProperty("connectionTimeout");
expect(stored).not.toHaveProperty("requestTimeout");
expect(stored).not.toHaveProperty("taskTtl");
expect(stored).not.toHaveProperty("oauth");
expect(stored).not.toHaveProperty("headers");
expect(stored).not.toHaveProperty("metadata");

View file

@ -243,4 +243,101 @@ describe("ManagedRequestorTasksState", () => {
state.destroy();
expect(() => state.destroy()).not.toThrow();
});
describe("clearCompleted", () => {
it("drops terminal-state tasks and keeps active ones", async () => {
client.setStatus("connected");
client.queueTaskPages({
tasks: [
task("active", "working"),
task("done", "completed"),
task("bad", "failed"),
task("gone", "cancelled"),
task("waiting", "input_required"),
],
});
await state.refresh();
const changePromise = waitForChange(state);
state.clearCompleted();
const next = await changePromise;
expect(next.map((t) => t.taskId)).toEqual(["active", "waiting"]);
expect(state.getTasks().map((t) => t.taskId)).toEqual([
"active",
"waiting",
]);
});
it("is a no-op (no event) when there are no terminal tasks", async () => {
client.setStatus("connected");
client.queueTaskPages({ tasks: [task("active", "working")] });
await state.refresh();
let dispatched = false;
state.addEventListener("tasksChange", () => {
dispatched = true;
});
state.clearCompleted();
expect(dispatched).toBe(false);
expect(state.getTasks().map((t) => t.taskId)).toEqual(["active"]);
});
it("keeps cleared tasks gone across a subsequent refresh (sticky)", async () => {
client.setStatus("connected");
client.queueTaskPages({
tasks: [task("active", "working"), task("done", "completed")],
});
await state.refresh();
state.clearCompleted();
expect(state.getTasks().map((t) => t.taskId)).toEqual(["active"]);
// Server still lists the dismissed task on the next refresh — it must be
// filtered back out rather than reappearing.
client.queueTaskPages({
tasks: [task("active", "working"), task("done", "completed")],
});
const refreshed = await state.refresh();
expect(refreshed.map((t) => t.taskId)).toEqual(["active"]);
});
it("ignores a late status update for a cleared task (sticky)", async () => {
client.setStatus("connected");
client.queueTaskPages({ tasks: [task("done", "completed")] });
await state.refresh();
state.clearCompleted();
expect(state.getTasks()).toEqual([]);
let dispatched = false;
state.addEventListener("tasksChange", () => {
dispatched = true;
});
client.dispatchTypedEvent("taskStatusChange", {
taskId: "done",
task: task("done", "completed"),
});
client.dispatchTypedEvent("requestorTaskUpdated", {
taskId: "done",
task: task("done", "working"),
});
client.dispatchTypedEvent("taskCancelled", { taskId: "done" });
expect(dispatched).toBe(false);
expect(state.getTasks()).toEqual([]);
});
it("resets dismissals on disconnect so a reconnect starts fresh", async () => {
client.setStatus("connected");
client.queueTaskPages({ tasks: [task("done", "completed")] });
await state.refresh();
state.clearCompleted();
// Disconnect clears the dismissed set...
client.setStatus("disconnected");
client.setStatus("connected");
// ...so the same task id can reappear on a fresh refresh.
client.queueTaskPages({ tasks: [task("done", "completed")] });
const refreshed = await state.refresh();
expect(refreshed.map((t) => t.taskId)).toEqual(["done"]);
});
});
});

View file

@ -116,4 +116,33 @@ describe("useManagedRequestorTasks", () => {
expect(result.current.tasks).toEqual([]);
});
it("clearCompleted() drops terminal tasks through to the state", async () => {
client.queueTaskPages({
tasks: [task("active", "working"), task("done", "completed")],
});
await state.refresh();
const { result } = renderHook(() =>
useManagedRequestorTasks(client, state),
);
await waitFor(() => {
expect(result.current.tasks.map((t) => t.taskId)).toEqual([
"active",
"done",
]);
});
act(() => {
result.current.clearCompleted();
});
await waitFor(() => {
expect(result.current.tasks.map((t) => t.taskId)).toEqual(["active"]);
});
});
it("clearCompleted() is a safe no-op when state is null", () => {
const { result } = renderHook(() => useManagedRequestorTasks(client, null));
expect(() => result.current.clearCompleted()).not.toThrow();
});
});

View file

@ -391,6 +391,7 @@ describe("useServers", () => {
metadata: [{ key: "trace", value: "abc" }],
connectionTimeout: 5000,
requestTimeout: 30000,
taskTtl: 30000,
});
});
@ -400,6 +401,7 @@ describe("useServers", () => {
metadata: [{ key: "trace", value: "abc" }],
connectionTimeout: 5000,
requestTimeout: 30000,
taskTtl: 30000,
});
});
const stored = readConfig(h.configPath).mcpServers
@ -414,6 +416,7 @@ describe("useServers", () => {
expect(stored.metadata).toEqual([{ key: "trace", value: "abc" }]);
expect(stored.connectionTimeout).toBe(5000);
expect(stored.requestTimeout).toBe(30000);
expect(stored.taskTtl).toBe(30000);
});
it("updateServerSettings throws when the target id does not exist (server-side 404)", async () => {
@ -429,6 +432,7 @@ describe("useServers", () => {
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 0,
});
}),
).rejects.toThrow(/not found/);
@ -475,6 +479,8 @@ describe("useServers", () => {
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
// Absent taskTtl on disk reads back as the product default for the form.
taskTtl: 60000,
});
});