Skip to content
Open
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
158 changes: 134 additions & 24 deletions packages/shared/src/ipc/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,23 @@

/** Request definition: params P, response R */
export interface RequestDef<P = void, R = void> {
readonly kind: "request";
readonly method: string;
/** @internal Phantom types for inference - not present at runtime */
readonly _types?: { params: P; response: R };
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with never?

readonly _types?: never & { params: P; response: R };

}

/** Command definition: params P, no response */
export interface CommandDef<P = void> {
readonly kind: "command";
readonly method: string;
/** @internal Phantom type for inference - not present at runtime */
readonly _types?: { params: P };
}

/** Notification definition: data D (extension to webview) */
export interface NotificationDef<D = void> {
readonly kind: "notification";
readonly method: string;
/** @internal Phantom type for inference - not present at runtime */
readonly _types?: { data: D };
Expand All @@ -34,19 +37,19 @@ export interface NotificationDef<D = void> {
export function defineRequest<P = void, R = void>(
method: string,
): RequestDef<P, R> {
return { method } as RequestDef<P, R>;
return { kind: "request", method } as RequestDef<P, R>;
}

/** Define a fire-and-forget command */
export function defineCommand<P = void>(method: string): CommandDef<P> {
return { method } as CommandDef<P>;
return { kind: "command", method } as CommandDef<P>;
}

/** Define a push notification (extension to webview) */
export function defineNotification<D = void>(
method: string,
): NotificationDef<D> {
return { method } as NotificationDef<D>;
return { kind: "notification", method } as NotificationDef<D>;
}

// --- Wire format ---
Expand All @@ -73,28 +76,135 @@ export interface IpcNotification<D = unknown> {
readonly data?: D;
}

// --- Handler utilities ---

/** Extract params type from a request/command definition */
export type ParamsOf<T> = T extends { _types?: { params: infer P } } ? P : void;

/** Extract response type from a request definition */
export type ResponseOf<T> = 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<Api> = {
[K in keyof Api as Api[K] extends { kind: "request" }
? K
: never]: Api[K] extends RequestDef<infer P, infer R>
? (params: P) => Promise<R>
: never;
};

/** Requires a handler for every CommandDef in Api. Compile error if one is missing. */
export type CommandHandlerMap<Api> = {
[K in keyof Api as Api[K] extends { kind: "command" }
? K
: never]: Api[K] extends CommandDef<infer P>
? (params: P) => void | Promise<void>
: never;
};

// --- API hook type ---

/** Derives a fully typed hook interface from an API definition object. */
export type ApiHook<Api> = {
[K in keyof Api as Api[K] extends { kind: "request" }
? K
: never]: Api[K] extends RequestDef<infer P, infer R>
? (...args: P extends void ? [] : [params: P]) => Promise<R>
: never;
} & {
[K in keyof Api as Api[K] extends { kind: "command" }
? K
: never]: Api[K] extends CommandDef<infer P>
? (...args: P extends void ? [] : [params: P]) => void
: never;
} & {
[K in keyof Api as Api[K] extends { kind: "notification" }
? `on${Capitalize<K & string>}`
: never]: Api[K] extends NotificationDef<infer D>
? 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<string, { method: string }>,
>(
api: Api,
handlers: RequestHandlerMap<Api>,
): Record<string, (params: unknown) => Promise<unknown>>;
export function buildRequestHandlers(
api: Record<string, { method: string }>,
handlers: Record<string, (params: unknown) => Promise<unknown>>,
) {
const result: Record<string, (params: unknown) => Promise<unknown>> = {};
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<P, R>(
_def: RequestDef<P, R>,
fn: (params: P) => Promise<R>,
): (params: unknown) => Promise<unknown> {
return fn as (params: unknown) => Promise<unknown>;
/** Build a method-indexed map of command handlers with compile-time completeness. */
export function buildCommandHandlers<
Api extends Record<string, { method: string }>,
>(
api: Api,
handlers: CommandHandlerMap<Api>,
): Record<string, (params: unknown) => void | Promise<void>>;
export function buildCommandHandlers(
api: Record<string, { method: string }>,
handlers: Record<string, (params: unknown) => void | Promise<void>>,
) {
const result: Record<string, (params: unknown) => void | Promise<void>> = {};
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<P>(
_def: CommandDef<P>,
fn: (params: P) => void | Promise<void>,
): (params: unknown) => void | Promise<void> {
return fn as (params: unknown) => void | Promise<void>;
/** Build a typed API hook from an API definition and IPC primitives. */
export function buildApiHook<
Api extends Record<string, { kind: string; method: string }>,
>(
api: Api,
ipc: {
request: <P, R>(
def: { method: string; _types?: { params: P; response: R } },
...args: P extends void ? [] : [params: P]
) => Promise<R>;
command: <P>(
def: { method: string; _types?: { params: P } },
...args: P extends void ? [] : [params: P]
) => void;
onNotification: <D>(
def: { method: string; _types?: { data: D } },
cb: (data: D) => void,
) => () => void;
},
): ApiHook<Api>;
export function buildApiHook(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non-blocking: thoughts on a unit test for this function? If it is sufficiently tested indirectly then no worries.

api: Record<string, { kind: string; method: string }>,
ipc: {
request: (def: { method: string }, params: unknown) => Promise<unknown>;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't be params?

command: (def: { method: string }, params: unknown) => void;
onNotification: (
def: { method: string },
cb: (data: unknown) => void,
) => () => void;
},
) {
const result: Record<string, unknown> = {};
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;
}
69 changes: 22 additions & 47 deletions packages/shared/src/tasks/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,65 +21,40 @@ export interface TaskIdParams {
taskId: string;
}

const getTasks = defineRequest<void, readonly Task[] | null>("getTasks");
const getTemplates = defineRequest<void, readonly TaskTemplate[] | null>(
"getTemplates",
);
const getTask = defineRequest<TaskIdParams, Task>("getTask");
const getTaskDetails = defineRequest<TaskIdParams, TaskDetails>(
"getTaskDetails",
);

export interface CreateTaskParams {
templateVersionId: string;
prompt: string;
presetId?: string;
}
const createTask = defineRequest<CreateTaskParams, Task>("createTask");

export interface TaskActionParams extends TaskIdParams {
taskName: string;
}
const deleteTask = defineRequest<TaskActionParams, void>("deleteTask");
const pauseTask = defineRequest<TaskActionParams, void>("pauseTask");
const resumeTask = defineRequest<TaskActionParams, void>("resumeTask");
const downloadLogs = defineRequest<TaskIdParams, void>("downloadLogs");
const sendTaskMessage = defineRequest<TaskIdParams & { message: string }, void>(
"sendTaskMessage",
);

const viewInCoder = defineCommand<TaskIdParams>("viewInCoder");
const viewLogs = defineCommand<TaskIdParams>("viewLogs");
const stopStreamingWorkspaceLogs = defineCommand<void>(
"stopStreamingWorkspaceLogs",
);

const taskUpdated = defineNotification<Task>("taskUpdated");
const tasksUpdated = defineNotification<Task[]>("tasksUpdated");
const workspaceLogsAppend = defineNotification<string[]>("workspaceLogsAppend");
const refresh = defineNotification<void>("refresh");
const showCreateForm = defineNotification<void>("showCreateForm");

export const TasksApi = {
// Requests
getTasks,
getTemplates,
getTask,
getTaskDetails,
createTask,
deleteTask,
pauseTask,
resumeTask,
downloadLogs,
sendTaskMessage,
getTasks: defineRequest<void, readonly Task[] | null>("getTasks"),
getTemplates: defineRequest<void, readonly TaskTemplate[] | null>(
"getTemplates",
),
getTask: defineRequest<TaskIdParams, Task>("getTask"),
getTaskDetails: defineRequest<TaskIdParams, TaskDetails>("getTaskDetails"),
createTask: defineRequest<CreateTaskParams, Task>("createTask"),
deleteTask: defineRequest<TaskActionParams, void>("deleteTask"),
pauseTask: defineRequest<TaskActionParams, void>("pauseTask"),
resumeTask: defineRequest<TaskActionParams, void>("resumeTask"),
downloadLogs: defineRequest<TaskIdParams, void>("downloadLogs"),
sendTaskMessage: defineRequest<TaskIdParams & { message: string }, void>(
"sendTaskMessage",
),
// Commands
viewInCoder,
viewLogs,
stopStreamingWorkspaceLogs,
viewInCoder: defineCommand<TaskIdParams>("viewInCoder"),
viewLogs: defineCommand<TaskIdParams>("viewLogs"),
stopStreamingWorkspaceLogs: defineCommand<void>("stopStreamingWorkspaceLogs"),
// Notifications
taskUpdated,
tasksUpdated,
workspaceLogsAppend,
refresh,
showCreateForm,
taskUpdated: defineNotification<Task>("taskUpdated"),
tasksUpdated: defineNotification<Task[]>("tasksUpdated"),
workspaceLogsAppend: defineNotification<string[]>("workspaceLogsAppend"),
refresh: defineNotification<void>("refresh"),
showCreateForm: defineNotification<void>("showCreateForm"),
} as const;
2 changes: 1 addition & 1 deletion packages/tasks/src/components/ErrorBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function ErrorBanner({ task }: ErrorBannerProps) {
<button
type="button"
className="text-link"
onClick={() => api.viewLogs(task.id)}
onClick={() => api.viewLogs({ taskId: task.id })}
>
View logs <VscodeIcon name="link-external" />
</button>
Expand Down
3 changes: 2 additions & 1 deletion packages/tasks/src/components/TaskMessageInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
Expand Down
5 changes: 3 additions & 2 deletions packages/tasks/src/components/useTaskMenuItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});

Expand Down
2 changes: 1 addition & 1 deletion packages/tasks/src/hooks/useSelectedTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
58 changes: 2 additions & 56 deletions packages/tasks/src/hooks/useTasksApi.ts
Original file line number Diff line number Diff line change
@@ -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());
}
Loading