diff --git a/packages/shared/src/ipc/protocol.ts b/packages/shared/src/ipc/protocol.ts
index 12bf3e4e..223d7302 100644
--- a/packages/shared/src/ipc/protocol.ts
+++ b/packages/shared/src/ipc/protocol.ts
@@ -9,6 +9,7 @@
/** Request definition: params P, response R */
export interface RequestDef
{
+ readonly kind: "request";
readonly method: string;
/** @internal Phantom types for inference - not present at runtime */
readonly _types?: { params: P; response: R };
@@ -16,6 +17,7 @@ export interface RequestDef
{
/** Command definition: params P, no response */
export interface CommandDef
{
+ readonly kind: "command";
readonly method: string;
/** @internal Phantom type for inference - not present at runtime */
readonly _types?: { params: P };
@@ -23,6 +25,7 @@ export interface CommandDef
{
/** Notification definition: data D (extension to webview) */
export interface NotificationDef {
+ readonly kind: "notification";
readonly method: string;
/** @internal Phantom type for inference - not present at runtime */
readonly _types?: { data: D };
@@ -34,19 +37,19 @@ export interface NotificationDef {
export function defineRequest(
method: string,
): RequestDef
{
- return { method } as RequestDef
;
+ return { kind: "request", method } as RequestDef
;
}
/** Define a fire-and-forget command */
export function defineCommand
(method: string): CommandDef
{
- return { method } as CommandDef
;
+ return { kind: "command", method } as CommandDef
;
}
/** Define a push notification (extension to webview) */
export function defineNotification(
method: string,
): NotificationDef {
- return { method } as NotificationDef;
+ return { kind: "notification", method } as NotificationDef;
}
// --- Wire format ---
@@ -73,28 +76,135 @@ export interface IpcNotification {
readonly data?: D;
}
-// --- Handler utilities ---
-
-/** Extract params type from a request/command definition */
-export type ParamsOf = T extends { _types?: { params: infer P } } ? P : void;
-
-/** Extract response type from a request definition */
-export type ResponseOf = T extends { _types?: { response: infer R } }
- ? R
- : void;
+// --- Mapped types for handler completeness ---
+
+/** Requires a handler for every RequestDef in Api. Compile error if one is missing. */
+export type RequestHandlerMap = {
+ [K in keyof Api as Api[K] extends { kind: "request" }
+ ? K
+ : never]: Api[K] extends RequestDef
+ ? (params: P) => Promise
+ : never;
+};
+
+/** Requires a handler for every CommandDef in Api. Compile error if one is missing. */
+export type CommandHandlerMap = {
+ [K in keyof Api as Api[K] extends { kind: "command" }
+ ? K
+ : never]: Api[K] extends CommandDef
+ ? (params: P) => void | Promise
+ : never;
+};
+
+// --- API hook type ---
+
+/** Derives a fully typed hook interface from an API definition object. */
+export type ApiHook = {
+ [K in keyof Api as Api[K] extends { kind: "request" }
+ ? K
+ : never]: Api[K] extends RequestDef
+ ? (...args: P extends void ? [] : [params: P]) => Promise
+ : never;
+} & {
+ [K in keyof Api as Api[K] extends { kind: "command" }
+ ? K
+ : never]: Api[K] extends CommandDef
+ ? (...args: P extends void ? [] : [params: P]) => void
+ : never;
+} & {
+ [K in keyof Api as Api[K] extends { kind: "notification" }
+ ? `on${Capitalize}`
+ : never]: Api[K] extends NotificationDef
+ ? D extends void
+ ? (cb: () => void) => () => void
+ : (cb: (data: D) => void) => () => void
+ : never;
+};
+
+// --- Builder functions ---
+
+/** Build a method-indexed map of request handlers with compile-time completeness. */
+export function buildRequestHandlers<
+ Api extends Record,
+>(
+ api: Api,
+ handlers: RequestHandlerMap,
+): Record Promise>;
+export function buildRequestHandlers(
+ api: Record,
+ handlers: Record Promise>,
+) {
+ const result: Record Promise> = {};
+ for (const key of Object.keys(handlers)) {
+ result[api[key].method] = handlers[key];
+ }
+ return result;
+}
-/** Type-safe request handler - infers params and return type from definition */
-export function requestHandler(
- _def: RequestDef
,
- fn: (params: P) => Promise,
-): (params: unknown) => Promise {
- return fn as (params: unknown) => Promise;
+/** Build a method-indexed map of command handlers with compile-time completeness. */
+export function buildCommandHandlers<
+ Api extends Record,
+>(
+ api: Api,
+ handlers: CommandHandlerMap,
+): Record void | Promise>;
+export function buildCommandHandlers(
+ api: Record,
+ handlers: Record void | Promise>,
+) {
+ const result: Record void | Promise> = {};
+ for (const key of Object.keys(handlers)) {
+ result[api[key].method] = handlers[key];
+ }
+ return result;
}
-/** Type-safe command handler - infers params type from definition */
-export function commandHandler(
- _def: CommandDef
,
- fn: (params: P) => void | Promise,
-): (params: unknown) => void | Promise {
- return fn as (params: unknown) => void | Promise;
+/** Build a typed API hook from an API definition and IPC primitives. */
+export function buildApiHook<
+ Api extends Record,
+>(
+ api: Api,
+ ipc: {
+ request: (
+ def: { method: string; _types?: { params: P; response: R } },
+ ...args: P extends void ? [] : [params: P]
+ ) => Promise;
+ command: (
+ def: { method: string; _types?: { params: P } },
+ ...args: P extends void ? [] : [params: P]
+ ) => void;
+ onNotification: (
+ def: { method: string; _types?: { data: D } },
+ cb: (data: D) => void,
+ ) => () => void;
+ },
+): ApiHook;
+export function buildApiHook(
+ api: Record,
+ ipc: {
+ request: (def: { method: string }, params: unknown) => Promise;
+ command: (def: { method: string }, params: unknown) => void;
+ onNotification: (
+ def: { method: string },
+ cb: (data: unknown) => void,
+ ) => () => void;
+ },
+) {
+ const result: Record = {};
+ for (const [key, def] of Object.entries(api)) {
+ switch (def.kind) {
+ case "request":
+ result[key] = (params: unknown) => ipc.request(def, params);
+ break;
+ case "command":
+ result[key] = (params: unknown) => ipc.command(def, params);
+ break;
+ case "notification":
+ result[`on${key[0].toUpperCase()}${key.slice(1)}`] = (
+ cb: (data: unknown) => void,
+ ) => ipc.onNotification(def, cb);
+ break;
+ }
+ }
+ return result;
}
diff --git a/packages/shared/src/tasks/api.ts b/packages/shared/src/tasks/api.ts
index efd70509..032ec016 100644
--- a/packages/shared/src/tasks/api.ts
+++ b/packages/shared/src/tasks/api.ts
@@ -21,65 +21,40 @@ export interface TaskIdParams {
taskId: string;
}
-const getTasks = defineRequest("getTasks");
-const getTemplates = defineRequest(
- "getTemplates",
-);
-const getTask = defineRequest("getTask");
-const getTaskDetails = defineRequest(
- "getTaskDetails",
-);
-
export interface CreateTaskParams {
templateVersionId: string;
prompt: string;
presetId?: string;
}
-const createTask = defineRequest("createTask");
export interface TaskActionParams extends TaskIdParams {
taskName: string;
}
-const deleteTask = defineRequest("deleteTask");
-const pauseTask = defineRequest("pauseTask");
-const resumeTask = defineRequest("resumeTask");
-const downloadLogs = defineRequest("downloadLogs");
-const sendTaskMessage = defineRequest(
- "sendTaskMessage",
-);
-
-const viewInCoder = defineCommand("viewInCoder");
-const viewLogs = defineCommand("viewLogs");
-const stopStreamingWorkspaceLogs = defineCommand(
- "stopStreamingWorkspaceLogs",
-);
-
-const taskUpdated = defineNotification("taskUpdated");
-const tasksUpdated = defineNotification("tasksUpdated");
-const workspaceLogsAppend = defineNotification("workspaceLogsAppend");
-const refresh = defineNotification("refresh");
-const showCreateForm = defineNotification("showCreateForm");
export const TasksApi = {
// Requests
- getTasks,
- getTemplates,
- getTask,
- getTaskDetails,
- createTask,
- deleteTask,
- pauseTask,
- resumeTask,
- downloadLogs,
- sendTaskMessage,
+ getTasks: defineRequest("getTasks"),
+ getTemplates: defineRequest(
+ "getTemplates",
+ ),
+ getTask: defineRequest("getTask"),
+ getTaskDetails: defineRequest("getTaskDetails"),
+ createTask: defineRequest("createTask"),
+ deleteTask: defineRequest("deleteTask"),
+ pauseTask: defineRequest("pauseTask"),
+ resumeTask: defineRequest("resumeTask"),
+ downloadLogs: defineRequest("downloadLogs"),
+ sendTaskMessage: defineRequest(
+ "sendTaskMessage",
+ ),
// Commands
- viewInCoder,
- viewLogs,
- stopStreamingWorkspaceLogs,
+ viewInCoder: defineCommand("viewInCoder"),
+ viewLogs: defineCommand("viewLogs"),
+ stopStreamingWorkspaceLogs: defineCommand("stopStreamingWorkspaceLogs"),
// Notifications
- taskUpdated,
- tasksUpdated,
- workspaceLogsAppend,
- refresh,
- showCreateForm,
+ taskUpdated: defineNotification("taskUpdated"),
+ tasksUpdated: defineNotification("tasksUpdated"),
+ workspaceLogsAppend: defineNotification("workspaceLogsAppend"),
+ refresh: defineNotification("refresh"),
+ showCreateForm: defineNotification("showCreateForm"),
} as const;
diff --git a/packages/tasks/src/components/ErrorBanner.tsx b/packages/tasks/src/components/ErrorBanner.tsx
index 17b262f3..31b77f18 100644
--- a/packages/tasks/src/components/ErrorBanner.tsx
+++ b/packages/tasks/src/components/ErrorBanner.tsx
@@ -19,7 +19,7 @@ export function ErrorBanner({ task }: ErrorBannerProps) {
diff --git a/packages/tasks/src/components/TaskMessageInput.tsx b/packages/tasks/src/components/TaskMessageInput.tsx
index 9434dcd8..f2820269 100644
--- a/packages/tasks/src/components/TaskMessageInput.tsx
+++ b/packages/tasks/src/components/TaskMessageInput.tsx
@@ -69,7 +69,8 @@ export function TaskMessageInput({ task }: TaskMessageInputProps) {
});
const { mutate: sendMessage, isPending: isSending } = useMutation({
- mutationFn: (msg: string) => api.sendTaskMessage(task.id, msg),
+ mutationFn: (msg: string) =>
+ api.sendTaskMessage({ taskId: task.id, message: msg }),
onSuccess: () => setMessage(""),
onError: (err) => logger.error("Failed to send message", err),
});
diff --git a/packages/tasks/src/components/useTaskMenuItems.ts b/packages/tasks/src/components/useTaskMenuItems.ts
index 3ffdae03..630fb83c 100644
--- a/packages/tasks/src/components/useTaskMenuItems.ts
+++ b/packages/tasks/src/components/useTaskMenuItems.ts
@@ -73,13 +73,14 @@ export function useTaskMenuItems({
menuItems.push({
label: "View in Coder",
icon: "link-external",
- onClick: () => api.viewInCoder(task.id),
+ onClick: () => api.viewInCoder({ taskId: task.id }),
});
menuItems.push({
label: "Download Logs",
icon: "cloud-download",
- onClick: () => run("downloading", () => api.downloadLogs(task.id)),
+ onClick: () =>
+ run("downloading", () => api.downloadLogs({ taskId: task.id })),
loading: action === "downloading",
});
diff --git a/packages/tasks/src/hooks/useSelectedTask.ts b/packages/tasks/src/hooks/useSelectedTask.ts
index 23648253..b9adeb2c 100644
--- a/packages/tasks/src/hooks/useSelectedTask.ts
+++ b/packages/tasks/src/hooks/useSelectedTask.ts
@@ -30,7 +30,7 @@ export function useSelectedTask(tasks: readonly Task[]) {
? queryKeys.taskDetail(selectedTaskId)
: queryKeys.details,
queryFn: selectedTaskId
- ? () => api.getTaskDetails(selectedTaskId)
+ ? () => api.getTaskDetails({ taskId: selectedTaskId })
: skipToken,
refetchInterval: (query) => {
const task = query.state.data?.task;
diff --git a/packages/tasks/src/hooks/useTasksApi.ts b/packages/tasks/src/hooks/useTasksApi.ts
index 04660738..ce6b8041 100644
--- a/packages/tasks/src/hooks/useTasksApi.ts
+++ b/packages/tasks/src/hooks/useTasksApi.ts
@@ -1,60 +1,6 @@
-/**
- * Tasks API hook - provides type-safe access to all Tasks operations.
- *
- * @example
- * ```tsx
- * const api = useTasksApi();
- * const tasks = await api.getTasks();
- * api.viewInCoder("task-id");
- * ```
- */
-
-import {
- TasksApi,
- type CreateTaskParams,
- type Task,
- type TaskActionParams,
-} from "@repo/shared";
+import { TasksApi, buildApiHook } from "@repo/shared";
import { useIpc } from "@repo/webview-shared/react";
export function useTasksApi() {
- const { request, command, onNotification } = useIpc();
-
- return {
- // Requests
- getTasks: () => request(TasksApi.getTasks),
- getTemplates: () => request(TasksApi.getTemplates),
- getTask: (taskId: string) => request(TasksApi.getTask, { taskId }),
- getTaskDetails: (taskId: string) =>
- request(TasksApi.getTaskDetails, { taskId }),
- createTask: (params: CreateTaskParams) =>
- request(TasksApi.createTask, params),
- deleteTask: (params: TaskActionParams) =>
- request(TasksApi.deleteTask, params),
- pauseTask: (params: TaskActionParams) =>
- request(TasksApi.pauseTask, params),
- resumeTask: (params: TaskActionParams) =>
- request(TasksApi.resumeTask, params),
- downloadLogs: (taskId: string) =>
- request(TasksApi.downloadLogs, { taskId }),
- sendTaskMessage: (taskId: string, message: string) =>
- request(TasksApi.sendTaskMessage, { taskId, message }),
-
- // Commands
- viewInCoder: (taskId: string) => command(TasksApi.viewInCoder, { taskId }),
- viewLogs: (taskId: string) => command(TasksApi.viewLogs, { taskId }),
- stopStreamingWorkspaceLogs: () =>
- command(TasksApi.stopStreamingWorkspaceLogs),
-
- // Notifications
- onTaskUpdated: (cb: (task: Task) => void) =>
- onNotification(TasksApi.taskUpdated, cb),
- onTasksUpdated: (cb: (tasks: Task[]) => void) =>
- onNotification(TasksApi.tasksUpdated, cb),
- onWorkspaceLogsAppend: (cb: (lines: string[]) => void) =>
- onNotification(TasksApi.workspaceLogsAppend, cb),
- onRefresh: (cb: () => void) => onNotification(TasksApi.refresh, cb),
- onShowCreateForm: (cb: () => void) =>
- onNotification(TasksApi.showCreateForm, cb),
- };
+ return buildApiHook(TasksApi, useIpc());
}
diff --git a/src/webviews/tasks/tasksPanelProvider.ts b/src/webviews/tasks/tasksPanelProvider.ts
index 5e2e2f7e..61671de7 100644
--- a/src/webviews/tasks/tasksPanelProvider.ts
+++ b/src/webviews/tasks/tasksPanelProvider.ts
@@ -3,18 +3,18 @@ import stripAnsi from "strip-ansi";
import * as vscode from "vscode";
import {
- commandHandler,
+ buildCommandHandlers,
+ buildRequestHandlers,
isBuildingWorkspace,
isAgentStarting,
getTaskPermissions,
getTaskLabel,
isStableTask,
- requestHandler,
TasksApi,
type CreateTaskParams,
- type IpcNotification,
type IpcRequest,
type IpcResponse,
+ type NotificationDef,
type TaskDetails,
type TaskLogs,
type TaskTemplate,
@@ -100,71 +100,28 @@ export class TasksPanelProvider
logs: TaskLogs;
};
- /**
- * Request handlers indexed by method name.
- * Type safety is ensured at definition time via requestHandler().
- */
- private readonly requestHandlers: Record<
- string,
- (params: unknown) => Promise
- > = {
- [TasksApi.getTasks.method]: requestHandler(TasksApi.getTasks, () =>
- this.fetchTasks(),
- ),
- [TasksApi.getTemplates.method]: requestHandler(TasksApi.getTemplates, () =>
- this.fetchTemplates(),
- ),
- [TasksApi.getTask.method]: requestHandler(TasksApi.getTask, (p) =>
- this.client.getTask("me", p.taskId),
- ),
- [TasksApi.getTaskDetails.method]: requestHandler(
- TasksApi.getTaskDetails,
- (p) => this.handleGetTaskDetails(p.taskId),
- ),
- [TasksApi.createTask.method]: requestHandler(TasksApi.createTask, (p) =>
- this.handleCreateTask(p),
- ),
- [TasksApi.deleteTask.method]: requestHandler(TasksApi.deleteTask, (p) =>
- this.handleDeleteTask(p.taskId, p.taskName),
- ),
- [TasksApi.pauseTask.method]: requestHandler(TasksApi.pauseTask, (p) =>
- this.handlePauseTask(p.taskId, p.taskName),
- ),
- [TasksApi.resumeTask.method]: requestHandler(TasksApi.resumeTask, (p) =>
- this.handleResumeTask(p.taskId, p.taskName),
- ),
- [TasksApi.downloadLogs.method]: requestHandler(TasksApi.downloadLogs, (p) =>
- this.handleDownloadLogs(p.taskId),
- ),
- [TasksApi.sendTaskMessage.method]: requestHandler(
- TasksApi.sendTaskMessage,
- (p) => this.handleSendMessage(p.taskId, p.message),
- ),
- };
-
- /**
- * Command handlers indexed by method name.
- * Type safety is ensured at definition time via commandHandler().
- */
- private readonly commandHandlers: Record<
- string,
- (params: unknown) => void | Promise
- > = {
- [TasksApi.viewInCoder.method]: commandHandler(TasksApi.viewInCoder, (p) =>
- this.handleViewInCoder(p.taskId),
- ),
- [TasksApi.viewLogs.method]: commandHandler(TasksApi.viewLogs, (p) =>
- this.handleViewLogs(p.taskId),
- ),
- [TasksApi.stopStreamingWorkspaceLogs.method]: commandHandler(
- TasksApi.stopStreamingWorkspaceLogs,
- () => {
- this.streamingTaskId = null;
- this.buildLogStream.close();
- this.agentLogStream.close();
- },
- ),
- };
+ private readonly requestHandlers = buildRequestHandlers(TasksApi, {
+ getTasks: () => this.fetchTasks(),
+ getTemplates: () => this.fetchTemplates(),
+ getTask: (p) => this.client.getTask("me", p.taskId),
+ getTaskDetails: (p) => this.handleGetTaskDetails(p.taskId),
+ createTask: (p) => this.handleCreateTask(p),
+ deleteTask: (p) => this.handleDeleteTask(p.taskId, p.taskName),
+ pauseTask: (p) => this.handlePauseTask(p.taskId, p.taskName),
+ resumeTask: (p) => this.handleResumeTask(p.taskId, p.taskName),
+ downloadLogs: (p) => this.handleDownloadLogs(p.taskId),
+ sendTaskMessage: (p) => this.handleSendMessage(p.taskId, p.message),
+ });
+
+ private readonly commandHandlers = buildCommandHandlers(TasksApi, {
+ viewInCoder: (p) => this.handleViewInCoder(p.taskId),
+ viewLogs: (p) => this.handleViewLogs(p.taskId),
+ stopStreamingWorkspaceLogs: () => {
+ this.streamingTaskId = null;
+ this.buildLogStream.close();
+ this.agentLogStream.close();
+ },
+ });
constructor(
private readonly extensionUri: vscode.Uri,
@@ -173,12 +130,12 @@ export class TasksPanelProvider
) {}
public showCreateForm(): void {
- this.sendNotification({ type: TasksApi.showCreateForm.method });
+ this.notify(TasksApi.showCreateForm);
}
public refresh(): void {
this.cachedLogs = undefined;
- this.sendNotification({ type: TasksApi.refresh.method });
+ this.notify(TasksApi.refresh);
}
resolveWebviewView(
@@ -452,10 +409,7 @@ export class TasksPanelProvider
const clean = stripAnsi(line);
// Skip lines that were purely ANSI codes, but keep intentional blank lines.
if (line.length > 0 && clean.length === 0) return;
- this.sendNotification({
- type: TasksApi.workspaceLogsAppend.method,
- data: [clean],
- });
+ this.notify(TasksApi.workspaceLogsAppend, [clean]);
};
const onStreamClose = () => {
@@ -514,10 +468,7 @@ export class TasksPanelProvider
try {
const tasks = await this.fetchTasks();
if (tasks !== null) {
- this.sendNotification({
- type: TasksApi.tasksUpdated.method,
- data: tasks,
- });
+ this.notify(TasksApi.tasksUpdated, [...tasks]);
}
} catch (err) {
this.logger.warn("Failed to refresh tasks after action", err);
@@ -527,10 +478,7 @@ export class TasksPanelProvider
private async refreshAndNotifyTask(taskId: string): Promise {
try {
const task = await this.client.getTask("me", taskId);
- this.sendNotification({
- type: TasksApi.taskUpdated.method,
- data: task,
- });
+ this.notify(TasksApi.taskUpdated, task);
} catch (err) {
this.logger.warn("Failed to refresh task after action", err);
}
@@ -616,8 +564,14 @@ export class TasksPanelProvider
this.view?.webview.postMessage(response);
}
- private sendNotification(notification: IpcNotification): void {
- this.view?.webview.postMessage(notification);
+ private notify(
+ def: NotificationDef,
+ ...args: D extends void ? [] : [data: D]
+ ): void {
+ this.view?.webview.postMessage({
+ type: def.method,
+ ...(args.length > 0 ? { data: args[0] } : {}),
+ });
}
dispose(): void {
diff --git a/test/webview/tasks/ErrorBanner.test.tsx b/test/webview/tasks/ErrorBanner.test.tsx
index 25a92482..425dd8b5 100644
--- a/test/webview/tasks/ErrorBanner.test.tsx
+++ b/test/webview/tasks/ErrorBanner.test.tsx
@@ -58,6 +58,6 @@ describe("ErrorBanner", () => {
const t = task({ id: "task-42", status: "error" });
renderWithQuery();
fireEvent.click(screen.getByText("View logs"));
- expect(mockApi.viewLogs).toHaveBeenCalledWith("task-42");
+ expect(mockApi.viewLogs).toHaveBeenCalledWith({ taskId: "task-42" });
});
});
diff --git a/test/webview/tasks/TaskMessageInput.test.tsx b/test/webview/tasks/TaskMessageInput.test.tsx
index bdad1e44..dc7927b6 100644
--- a/test/webview/tasks/TaskMessageInput.test.tsx
+++ b/test/webview/tasks/TaskMessageInput.test.tsx
@@ -162,10 +162,10 @@ describe("TaskMessageInput", () => {
fireEvent.keyDown(getTextarea(), { key: "Enter", ctrlKey: true });
await waitFor(() => {
- expect(mockApi.sendTaskMessage).toHaveBeenCalledWith(
- "task-1",
- "Hello agent",
- );
+ expect(mockApi.sendTaskMessage).toHaveBeenCalledWith({
+ taskId: "task-1",
+ message: "Hello agent",
+ });
});
await waitFor(() => {
expect(getTextarea()).toHaveValue("");
diff --git a/test/webview/tasks/useTaskMenuItems.test.tsx b/test/webview/tasks/useTaskMenuItems.test.tsx
index 071395e7..a7022728 100644
--- a/test/webview/tasks/useTaskMenuItems.test.tsx
+++ b/test/webview/tasks/useTaskMenuItems.test.tsx
@@ -134,7 +134,7 @@ describe("useTaskMenuItems", () => {
const { result } = renderTask(testTask);
clickItem(result.current.menuItems, label);
await waitFor(() => {
- expect(mockApi[apiMethod]).toHaveBeenCalledWith(testTask.id);
+ expect(mockApi[apiMethod]).toHaveBeenCalledWith({ taskId: testTask.id });
});
});