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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ jobs:

- run: pnpm install

- run: pnpm prettier --check .
- run: pnpm typecheck

- run: pnpm fmt:check

- run: pnpm lint

Expand Down
4 changes: 2 additions & 2 deletions esbuild.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ const buildOptions = {
target: "node20",
format: "cjs",
mainFields: ["module", "main"],
// Force openpgp to use CJS. The ESM version uses import.meta.url which is
// undefined when bundled to CJS, causing runtime errors.
alias: {
// Force openpgp to use CJS. The ESM version uses import.meta.url which is
// undefined when bundled to CJS, causing runtime errors.
openpgp: "./node_modules/openpgp/dist/node/openpgp.min.cjs",
},
external: ["vscode"],
Expand Down
4 changes: 3 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createTypeScriptImportResolver } from "eslint-import-resolver-typescrip
import { flatConfigs as importXFlatConfigs } from "eslint-plugin-import-x";
import packageJson from "eslint-plugin-package-json";
import reactPlugin from "eslint-plugin-react";
import reactCompilerPlugin from "eslint-plugin-react-compiler";
import reactHooksPlugin from "eslint-plugin-react-hooks";
import globals from "globals";

Expand Down Expand Up @@ -181,6 +182,7 @@ export default defineConfig(
files: ["**/*.tsx"],
plugins: {
react: reactPlugin,
"react-compiler": reactCompilerPlugin,
"react-hooks": reactHooksPlugin,
},
settings: {
Expand All @@ -189,7 +191,7 @@ export default defineConfig(
},
},
rules: {
// TS rules already applied above; add React-specific rules
...reactCompilerPlugin.configs.recommended.rules,
...reactPlugin.configs.recommended.rules,
...reactPlugin.configs["jsx-runtime"].rules, // React 17+ JSX transform
...reactHooksPlugin.configs.recommended.rules,
Expand Down
4 changes: 4 additions & 0 deletions media/tasks-logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 32 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"test:extension": "ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs --project extension",
"test:integration": "tsc -p test --outDir out && node esbuild.mjs && vscode-test",
"test:webview": "vitest --project webview",
"typecheck": "concurrently -g \"tsc --noEmit\" \"tsc --noEmit -p test\"",
"vscode:prepublish": "pnpm build:production",
"watch": "pnpm watch:all",
"watch:all": "concurrently -n extension,webviews \"pnpm watch:extension\" \"pnpm watch:webviews\"",
Expand Down Expand Up @@ -182,6 +183,11 @@
"id": "coder",
"title": "Coder Remote",
"icon": "media/logo-white.svg"
},
{
"id": "coderTasks",
"title": "Coder Tasks",
"icon": "media/tasks-logo.svg"
}
]
},
Expand All @@ -199,13 +205,15 @@
"visibility": "visible",
"icon": "media/logo-white.svg",
"when": "coder.authenticated && coder.isOwner"
},
}
],
"coderTasks": [
{
"type": "webview",
"id": "coder.tasksPanel",
"name": "Tasks",
"icon": "media/logo-white.svg",
"when": "coder.authenticated && coder.devMode"
"name": "Coder Tasks",
"icon": "media/tasks-logo.svg",
"when": "coder.authenticated"
}
]
},
Expand Down Expand Up @@ -308,6 +316,12 @@
"command": "coder.manageCredentials",
"title": "Manage Credentials",
"category": "Coder"
},
{
"command": "coder.tasks.refresh",
"title": "Refresh Tasks",
"category": "Coder",
"icon": "$(refresh)"
}
],
"menus": {
Expand Down Expand Up @@ -370,6 +384,10 @@
},
{
"command": "coder.manageCredentials"
},
{
"command": "coder.tasks.refresh",
"when": "false"
}
],
"view/title": [
Expand Down Expand Up @@ -404,6 +422,11 @@
"command": "coder.searchAllWorkspaces",
"when": "coder.authenticated && view == allWorkspaces",
"group": "navigation@3"
},
{
"command": "coder.tasks.refresh",
"when": "coder.authenticated && view == coder.tasksPanel",
"group": "navigation@1"
}
],
"view/item/context": [
Expand Down Expand Up @@ -448,6 +471,7 @@
},
"dependencies": {
"@peculiar/x509": "^1.14.3",
"@repo/shared": "workspace:*",
"axios": "1.13.4",
"date-fns": "^4.1.0",
"eventsource": "^4.1.0",
Expand Down Expand Up @@ -478,11 +502,12 @@
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.53.1",
"@vitejs/plugin-react-swc": "catalog:",
"@vitejs/plugin-react": "catalog:",
"@vitest/coverage-v8": "^4.0.16",
"@vscode/test-cli": "^0.0.12",
"@vscode/test-electron": "^2.5.2",
"@vscode/vsce": "^3.7.1",
"babel-plugin-react-compiler": "catalog:",
"bufferutil": "^4.1.0",
"coder": "github:coder/coder#main",
"concurrently": "^9.2.1",
Expand All @@ -495,6 +520,7 @@
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-package-json": "^0.88.2",
"eslint-plugin-react": "^7.37.0",
"eslint-plugin-react-compiler": "catalog:",
"eslint-plugin-react-hooks": "^5.0.0",
"globals": "^17.0.0",
"jsdom": "^27.4.0",
Expand All @@ -512,7 +538,7 @@
"extensionPack": [
"ms-vscode-remote.remote-ssh"
],
"packageManager": "pnpm@10.27.0",
"packageManager": "pnpm@10.28.2",
"engines": {
"vscode": "^1.95.0",
"node": ">= 20"
Expand Down
16 changes: 16 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "@repo/shared",
"version": "1.0.0",
"description": "Shared types and utilities for extension and webviews",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"devDependencies": {
"typescript": "catalog:"
}
}
6 changes: 6 additions & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// IPC protocol types
export * from "./ipc/protocol";

// Tasks types and API
export * from "./tasks/types";
export * from "./tasks/api";
100 changes: 100 additions & 0 deletions packages/shared/src/ipc/protocol.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* Type-safe IPC protocol for VS Code webview communication.
*
* Inspired by tRPC's approach: types are carried in a phantom `_types` property
* that exists only for TypeScript inference, not at runtime.
*/

// --- Message definitions ---

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

/** Command definition: params P, no response */
export interface CommandDef<P = void> {
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 method: string;
/** @internal Phantom type for inference - not present at runtime */
readonly _types?: { data: D };
}

// --- Factory functions ---

/** Define a request with typed params and response */
export function defineRequest<P = void, R = void>(
method: string,
): RequestDef<P, R> {
return { 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>;
}

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

// --- Wire format ---

/** Request from webview to extension */
export interface IpcRequest<P = unknown> {
readonly requestId: string;
readonly method: string;
readonly params?: P;
}

/** Response from extension to webview */
export interface IpcResponse<T = unknown> {
readonly requestId: string;
readonly method: string;
readonly success: boolean;
readonly data?: T;
readonly error?: string;
}

/** Push notification from extension to webview */
export interface IpcNotification<D = unknown> {
readonly type: string;
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;

/** 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>;
}

/** 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>;
}
82 changes: 82 additions & 0 deletions packages/shared/src/tasks/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Tasks API - Type-safe message definitions for the Tasks webview.
*
* Usage:
* ```tsx
* const ipc = useIpc();
* const tasks = await ipc.request(TasksApi.getTasks); // Returns Task[]
* ipc.command(TasksApi.viewInCoder, { taskId: "..." }); // Fire-and-forget
* ```
*/

import {
defineCommand,
defineNotification,
defineRequest,
} from "../ipc/protocol";

import type { Task, TaskDetails, TaskLogEntry, TaskTemplate } from "./types";

export interface InitResponse {
tasks: readonly Task[];
templates: readonly TaskTemplate[];
baseUrl: string;
tasksSupported: boolean;
}

const init = defineRequest<void, InitResponse>("init");
const getTasks = defineRequest<void, Task[]>("getTasks");
const getTemplates = defineRequest<void, TaskTemplate[]>("getTemplates");
const getTask = defineRequest<{ taskId: string }, Task>("getTask");
const getTaskDetails = defineRequest<{ taskId: string }, TaskDetails>(
"getTaskDetails",
);

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

const deleteTask = defineRequest<{ taskId: string }, void>("deleteTask");
const pauseTask = defineRequest<{ taskId: string }, void>("pauseTask");
const resumeTask = defineRequest<{ taskId: string }, void>("resumeTask");

const viewInCoder = defineCommand<{ taskId: string }>("viewInCoder");
const viewLogs = defineCommand<{ taskId: string }>("viewLogs");
const downloadLogs = defineCommand<{ taskId: string }>("downloadLogs");
const sendTaskMessage = defineCommand<{
taskId: string;
message: string;
}>("sendTaskMessage");

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

export const TasksApi = {
// Requests
init,
getTasks,
getTemplates,
getTask,
getTaskDetails,
createTask,
deleteTask,
pauseTask,
resumeTask,
// Commands
viewInCoder,
viewLogs,
downloadLogs,
sendTaskMessage,
// Notifications
taskUpdated,
tasksUpdated,
logsAppend,
refresh,
showCreateForm,
} as const;
Loading