Initial Coworker app scaffold
Some checks failed
Codex Template Compliance / compliance (push) Successful in 7s
Release Dry Run / release-dry-run (push) Failing after 44s
Build / build (push) Failing after 45s

This commit is contained in:
2026-06-19 01:07:16 +02:00
commit 2ec18964b7
46 changed files with 2582 additions and 0 deletions

View File

@@ -0,0 +1,416 @@
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
import { createHash, randomBytes } from "node:crypto";
import { createReadStream } from "node:fs";
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import { createServer, IncomingMessage, Server, ServerResponse } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
AppState,
BrainInput,
ModelProvider,
WebAccessSettings,
createBrainItem,
createPairedDevice,
createPairingChallenge,
createProvider,
isPairingChallengeValid,
nowIso,
probeOpenAiCompatibleProvider,
runAgent,
updateBrainItem
} from "@coworker/core";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rendererDir = path.resolve(__dirname, "../dist");
const stateFile = () => path.join(app.getPath("userData"), "state.json");
let mainWindow: BrowserWindow | undefined;
let webServer: Server | undefined;
let activePairing = createPairingChallenge(0);
const defaultState: AppState = {
providers: [
createProvider("ollama", "Ollama", "http://localhost:11434/v1"),
createProvider("lmstudio", "LM Studio", "http://localhost:1234/v1")
],
brain: [],
workspaces: [],
webAccess: {
enabled: false,
bindHost: "127.0.0.1",
port: 4789,
requirePairing: true
},
pairedDevices: []
};
async function readState(): Promise<AppState> {
try {
return { ...defaultState, ...JSON.parse(await readFile(stateFile(), "utf8")) };
} catch {
return defaultState;
}
}
async function writeState(state: AppState): Promise<AppState> {
await mkdir(path.dirname(stateFile()), { recursive: true });
await writeFile(stateFile(), JSON.stringify(state, null, 2), "utf8");
return state;
}
async function mutateState(mutator: (state: AppState) => AppState | Promise<AppState>): Promise<AppState> {
const current = await readState();
const next = await mutator(current);
return writeState(next);
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1320,
height: 860,
minWidth: 980,
minHeight: 680,
title: "Coworker",
backgroundColor: "#111317",
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false
}
});
const devUrl = process.env.VITE_DEV_SERVER_URL;
if (devUrl) {
void mainWindow.loadURL(devUrl);
} else {
void mainWindow.loadFile(path.join(rendererDir, "index.html"));
}
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
void shell.openExternal(url);
return { action: "deny" };
});
}
function registerIpc() {
ipcMain.handle("state:get", () => readState());
ipcMain.handle("providers:save", async (_event, provider: ModelProvider) => mutateState((state) => {
const next = { ...provider, baseUrl: provider.baseUrl.replace(/\/+$/, ""), updatedAt: nowIso() };
return {
...state,
providers: state.providers.some((item) => item.id === next.id)
? state.providers.map((item) => item.id === next.id ? next : item)
: [...state.providers, next]
};
}));
ipcMain.handle("providers:remove", async (_event, id: string) => mutateState((state) => ({
...state,
providers: state.providers.filter((provider) => provider.id !== id)
})));
ipcMain.handle("providers:probe", async (_event, id: string) => {
const state = await readState();
const provider = state.providers.find((item) => item.id === id);
if (!provider) throw new Error("Provider not found.");
return probeOpenAiCompatibleProvider(provider);
});
ipcMain.handle("brain:add", async (_event, input: BrainInput) => mutateState((state) => ({
...state,
brain: [createBrainItem(input), ...state.brain]
})));
ipcMain.handle("brain:update", async (_event, id: string, patch: Partial<BrainInput>) => mutateState((state) => ({
...state,
brain: state.brain.map((item) => item.id === id ? updateBrainItem(item, patch) : item)
})));
ipcMain.handle("brain:remove", async (_event, id: string) => mutateState((state) => ({
...state,
brain: state.brain.filter((item) => item.id !== id)
})));
ipcMain.handle("workspace:open", async () => {
const result = await dialog.showOpenDialog({ properties: ["openDirectory"] });
if (result.canceled || !result.filePaths[0]) return undefined;
const rootPath = result.filePaths[0];
const workspace = {
id: createHash("sha256").update(rootPath).digest("hex").slice(0, 16),
name: path.basename(rootPath),
rootPath,
openedAt: nowIso()
};
await mutateState((state) => ({
...state,
workspaces: [workspace, ...state.workspaces.filter((item) => item.id !== workspace.id)]
}));
return workspace;
});
ipcMain.handle("workspace:list", async (_event, id: string) => {
const workspace = (await readState()).workspaces.find((item) => item.id === id);
if (!workspace) throw new Error("Workspace not found.");
const entries = await readdir(workspace.rootPath, { withFileTypes: true });
return Promise.all(entries.slice(0, 200).map(async (entry) => {
const absolute = path.join(workspace.rootPath, entry.name);
const info = await stat(absolute);
return {
path: entry.name,
type: entry.isDirectory() ? "directory" : "file",
size: entry.isFile() ? info.size : undefined,
modifiedAt: info.mtime.toISOString()
};
}));
});
ipcMain.handle("workspace:read", async (_event, id: string, relativePath: string) => {
const workspace = (await readState()).workspaces.find((item) => item.id === id);
if (!workspace) throw new Error("Workspace not found.");
const absolute = confinedPath(workspace.rootPath, relativePath);
const info = await stat(absolute);
if (!info.isFile() || info.size > 512 * 1024) throw new Error("File is not readable in preview.");
return readFile(absolute, "utf8");
});
ipcMain.handle("agent:run", async (_event, request) => {
const state = await readState();
const provider = request.providerId
? state.providers.find((item) => item.id === request.providerId)
: state.providers.find((item) => item.enabled);
const model = request.model ?? provider?.defaults.coding ?? provider?.defaults.chat;
return runAgent(
{
prompt: String(request.prompt ?? ""),
workspace: request.workspace,
provider,
model,
messages: request.messages ?? [],
brain: state.brain
},
(messages) => completeWithProvider(provider, model, messages)
);
});
ipcMain.handle("web:settings", async (_event, settings: WebAccessSettings) => {
const state = await mutateState((current) => ({ ...current, webAccess: settings }));
await restartWebServer(state.webAccess);
return state;
});
ipcMain.handle("web:pairing:create", () => {
activePairing = createPairingChallenge();
return activePairing;
});
}
async function completeWithProvider(provider: ModelProvider | undefined, model: string | undefined, messages: { role: string; content: string }[]) {
if (!provider || !model) {
return "No model is configured yet. Add Ollama, LM Studio, or another OpenAI-compatible provider in Settings.";
}
const response = await fetch(`${provider.baseUrl.replace(/\/+$/, "")}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(provider.apiKey ? { Authorization: `Bearer ${provider.apiKey}` } : {})
},
body: JSON.stringify({
model,
messages: messages.map((message) => ({ role: message.role, content: message.content })),
temperature: 0.2
})
});
if (!response.ok) return `Model request failed with HTTP ${response.status}.`;
const body = await response.json() as { choices?: Array<{ message?: { content?: string } }> };
return body.choices?.[0]?.message?.content ?? "The model returned an empty response.";
}
function confinedPath(root: string, relativePath: string) {
const absolute = path.resolve(root, relativePath);
const normalizedRoot = path.resolve(root);
if (absolute !== normalizedRoot && !absolute.startsWith(`${normalizedRoot}${path.sep}`)) {
throw new Error("Path is outside the workspace.");
}
return absolute;
}
async function restartWebServer(settings: WebAccessSettings) {
if (webServer) {
await new Promise<void>((resolve) => webServer?.close(() => resolve()));
webServer = undefined;
}
if (!settings.enabled) return;
webServer = createServer(handleWebRequest);
await new Promise<void>((resolve, reject) => {
webServer?.listen(settings.port, settings.bindHost, resolve);
webServer?.on("error", reject);
});
}
async function handleWebRequest(req: IncomingMessage, res: ServerResponse) {
const url = new URL(req.url ?? "/", "http://coworker.local");
if (url.pathname === "/api/pair" && req.method === "POST") return handlePair(req, res);
if (url.pathname === "/api/state") return sendJson(res, await publicState(req));
if (url.pathname.startsWith("/api/") && !(await isAuthorized(req))) return sendJson(res, { error: "Pairing required." }, 401);
if (url.pathname === "/api/providers" && req.method === "POST") return handleWebProviderSave(req, res);
if (url.pathname.match(/^\/api\/providers\/[^/]+\/probe$/) && req.method === "POST") return handleWebProviderProbe(url, res);
if (url.pathname === "/api/brain" && req.method === "POST") return handleWebBrainAdd(req, res);
if (url.pathname.match(/^\/api\/brain\/[^/]+$/) && req.method === "DELETE") return handleWebBrainRemove(url, res);
if (url.pathname.match(/^\/api\/workspaces\/[^/]+\/files$/)) return handleWebWorkspaceList(url, res);
if (url.pathname.match(/^\/api\/workspaces\/[^/]+\/files\/.+$/)) return handleWebWorkspaceRead(url, res);
if (url.pathname === "/api/agent/run" && req.method === "POST") return handleWebAgent(req, res);
return serveStatic(url.pathname, res);
}
async function handlePair(req: IncomingMessage, res: ServerResponse) {
const body = await readJsonBody<{ code?: string; name?: string }>(req);
if (!isPairingChallengeValid(activePairing, body.code ?? "")) return sendJson(res, { error: "Invalid pairing code." }, 403);
const token = randomBytes(24).toString("hex");
const device = await createPairedDevice(body.name ?? "Mobile device", token, digest);
await mutateState((state) => ({ ...state, pairedDevices: [device, ...state.pairedDevices] }));
res.setHeader("Set-Cookie", `coworker_token=${token}; HttpOnly; SameSite=Lax; Path=/`);
sendJson(res, { token });
}
async function handleWebProviderSave(req: IncomingMessage, res: ServerResponse) {
const provider = await readJsonBody<ModelProvider>(req);
const state = await mutateState((current) => ({
...current,
providers: current.providers.some((item) => item.id === provider.id)
? current.providers.map((item) => item.id === provider.id ? { ...provider, updatedAt: nowIso() } : item)
: [...current.providers, { ...createProvider(provider.kind, provider.name, provider.baseUrl), ...provider, updatedAt: nowIso() }]
}));
sendJson(res, sanitizeState(state));
}
async function handleWebProviderProbe(url: URL, res: ServerResponse) {
const id = decodeURIComponent(url.pathname.split("/")[3]);
const provider = (await readState()).providers.find((item) => item.id === id);
if (!provider) return sendJson(res, { error: "Provider not found." }, 404);
sendJson(res, await probeOpenAiCompatibleProvider(provider));
}
async function handleWebBrainAdd(req: IncomingMessage, res: ServerResponse) {
const input = await readJsonBody<BrainInput>(req);
const state = await mutateState((current) => ({ ...current, brain: [createBrainItem(input), ...current.brain] }));
sendJson(res, sanitizeState(state));
}
async function handleWebBrainRemove(url: URL, res: ServerResponse) {
const id = decodeURIComponent(url.pathname.split("/")[3]);
const state = await mutateState((current) => ({ ...current, brain: current.brain.filter((item) => item.id !== id) }));
sendJson(res, sanitizeState(state));
}
async function handleWebWorkspaceList(url: URL, res: ServerResponse) {
const id = decodeURIComponent(url.pathname.split("/")[3]);
const workspace = (await readState()).workspaces.find((item) => item.id === id);
if (!workspace) return sendJson(res, { error: "Workspace not found." }, 404);
const entries = await readdir(workspace.rootPath, { withFileTypes: true });
const files = await Promise.all(entries.slice(0, 200).map(async (entry) => {
const absolute = path.join(workspace.rootPath, entry.name);
const info = await stat(absolute);
return {
path: entry.name,
type: entry.isDirectory() ? "directory" : "file",
size: entry.isFile() ? info.size : undefined,
modifiedAt: info.mtime.toISOString()
};
}));
sendJson(res, files);
}
async function handleWebWorkspaceRead(url: URL, res: ServerResponse) {
const parts = url.pathname.split("/");
const id = decodeURIComponent(parts[3]);
const relativePath = decodeURIComponent(parts.slice(5).join("/"));
const workspace = (await readState()).workspaces.find((item) => item.id === id);
if (!workspace) return sendJson(res, { error: "Workspace not found." }, 404);
const absolute = confinedPath(workspace.rootPath, relativePath);
const info = await stat(absolute);
if (!info.isFile() || info.size > 512 * 1024) return sendJson(res, { error: "File is not readable in preview." }, 400);
sendJson(res, { content: await readFile(absolute, "utf8") });
}
async function handleWebAgent(req: IncomingMessage, res: ServerResponse) {
const body = await readJsonBody<{ prompt?: string; messages?: unknown[]; providerId?: string; model?: string }>(req);
const state = await readState();
const provider = body.providerId ? state.providers.find((item) => item.id === body.providerId) : state.providers[0];
const result = await runAgent(
{ prompt: body.prompt ?? "", messages: [], brain: state.brain, provider, model: body.model },
(messages) => completeWithProvider(provider, body.model ?? provider?.defaults.chat, messages)
);
sendJson(res, result);
}
async function publicState(req: IncomingMessage) {
const state = await readState();
const paired = await isAuthorized(req);
return {
paired,
webAccess: state.webAccess,
providers: paired ? state.providers.map(({ apiKey, ...provider }) => provider) : [],
brain: paired ? state.brain : [],
workspaces: paired ? state.workspaces : []
};
}
async function isAuthorized(req: IncomingMessage) {
const token = (req.headers.cookie ?? "").split(";").map((item) => item.trim()).find((item) => item.startsWith("coworker_token="))?.split("=")[1];
if (!token) return false;
const hash = await digest(token);
const state = await readState();
return state.pairedDevices.some((device) => !device.revokedAt && device.tokenHash === hash);
}
async function digest(value: string) {
return createHash("sha256").update(value).digest("hex");
}
function sanitizeState(state: AppState): AppState {
return {
...state,
providers: state.providers.map(({ apiKey, ...provider }) => provider),
pairedDevices: state.pairedDevices.map((device) => ({ ...device, tokenHash: "" }))
};
}
async function readJsonBody<T>(req: IncomingMessage): Promise<T> {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(Buffer.from(chunk));
return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}") as T;
}
function sendJson(res: ServerResponse, data: unknown, status = 200) {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(data));
}
function serveStatic(urlPath: string, res: ServerResponse) {
try {
const safePath = urlPath === "/" ? "index.html" : urlPath.replace(/^\/+/, "");
const absolute = confinedPath(rendererDir, safePath);
const finalPath = absolute.endsWith(path.sep) ? path.join(absolute, "index.html") : absolute;
createReadStream(finalPath)
.on("error", () => createReadStream(path.join(rendererDir, "index.html")).pipe(res))
.pipe(res);
} catch {
res.writeHead(403);
res.end("Forbidden");
}
}
app.whenReady().then(async () => {
registerIpc();
createWindow();
await restartWebServer((await readState()).webAccess);
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});

View File

@@ -0,0 +1,31 @@
import { contextBridge, ipcRenderer } from "electron";
const api = {
state: () => ipcRenderer.invoke("state:get"),
providers: {
save: (provider: unknown) => ipcRenderer.invoke("providers:save", provider),
remove: (id: string) => ipcRenderer.invoke("providers:remove", id),
probe: (id: string) => ipcRenderer.invoke("providers:probe", id)
},
brain: {
add: (input: unknown) => ipcRenderer.invoke("brain:add", input),
update: (id: string, patch: unknown) => ipcRenderer.invoke("brain:update", id, patch),
remove: (id: string) => ipcRenderer.invoke("brain:remove", id)
},
workspace: {
open: () => ipcRenderer.invoke("workspace:open"),
list: (id: string) => ipcRenderer.invoke("workspace:list", id),
read: (id: string, relativePath: string) => ipcRenderer.invoke("workspace:read", id, relativePath)
},
agent: {
run: (request: unknown) => ipcRenderer.invoke("agent:run", request)
},
web: {
saveSettings: (settings: unknown) => ipcRenderer.invoke("web:settings", settings),
createPairing: () => ipcRenderer.invoke("web:pairing:create")
}
};
contextBridge.exposeInMainWorld("coworker", api);
export type CoworkerApi = typeof api;

17
apps/desktop/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#111317" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Coworker" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Coworker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,6 @@
{
"name": "@coworker/desktop",
"version": "0.1.0",
"type": "module",
"private": true
}

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect width="128" height="128" rx="28" fill="#111317"/>
<path d="M31 70c0-23 14-39 34-39 16 0 27 9 31 23H77c-3-5-7-7-13-7-10 0-16 9-16 23s6 23 16 23c7 0 12-3 15-10h19c-5 17-17 27-35 27-19 0-32-16-32-40Z" fill="#f5f7fb"/>
<path d="M22 24h28v13H35v54h15v13H22V24Zm84 80H78V91h15V37H78V24h28v80Z" fill="#7dd3fc"/>
</svg>

After

Width:  |  Height:  |  Size: 387 B

View File

@@ -0,0 +1,17 @@
{
"name": "Coworker",
"short_name": "Coworker",
"display": "standalone",
"background_color": "#111317",
"theme_color": "#111317",
"start_url": "/",
"scope": "/",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}

13
apps/desktop/public/sw.js Normal file
View File

@@ -0,0 +1,13 @@
self.addEventListener("install", (event) => {
event.waitUntil(caches.open("coworker-shell-v1").then((cache) => cache.addAll(["/", "/manifest.webmanifest", "/icon.svg"])));
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return;
event.respondWith(fetch(event.request).catch(() => caches.match(event.request).then((cached) => cached || caches.match("/"))));
});

86
apps/desktop/src/api.ts Normal file
View File

@@ -0,0 +1,86 @@
import type {
AgentRunResult,
AppState,
BrainInput,
BrainItem,
ModelProvider,
PairingChallenge,
ProviderProbeResult,
WebAccessSettings,
WorkspaceFile,
WorkspaceState
} from "@coworker/core";
type AgentRequest = {
prompt: string;
providerId?: string;
model?: string;
workspace?: WorkspaceState;
messages?: Array<{ role: string; content: string }>;
};
async function json<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {})
}
});
if (!response.ok) throw new Error(await response.text());
return response.json() as Promise<T>;
}
export const coworkerApi = {
state(): Promise<AppState> {
if (window.coworker) return window.coworker.state() as Promise<AppState>;
return json<AppState>("/api/state");
},
saveProvider(provider: ModelProvider): Promise<AppState> {
if (window.coworker) return window.coworker.providers.save(provider) as Promise<AppState>;
return json<AppState>("/api/providers", { method: "POST", body: JSON.stringify(provider) });
},
probeProvider(id: string): Promise<ProviderProbeResult> {
if (window.coworker) return window.coworker.providers.probe(id) as Promise<ProviderProbeResult>;
return json<ProviderProbeResult>(`/api/providers/${id}/probe`, { method: "POST" });
},
addBrain(input: BrainInput): Promise<AppState> {
if (window.coworker) return window.coworker.brain.add(input) as Promise<AppState>;
return json<AppState>("/api/brain", { method: "POST", body: JSON.stringify(input) });
},
removeBrain(id: string): Promise<AppState> {
if (window.coworker) return window.coworker.brain.remove(id) as Promise<AppState>;
return json<AppState>(`/api/brain/${id}`, { method: "DELETE" });
},
openWorkspace(): Promise<WorkspaceState | undefined> {
if (window.coworker) return window.coworker.workspace.open() as Promise<WorkspaceState | undefined>;
throw new Error("Workspace selection is available in the desktop app.");
},
listWorkspace(id: string): Promise<WorkspaceFile[]> {
if (window.coworker) return window.coworker.workspace.list(id) as Promise<WorkspaceFile[]>;
return json<WorkspaceFile[]>(`/api/workspaces/${id}/files`);
},
readWorkspace(id: string, relativePath: string): Promise<string> {
if (window.coworker) return window.coworker.workspace.read(id, relativePath) as Promise<string>;
return json<{ content: string }>(`/api/workspaces/${id}/files/${encodeURIComponent(relativePath)}`).then((r) => r.content);
},
runAgent(request: AgentRequest): Promise<AgentRunResult> {
if (window.coworker) return window.coworker.agent.run(request) as Promise<AgentRunResult>;
return json<AgentRunResult>("/api/agent/run", { method: "POST", body: JSON.stringify(request) });
},
saveWebSettings(settings: WebAccessSettings): Promise<AppState> {
if (window.coworker) return window.coworker.web.saveSettings(settings) as Promise<AppState>;
throw new Error("Web settings are available in the desktop app.");
},
createPairing(): Promise<PairingChallenge> {
if (window.coworker) return window.coworker.web.createPairing() as Promise<PairingChallenge>;
throw new Error("Pairing is started from the desktop app.");
},
pair(code: string, name: string): Promise<{ token: string }> {
return json<{ token: string }>("/api/pair", { method: "POST", body: JSON.stringify({ code, name }) });
}
};
export function displayBrainItem(item: BrainItem) {
return `${item.kind.toUpperCase()} / ${item.scope}${item.pinned ? " / PINNED" : ""}`;
}

7
apps/desktop/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
import type { CoworkerApi } from "../electron/preload";
declare global {
interface Window {
coworker?: CoworkerApi;
}
}

310
apps/desktop/src/main.tsx Normal file
View File

@@ -0,0 +1,310 @@
import React, { useEffect, useMemo, useState } from "react";
import { createRoot } from "react-dom/client";
import type { AppState, BrainItem, ChatMessage, ModelProvider, WorkspaceFile, WorkspaceState } from "@coworker/core";
import { createProvider } from "@coworker/core";
import { coworkerApi, displayBrainItem } from "./api";
import "./styles.css";
type View = "agent" | "models" | "brain" | "files" | "settings";
const emptyState: AppState = {
providers: [],
brain: [],
workspaces: [],
webAccess: { enabled: false, bindHost: "127.0.0.1", port: 4789, requirePairing: true },
pairedDevices: []
};
function App() {
const [state, setState] = useState<AppState>(emptyState);
const [view, setView] = useState<View>("agent");
const [error, setError] = useState("");
const [workspace, setWorkspace] = useState<WorkspaceState | undefined>();
async function refresh() {
try {
setState(await coworkerApi.state());
setError("");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load Coworker state.");
}
}
useEffect(() => {
void refresh();
if ("serviceWorker" in navigator) void navigator.serviceWorker.register("/sw.js").catch(() => undefined);
}, []);
useEffect(() => {
setWorkspace(state.workspaces[0]);
}, [state.workspaces]);
const activeProvider = state.providers.find((provider) => provider.enabled) ?? state.providers[0];
return (
<main className="shell">
<aside className="sidebar">
<div className="brand">
<span className="brand-mark">C</span>
<div>
<strong>Coworker</strong>
<span>Local coding app</span>
</div>
</div>
<nav className="nav">
{(["agent", "files", "models", "brain", "settings"] as View[]).map((item) => (
<button key={item} className={view === item ? "active" : ""} onClick={() => setView(item)}>
{label(item)}
</button>
))}
</nav>
<div className="status">
<span>Model</span>
<strong>{activeProvider?.name ?? "Not configured"}</strong>
</div>
<button className="primary" onClick={async () => setWorkspace(await coworkerApi.openWorkspace())}>
Open project
</button>
</aside>
<section className="content">
<header className="topbar">
<div>
<h1>{label(view)}</h1>
<p>{subtitle(view, workspace)}</p>
</div>
{error ? <span className="error">{error}</span> : <span className="ok">Ready</span>}
</header>
{view === "agent" && <AgentView state={state} workspace={workspace} onError={setError} />}
{view === "files" && <FilesView workspace={workspace} onError={setError} />}
{view === "models" && <ModelsView providers={state.providers} onState={setState} onError={setError} />}
{view === "brain" && <BrainView items={state.brain} onState={setState} onError={setError} />}
{view === "settings" && <SettingsView state={state} onState={setState} onError={setError} />}
</section>
</main>
);
}
function AgentView({ state, workspace, onError }: { state: AppState; workspace?: WorkspaceState; onError: (value: string) => void }) {
const [prompt, setPrompt] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [running, setRunning] = useState(false);
const provider = state.providers.find((item) => item.enabled) ?? state.providers[0];
const model = provider?.defaults.coding ?? provider?.defaults.chat;
async function run() {
if (!prompt.trim()) return;
setRunning(true);
const userMessage: ChatMessage = {
id: `local_${Date.now()}`,
role: "user",
content: prompt,
createdAt: new Date().toISOString()
};
setMessages((current) => [...current, userMessage]);
setPrompt("");
try {
const result = await coworkerApi.runAgent({ prompt, providerId: provider?.id, model, workspace, messages });
setMessages((current) => [...current, result.assistantMessage]);
onError("");
} catch (err) {
onError(err instanceof Error ? err.message : "Agent failed.");
} finally {
setRunning(false);
}
}
return (
<div className="agent-grid">
<section className="conversation">
{messages.length === 0 ? (
<div className="empty">
<h2>Start a coding session</h2>
<p>Open a project, pick a local model, and ask Coworker to inspect, plan, or propose changes.</p>
</div>
) : messages.map((message) => (
<article key={message.id} className={`message ${message.role}`}>
<span>{message.role}</span>
<pre>{message.content}</pre>
</article>
))}
<div className="composer">
<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} placeholder="Ask Coworker to work on this project..." />
<button className="primary" disabled={running} onClick={() => void run()}>{running ? "Running" : "Send"}</button>
</div>
</section>
<aside className="inspector">
<h2>Session</h2>
<dl>
<dt>Project</dt>
<dd>{workspace?.name ?? "None"}</dd>
<dt>Provider</dt>
<dd>{provider?.name ?? "None"}</dd>
<dt>Model</dt>
<dd>{model ?? "Choose in Models"}</dd>
</dl>
<h2>Brain recall</h2>
<ul className="compact-list">
{state.brain.filter((item) => item.pinned).slice(0, 6).map((item) => <li key={item.id}>{item.title}</li>)}
</ul>
</aside>
</div>
);
}
function FilesView({ workspace, onError }: { workspace?: WorkspaceState; onError: (value: string) => void }) {
const [files, setFiles] = useState<WorkspaceFile[]>([]);
const [preview, setPreview] = useState("");
useEffect(() => {
if (!workspace) return;
coworkerApi.listWorkspace(workspace.id).then(setFiles).catch((err) => onError(err instanceof Error ? err.message : "File list failed."));
}, [workspace, onError]);
return (
<div className="split">
<section className="list-panel">
{files.map((file) => (
<button key={file.path} onClick={async () => {
if (file.type === "directory" || !workspace) return;
setPreview(await coworkerApi.readWorkspace(workspace.id, file.path));
}}>
<span>{file.type === "directory" ? "DIR" : "FILE"}</span>
{file.path}
</button>
))}
</section>
<pre className="preview">{preview || "Open a text file to preview it."}</pre>
</div>
);
}
function ModelsView({ providers, onState, onError }: { providers: ModelProvider[]; onState: (state: AppState) => void; onError: (value: string) => void }) {
const [draft, setDraft] = useState<ModelProvider>(() => createProvider("openai-compatible", "Custom endpoint", "http://localhost:8000/v1"));
const [probe, setProbe] = useState("");
return (
<div className="settings-grid">
<section>
<h2>Providers</h2>
<div className="provider-list">
{providers.map((provider) => (
<article key={provider.id}>
<strong>{provider.name}</strong>
<span>{provider.baseUrl}</span>
<button onClick={async () => {
const result = await coworkerApi.probeProvider(provider.id);
setProbe(`${result.message} ${result.models.join(", ")}`);
}}>Test</button>
</article>
))}
</div>
</section>
<section>
<h2>Add endpoint</h2>
<input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} />
<input value={draft.baseUrl} onChange={(event) => setDraft({ ...draft, baseUrl: event.target.value })} />
<button className="primary" onClick={async () => {
try {
onState(await coworkerApi.saveProvider(draft));
setDraft(createProvider("openai-compatible", "Custom endpoint", "http://localhost:8000/v1"));
} catch (err) {
onError(err instanceof Error ? err.message : "Provider save failed.");
}
}}>Save provider</button>
<p className="muted">{probe || "Ollama and LM Studio defaults are created on first launch."}</p>
</section>
</div>
);
}
function BrainView({ items, onState, onError }: { items: BrainItem[]; onState: (state: AppState) => void; onError: (value: string) => void }) {
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [kind, setKind] = useState<"memory" | "skill">("memory");
return (
<div className="settings-grid">
<section>
<h2>Brain</h2>
<div className="brain-list">
{items.map((item) => (
<article key={item.id}>
<span>{displayBrainItem(item)}</span>
<strong>{item.title}</strong>
<p>{item.body}</p>
<button onClick={async () => onState(await coworkerApi.removeBrain(item.id))}>Remove</button>
</article>
))}
</div>
</section>
<section>
<h2>Add Brain item</h2>
<div className="segmented">
<button className={kind === "memory" ? "active" : ""} onClick={() => setKind("memory")}>Memory</button>
<button className={kind === "skill" ? "active" : ""} onClick={() => setKind("skill")}>Skill</button>
</div>
<input value={title} placeholder="Title" onChange={(event) => setTitle(event.target.value)} />
<textarea value={body} placeholder="What should Coworker remember or know how to do?" onChange={(event) => setBody(event.target.value)} />
<button className="primary" onClick={async () => {
try {
onState(await coworkerApi.addBrain({ kind, scope: "global", title, body, pinned: kind === "skill" }));
setTitle("");
setBody("");
} catch (err) {
onError(err instanceof Error ? err.message : "Brain save failed.");
}
}}>Save</button>
</section>
</div>
);
}
function SettingsView({ state, onState, onError }: { state: AppState; onState: (state: AppState) => void; onError: (value: string) => void }) {
const [settings, setSettings] = useState(state.webAccess);
const [pairing, setPairing] = useState("");
const url = useMemo(() => `http://${settings.bindHost === "0.0.0.0" ? "localhost" : settings.bindHost}:${settings.port}`, [settings]);
return (
<div className="settings-grid">
<section>
<h2>Web access</h2>
<label className="switch"><input type="checkbox" checked={settings.enabled} onChange={(event) => setSettings({ ...settings, enabled: event.target.checked })} /> Enable Web/PWA access</label>
<label>Bind host</label>
<select value={settings.bindHost} onChange={(event) => setSettings({ ...settings, bindHost: event.target.value as "127.0.0.1" | "0.0.0.0" })}>
<option value="127.0.0.1">Localhost only</option>
<option value="0.0.0.0">LAN / Tailscale</option>
</select>
<label>Port</label>
<input type="number" value={settings.port} min={1024} max={65535} onChange={(event) => setSettings({ ...settings, port: Number(event.target.value) })} />
<button className="primary" onClick={async () => {
try {
onState(await coworkerApi.saveWebSettings(settings));
} catch (err) {
onError(err instanceof Error ? err.message : "Web access update failed.");
}
}}>Apply</button>
<p className="muted">Open {url} on iPhone or iPad after enabling LAN access.</p>
</section>
<section>
<h2>Pair device</h2>
<button onClick={async () => {
const challenge = await coworkerApi.createPairing();
setPairing(`${challenge.code} expires ${new Date(challenge.expiresAt).toLocaleTimeString()}`);
}}>Create pairing code</button>
<div className="pairing-code">{pairing || "No active code"}</div>
<p className="muted">The mobile webapp stores a scoped device session after pairing.</p>
</section>
</div>
);
}
function label(view: View) {
return ({ agent: "Agent", models: "Models", brain: "Brain", files: "Files", settings: "Settings" })[view];
}
function subtitle(view: View, workspace?: WorkspaceState) {
if (view === "agent") return workspace ? `Working in ${workspace.name}` : "Open a project to start.";
if (view === "models") return "Connect Ollama, LM Studio, or OpenAI-compatible endpoints.";
if (view === "brain") return "Persistent memory, project knowledge, and reusable skills.";
if (view === "files") return workspace ? workspace.rootPath : "Open a project to inspect files.";
return "Desktop, web access, pairing, and release-safe defaults.";
}
createRoot(document.getElementById("root")!).render(<App />);

461
apps/desktop/src/styles.css Normal file
View File

@@ -0,0 +1,461 @@
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #111317;
color: #f4f6f8;
--bg: #111317;
--panel: #181b20;
--panel-2: #20242b;
--line: #303640;
--muted: #9ba6b3;
--text: #f4f6f8;
--accent: #7dd3fc;
--accent-2: #84cc16;
--danger: #fb7185;
--radius: 8px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: var(--bg);
}
button,
input,
textarea,
select {
font: inherit;
}
button {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel-2);
color: var(--text);
min-height: 40px;
padding: 0 12px;
cursor: pointer;
}
button:hover {
border-color: var(--accent);
}
button:disabled {
cursor: wait;
opacity: 0.6;
}
input,
textarea,
select {
width: 100%;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #0f1115;
color: var(--text);
padding: 12px;
}
textarea {
min-height: 120px;
resize: vertical;
}
.shell {
min-height: 100vh;
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
}
.sidebar {
border-right: 1px solid var(--line);
background: #0e1014;
padding: 18px;
display: flex;
flex-direction: column;
gap: 18px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #15202a;
color: var(--accent);
font-weight: 800;
}
.brand strong,
.brand span {
display: block;
}
.brand span,
.muted,
.status span,
.message span,
dt {
color: var(--muted);
}
.nav {
display: grid;
gap: 6px;
}
.nav button {
justify-content: flex-start;
text-align: left;
background: transparent;
}
.nav button.active,
.segmented button.active {
border-color: var(--accent);
background: #142333;
}
.status {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 12px;
background: var(--panel);
margin-top: auto;
}
.status strong {
display: block;
margin-top: 4px;
}
.primary {
border-color: #24506a;
background: #123047;
color: #e6f7ff;
}
.content {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-width: 0;
}
.topbar {
min-height: 86px;
border-bottom: 1px solid var(--line);
padding: 18px 22px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin-bottom: 4px;
font-size: 24px;
}
h2 {
font-size: 15px;
margin-bottom: 12px;
}
.topbar p {
margin-bottom: 0;
color: var(--muted);
}
.ok,
.error {
border: 1px solid var(--line);
border-radius: 999px;
padding: 7px 10px;
white-space: nowrap;
}
.ok {
color: var(--accent-2);
}
.error {
color: var(--danger);
}
.agent-grid,
.settings-grid,
.split {
min-height: 0;
padding: 18px;
display: grid;
gap: 18px;
}
.agent-grid {
grid-template-columns: minmax(0, 1fr) 320px;
}
.settings-grid,
.split {
grid-template-columns: minmax(0, 1fr) minmax(320px, 420px);
}
.conversation,
.inspector,
.settings-grid section,
.list-panel,
.preview {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
}
.conversation {
min-height: 0;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
overflow: hidden;
}
.empty {
align-self: center;
justify-self: center;
max-width: 520px;
text-align: center;
color: var(--muted);
}
.message {
margin: 14px;
padding: 14px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #12161c;
}
.message.assistant {
background: #151d20;
}
.message pre,
.preview {
margin: 0;
white-space: pre-wrap;
overflow: auto;
font-family: "Fira Code", ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 13px;
}
.composer {
border-top: 1px solid var(--line);
padding: 12px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
}
.composer textarea {
min-height: 58px;
max-height: 180px;
}
.inspector,
.settings-grid section {
padding: 16px;
overflow: auto;
}
dl {
display: grid;
grid-template-columns: 86px minmax(0, 1fr);
gap: 8px 12px;
margin: 0 0 22px;
}
dd {
margin: 0;
overflow-wrap: anywhere;
}
.compact-list {
margin: 0;
padding-left: 18px;
color: var(--muted);
}
.provider-list,
.brain-list {
display: grid;
gap: 10px;
}
.provider-list article,
.brain-list article {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 12px;
background: #12161c;
display: grid;
gap: 8px;
}
.provider-list span,
.brain-list span {
color: var(--muted);
overflow-wrap: anywhere;
}
.brain-list p {
margin-bottom: 0;
color: #d6dbe1;
}
.segmented {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 10px;
}
.settings-grid section > * + * {
margin-top: 10px;
}
.switch {
display: flex;
align-items: center;
gap: 10px;
}
.switch input {
width: auto;
}
.pairing-code {
min-height: 72px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #101319;
display: grid;
place-items: center;
font-size: 20px;
color: var(--accent);
}
.list-panel {
padding: 10px;
overflow: auto;
display: grid;
align-content: start;
gap: 6px;
}
.list-panel button {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
text-align: left;
align-items: center;
}
.list-panel span {
color: var(--muted);
font-size: 11px;
}
.preview {
padding: 14px;
min-height: 0;
}
@media (max-width: 880px) {
.shell {
grid-template-columns: 1fr;
grid-template-rows: minmax(0, 1fr) auto;
padding-bottom: env(safe-area-inset-bottom);
}
.sidebar {
grid-row: 2;
border-right: 0;
border-top: 1px solid var(--line);
padding: 8px max(8px, env(safe-area-inset-left)) max(8px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-right));
}
.brand,
.status,
.sidebar .primary {
display: none;
}
.nav {
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
}
.nav button {
min-height: 48px;
padding: 0 6px;
text-align: center;
justify-content: center;
}
.content {
min-height: 0;
}
.topbar {
min-height: 72px;
padding: calc(12px + env(safe-area-inset-top)) 14px 12px;
}
.topbar p,
.ok,
.error {
display: none;
}
h1 {
font-size: 20px;
}
.agent-grid,
.settings-grid,
.split {
grid-template-columns: 1fr;
padding: 10px;
}
.inspector {
display: none;
}
.composer {
grid-template-columns: 1fr;
}
.settings-grid,
.split {
overflow: auto;
}
}

View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": false,
"outDir": "dist-electron",
"rootDir": "electron",
"types": ["node"],
"lib": ["ES2022", "DOM"]
},
"include": ["electron/**/*.ts"]
}

View File

@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@coworker/core": ["../../packages/core/src"],
"@coworker/core/*": ["../../packages/core/src/*"]
}
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"electron/**/*.ts",
"vite.config.ts"
]
}

View File

@@ -0,0 +1,21 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { fileURLToPath, URL } from "node:url";
export default defineConfig({
root: fileURLToPath(new URL(".", import.meta.url)),
plugins: [react()],
resolve: {
alias: {
"@coworker/core": fileURLToPath(new URL("../../packages/core/src", import.meta.url))
}
},
build: {
outDir: "dist",
emptyOutDir: true
},
server: {
port: 5173,
strictPort: true
}
});