Skip to content

Commit a446c13

Browse files
committed
Auto-generate useTasksApi from TasksApi at runtime
Add buildApiHook and ApiHook<Api> to protocol.ts so that useTasksApi can be derived from the TasksApi definition object with zero manual sync. - Add ApiHook<Api> mapped type that derives request, command, and notification subscription signatures from an API definition - Add buildApiHook() that iterates API entries by kind at runtime, dispatching to ipc.request/command/onNotification - Replace ~60 lines of manual method listings in useTasksApi with a single buildApiHook(TasksApi, useIpc()) call - Update 5 consumer call sites from positional args to params objects - Inline intermediate const declarations in api.ts into TasksApi object - Remove unused ParamsOf and ResponseOf utility types
1 parent d241239 commit a446c13

File tree

10 files changed

+112
-124
lines changed

10 files changed

+112
-124
lines changed

packages/shared/src/ipc/protocol.ts

Lines changed: 75 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -76,16 +76,6 @@ export interface IpcNotification<D = unknown> {
7676
readonly data?: D;
7777
}
7878

79-
// --- Handler utilities ---
80-
81-
/** Extract params type from a request/command definition */
82-
export type ParamsOf<T> = T extends { _types?: { params: infer P } } ? P : void;
83-
84-
/** Extract response type from a request definition */
85-
export type ResponseOf<T> = T extends { _types?: { response: infer R } }
86-
? R
87-
: void;
88-
8979
// --- Mapped types for handler completeness ---
9080

9181
/** Requires a handler for every RequestDef in Api. Compile error if one is missing. */
@@ -106,6 +96,31 @@ export type CommandHandlerMap<Api> = {
10696
: never;
10797
};
10898

99+
// --- API hook type ---
100+
101+
/** Derives a fully typed hook interface from an API definition object. */
102+
export type ApiHook<Api> = {
103+
[K in keyof Api as Api[K] extends { kind: "request" }
104+
? K
105+
: never]: Api[K] extends RequestDef<infer P, infer R>
106+
? (...args: P extends void ? [] : [params: P]) => Promise<R>
107+
: never;
108+
} & {
109+
[K in keyof Api as Api[K] extends { kind: "command" }
110+
? K
111+
: never]: Api[K] extends CommandDef<infer P>
112+
? (...args: P extends void ? [] : [params: P]) => void
113+
: never;
114+
} & {
115+
[K in keyof Api as Api[K] extends { kind: "notification" }
116+
? `on${Capitalize<K & string>}`
117+
: never]: Api[K] extends NotificationDef<infer D>
118+
? D extends void
119+
? (cb: () => void) => () => void
120+
: (cb: (data: D) => void) => () => void
121+
: never;
122+
};
123+
109124
// --- Builder functions ---
110125

111126
/** Build a method-indexed map of request handlers with compile-time completeness. */
@@ -143,3 +158,53 @@ export function buildCommandHandlers(
143158
}
144159
return result;
145160
}
161+
162+
/** Build a typed API hook from an API definition and IPC primitives. */
163+
export function buildApiHook<
164+
Api extends Record<string, { kind: string; method: string }>,
165+
>(
166+
api: Api,
167+
ipc: {
168+
request: <P, R>(
169+
def: { method: string; _types?: { params: P; response: R } },
170+
...args: P extends void ? [] : [params: P]
171+
) => Promise<R>;
172+
command: <P>(
173+
def: { method: string; _types?: { params: P } },
174+
...args: P extends void ? [] : [params: P]
175+
) => void;
176+
onNotification: <D>(
177+
def: { method: string; _types?: { data: D } },
178+
cb: (data: D) => void,
179+
) => () => void;
180+
},
181+
): ApiHook<Api>;
182+
export function buildApiHook(
183+
api: Record<string, { kind: string; method: string }>,
184+
ipc: {
185+
request: (def: { method: string }, params: unknown) => Promise<unknown>;
186+
command: (def: { method: string }, params: unknown) => void;
187+
onNotification: (
188+
def: { method: string },
189+
cb: (data: unknown) => void,
190+
) => () => void;
191+
},
192+
) {
193+
const result: Record<string, unknown> = {};
194+
for (const [key, def] of Object.entries(api)) {
195+
switch (def.kind) {
196+
case "request":
197+
result[key] = (params: unknown) => ipc.request(def, params);
198+
break;
199+
case "command":
200+
result[key] = (params: unknown) => ipc.command(def, params);
201+
break;
202+
case "notification":
203+
result[`on${key[0].toUpperCase()}${key.slice(1)}`] = (
204+
cb: (data: unknown) => void,
205+
) => ipc.onNotification(def, cb);
206+
break;
207+
}
208+
}
209+
return result;
210+
}

packages/shared/src/tasks/api.ts

Lines changed: 22 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -21,65 +21,40 @@ export interface TaskIdParams {
2121
taskId: string;
2222
}
2323

24-
const getTasks = defineRequest<void, readonly Task[] | null>("getTasks");
25-
const getTemplates = defineRequest<void, readonly TaskTemplate[] | null>(
26-
"getTemplates",
27-
);
28-
const getTask = defineRequest<TaskIdParams, Task>("getTask");
29-
const getTaskDetails = defineRequest<TaskIdParams, TaskDetails>(
30-
"getTaskDetails",
31-
);
32-
3324
export interface CreateTaskParams {
3425
templateVersionId: string;
3526
prompt: string;
3627
presetId?: string;
3728
}
38-
const createTask = defineRequest<CreateTaskParams, Task>("createTask");
3929

4030
export interface TaskActionParams extends TaskIdParams {
4131
taskName: string;
4232
}
43-
const deleteTask = defineRequest<TaskActionParams, void>("deleteTask");
44-
const pauseTask = defineRequest<TaskActionParams, void>("pauseTask");
45-
const resumeTask = defineRequest<TaskActionParams, void>("resumeTask");
46-
const downloadLogs = defineRequest<TaskIdParams, void>("downloadLogs");
47-
const sendTaskMessage = defineRequest<TaskIdParams & { message: string }, void>(
48-
"sendTaskMessage",
49-
);
50-
51-
const viewInCoder = defineCommand<TaskIdParams>("viewInCoder");
52-
const viewLogs = defineCommand<TaskIdParams>("viewLogs");
53-
const stopStreamingWorkspaceLogs = defineCommand<void>(
54-
"stopStreamingWorkspaceLogs",
55-
);
56-
57-
const taskUpdated = defineNotification<Task>("taskUpdated");
58-
const tasksUpdated = defineNotification<Task[]>("tasksUpdated");
59-
const workspaceLogsAppend = defineNotification<string[]>("workspaceLogsAppend");
60-
const refresh = defineNotification<void>("refresh");
61-
const showCreateForm = defineNotification<void>("showCreateForm");
6233

6334
export const TasksApi = {
6435
// Requests
65-
getTasks,
66-
getTemplates,
67-
getTask,
68-
getTaskDetails,
69-
createTask,
70-
deleteTask,
71-
pauseTask,
72-
resumeTask,
73-
downloadLogs,
74-
sendTaskMessage,
36+
getTasks: defineRequest<void, readonly Task[] | null>("getTasks"),
37+
getTemplates: defineRequest<void, readonly TaskTemplate[] | null>(
38+
"getTemplates",
39+
),
40+
getTask: defineRequest<TaskIdParams, Task>("getTask"),
41+
getTaskDetails: defineRequest<TaskIdParams, TaskDetails>("getTaskDetails"),
42+
createTask: defineRequest<CreateTaskParams, Task>("createTask"),
43+
deleteTask: defineRequest<TaskActionParams, void>("deleteTask"),
44+
pauseTask: defineRequest<TaskActionParams, void>("pauseTask"),
45+
resumeTask: defineRequest<TaskActionParams, void>("resumeTask"),
46+
downloadLogs: defineRequest<TaskIdParams, void>("downloadLogs"),
47+
sendTaskMessage: defineRequest<TaskIdParams & { message: string }, void>(
48+
"sendTaskMessage",
49+
),
7550
// Commands
76-
viewInCoder,
77-
viewLogs,
78-
stopStreamingWorkspaceLogs,
51+
viewInCoder: defineCommand<TaskIdParams>("viewInCoder"),
52+
viewLogs: defineCommand<TaskIdParams>("viewLogs"),
53+
stopStreamingWorkspaceLogs: defineCommand<void>("stopStreamingWorkspaceLogs"),
7954
// Notifications
80-
taskUpdated,
81-
tasksUpdated,
82-
workspaceLogsAppend,
83-
refresh,
84-
showCreateForm,
55+
taskUpdated: defineNotification<Task>("taskUpdated"),
56+
tasksUpdated: defineNotification<Task[]>("tasksUpdated"),
57+
workspaceLogsAppend: defineNotification<string[]>("workspaceLogsAppend"),
58+
refresh: defineNotification<void>("refresh"),
59+
showCreateForm: defineNotification<void>("showCreateForm"),
8560
} as const;

packages/tasks/src/components/ErrorBanner.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export function ErrorBanner({ task }: ErrorBannerProps) {
1919
<button
2020
type="button"
2121
className="text-link"
22-
onClick={() => api.viewLogs(task.id)}
22+
onClick={() => api.viewLogs({ taskId: task.id })}
2323
>
2424
View logs <VscodeIcon name="link-external" />
2525
</button>

packages/tasks/src/components/TaskMessageInput.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ export function TaskMessageInput({ task }: TaskMessageInputProps) {
6969
});
7070

7171
const { mutate: sendMessage, isPending: isSending } = useMutation({
72-
mutationFn: (msg: string) => api.sendTaskMessage(task.id, msg),
72+
mutationFn: (msg: string) =>
73+
api.sendTaskMessage({ taskId: task.id, message: msg }),
7374
onSuccess: () => setMessage(""),
7475
onError: (err) => logger.error("Failed to send message", err),
7576
});

packages/tasks/src/components/useTaskMenuItems.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,14 @@ export function useTaskMenuItems({
7373
menuItems.push({
7474
label: "View in Coder",
7575
icon: "link-external",
76-
onClick: () => api.viewInCoder(task.id),
76+
onClick: () => api.viewInCoder({ taskId: task.id }),
7777
});
7878

7979
menuItems.push({
8080
label: "Download Logs",
8181
icon: "cloud-download",
82-
onClick: () => run("downloading", () => api.downloadLogs(task.id)),
82+
onClick: () =>
83+
run("downloading", () => api.downloadLogs({ taskId: task.id })),
8384
loading: action === "downloading",
8485
});
8586

packages/tasks/src/hooks/useSelectedTask.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export function useSelectedTask(tasks: readonly Task[]) {
3030
? queryKeys.taskDetail(selectedTaskId)
3131
: queryKeys.details,
3232
queryFn: selectedTaskId
33-
? () => api.getTaskDetails(selectedTaskId)
33+
? () => api.getTaskDetails({ taskId: selectedTaskId })
3434
: skipToken,
3535
refetchInterval: (query) => {
3636
const task = query.state.data?.task;
Lines changed: 2 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,6 @@
1-
/**
2-
* Tasks API hook - provides type-safe access to all Tasks operations.
3-
*
4-
* @example
5-
* ```tsx
6-
* const api = useTasksApi();
7-
* const tasks = await api.getTasks();
8-
* api.viewInCoder("task-id");
9-
* ```
10-
*/
11-
12-
import {
13-
TasksApi,
14-
type CreateTaskParams,
15-
type Task,
16-
type TaskActionParams,
17-
} from "@repo/shared";
1+
import { TasksApi, buildApiHook } from "@repo/shared";
182
import { useIpc } from "@repo/webview-shared/react";
193

204
export function useTasksApi() {
21-
const { request, command, onNotification } = useIpc();
22-
23-
return {
24-
// Requests
25-
getTasks: () => request(TasksApi.getTasks),
26-
getTemplates: () => request(TasksApi.getTemplates),
27-
getTask: (taskId: string) => request(TasksApi.getTask, { taskId }),
28-
getTaskDetails: (taskId: string) =>
29-
request(TasksApi.getTaskDetails, { taskId }),
30-
createTask: (params: CreateTaskParams) =>
31-
request(TasksApi.createTask, params),
32-
deleteTask: (params: TaskActionParams) =>
33-
request(TasksApi.deleteTask, params),
34-
pauseTask: (params: TaskActionParams) =>
35-
request(TasksApi.pauseTask, params),
36-
resumeTask: (params: TaskActionParams) =>
37-
request(TasksApi.resumeTask, params),
38-
downloadLogs: (taskId: string) =>
39-
request(TasksApi.downloadLogs, { taskId }),
40-
sendTaskMessage: (taskId: string, message: string) =>
41-
request(TasksApi.sendTaskMessage, { taskId, message }),
42-
43-
// Commands
44-
viewInCoder: (taskId: string) => command(TasksApi.viewInCoder, { taskId }),
45-
viewLogs: (taskId: string) => command(TasksApi.viewLogs, { taskId }),
46-
stopStreamingWorkspaceLogs: () =>
47-
command(TasksApi.stopStreamingWorkspaceLogs),
48-
49-
// Notifications
50-
onTaskUpdated: (cb: (task: Task) => void) =>
51-
onNotification(TasksApi.taskUpdated, cb),
52-
onTasksUpdated: (cb: (tasks: Task[]) => void) =>
53-
onNotification(TasksApi.tasksUpdated, cb),
54-
onWorkspaceLogsAppend: (cb: (lines: string[]) => void) =>
55-
onNotification(TasksApi.workspaceLogsAppend, cb),
56-
onRefresh: (cb: () => void) => onNotification(TasksApi.refresh, cb),
57-
onShowCreateForm: (cb: () => void) =>
58-
onNotification(TasksApi.showCreateForm, cb),
59-
};
5+
return buildApiHook(TasksApi, useIpc());
606
}

test/webview/tasks/ErrorBanner.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,6 @@ describe("ErrorBanner", () => {
5858
const t = task({ id: "task-42", status: "error" });
5959
renderWithQuery(<ErrorBanner task={t} />);
6060
fireEvent.click(screen.getByText("View logs"));
61-
expect(mockApi.viewLogs).toHaveBeenCalledWith("task-42");
61+
expect(mockApi.viewLogs).toHaveBeenCalledWith({ taskId: "task-42" });
6262
});
6363
});

test/webview/tasks/TaskMessageInput.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,10 +162,10 @@ describe("TaskMessageInput", () => {
162162
fireEvent.keyDown(getTextarea(), { key: "Enter", ctrlKey: true });
163163

164164
await waitFor(() => {
165-
expect(mockApi.sendTaskMessage).toHaveBeenCalledWith(
166-
"task-1",
167-
"Hello agent",
168-
);
165+
expect(mockApi.sendTaskMessage).toHaveBeenCalledWith({
166+
taskId: "task-1",
167+
message: "Hello agent",
168+
});
169169
});
170170
await waitFor(() => {
171171
expect(getTextarea()).toHaveValue("");

test/webview/tasks/useTaskMenuItems.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ describe("useTaskMenuItems", () => {
134134
const { result } = renderTask(testTask);
135135
clickItem(result.current.menuItems, label);
136136
await waitFor(() => {
137-
expect(mockApi[apiMethod]).toHaveBeenCalledWith(testTask.id);
137+
expect(mockApi[apiMethod]).toHaveBeenCalledWith({ taskId: testTask.id });
138138
});
139139
});
140140

0 commit comments

Comments
 (0)