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

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