diff --git a/README.md b/README.md index d1dff6f..2f0c40c 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Skills are contextual and auto-loaded based on your conversation. When a request | Skill | Useful for | |-------|------------| | create-agent-tui | Scaffolds a complete agent TUI in TypeScript — like `create-react-app` for terminal agents. Customizable input styles, tool display modes, ASCII banners, loaders, session persistence, and [14 built-in tools](skills/create-agent-tui/README.md) | +| create-agent-desktop-app | Scaffolds a complete Electron desktop agent app in TypeScript — like `create-react-app` for desktop AI agents. React chat UI with themes, SQLite persistence, streaming, configurable tools, and native desktop features (clipboard, notifications, screenshots, Quick Chat overlay, system tray) | | openrouter-typescript-sdk | Complete reference for integrating with [600+ AI models](https://openrouter.ai/models) through the OpenRouter TypeScript SDK using the `callModel` pattern | | openrouter-models | Querying available models, comparing pricing, checking context lengths, finding provider performance, and fuzzy model name resolution | | openrouter-images | Generating images from text prompts and editing existing images using OpenRouter's image generation models | diff --git a/skills/create-agent-desktop-app/README.md b/skills/create-agent-desktop-app/README.md new file mode 100644 index 0000000..42950d1 --- /dev/null +++ b/skills/create-agent-desktop-app/README.md @@ -0,0 +1,153 @@ +# Create Agent Desktop App + +A skill for AI coding agents (Claude Code, Cursor, etc.) that scaffolds a complete Electron desktop agent app in TypeScript — like `create-react-app` but for desktop AI agents. Tell your coding agent what kind of desktop assistant you want, and it generates a runnable project that works with any model on OpenRouter and gives you a fully customizable native chat app. + +## Quickstart + +Install the [OpenRouter skills plugin](https://github.com/OpenRouterTeam/skills) in Claude Code: + +``` +/plugin marketplace add OpenRouterTeam/skills +/plugin install openrouter@openrouter +``` + +Or with the [GitHub CLI](https://cli.github.com) (v2.90+, works with Claude Code, Copilot, Cursor, Codex, Gemini CLI, and more): + +Install only this skill: + +``` +gh skill install OpenRouterTeam/skills create-agent-desktop-app +``` + +Or install the whole OpenRouter skills collection: + +``` +gh skill install OpenRouterTeam/skills +``` + +Then tell your agent to build a desktop agent app — it will use this skill automatically. + +## When to use this + +Building your own desktop agent app makes sense when: + +- **You want a polished native app** — a real desktop experience with window chrome, system tray, notifications, and global shortcuts +- **You need local-only chat** — all conversations stored in SQLite on disk, never leaves the machine +- **You need custom tools** — your agent interacts with your APIs, databases, or domain-specific systems +- **You want control over the loop** — custom stop conditions, approval flows, cost limits, or model selection +- **You're shipping a product** — the agent is part of your application, not just a developer tool + +## What gets scaffolded + +A complete Electron + React + TypeScript app built on [`@openrouter/agent`](https://www.npmjs.com/package/@openrouter/agent). With all defaults selected: + +``` +my-agent-app/ + package.json Electron + Vite + React + dependencies + electron.vite.config.ts Three-target build (main/preload/renderer) + tsconfig.*.json Strict TypeScript for each target + .env.example OPENROUTER_API_KEY= + src/ + main/ Electron main process (Node.js) + index.ts App entry, BrowserWindow, lifecycle + agent.ts Agent runner emitting IPC events + config.ts Layered config (defaults → file → env) + ipc-handlers.ts Typed IPC channel handlers + persistence.ts SQLite chat storage (better-sqlite3) + tools/ File read/write/edit, glob, grep, shell + preload/ + index.ts contextBridge typed API + renderer/ React frontend in BrowserWindow + App.tsx Root layout + components/ ChatView, MessageBubble, Sidebar, InputBar, etc. + stores/ Zustand (chat + app state) + hooks/ useAgent, useConversations, useTheme + styles/globals.css Tailwind + CSS variable themes +``` + +## Customizable features + +The skill presents an interactive checklist when invoked. Pick what you need: + +### Server tools (executed by OpenRouter, zero client code) + +| Tool | Default | +|------|---------| +| Web Search | on | +| Datetime | on | +| Image Generation | off | + +### User-defined tools (your code, executed in the Electron main process) + +| Tool | Default | +|------|---------| +| File Read, Write, Edit | on | +| Glob, Grep, Directory List | on | +| Shell/Bash | on | +| JS REPL | off | +| Sub-agent Spawn | off | +| Plan/Todo | off | +| Web Fetch | off | +| View Image | off | +| Custom Tool Template | on | + +### Desktop-specific tools + +| Tool | Default | +|------|---------| +| Clipboard Read/Write | off | +| Desktop Notifications | off | +| Screenshot Capture | off | +| System Info | off | +| Open Path/URL | off | +| File Dialog | off | + +### App modules + +| Module | Default | +|--------|---------| +| Chat Persistence (SQLite) | on | +| MCP Client Support | off | +| Theming Engine | on | +| Window Management | on | +| Quick Chat Overlay | off | +| System Tray | off | +| Context Compaction | off | +| Tool Approval Dialogs | off | +| Structured Event Logging | off | +| Auto-Update | off | + +### Visual customization + +| Category | Options | +|----------|---------| +| **Theme** | `system` (default), `dark`, `light`, custom | +| **Layout** | `sidebar` (default), `single`, custom | +| **Message style** | `bubbles` (default), `flat`, `terminal`, custom | +| **Tool display** | `collapsible` (default), `inline`, `hidden`, custom | + +## What `@openrouter/agent` handles + +The scaffolded app doesn't reimplement the agent loop — [`@openrouter/agent`](https://www.npmjs.com/package/@openrouter/agent) handles it: + +| Concern | How the SDK handles it | +|---------|------------------------| +| **Model calls** | `client.callModel()` — one call, any model on OpenRouter | +| **Tool execution** | Define tools with `tool()` + Zod schemas; SDK validates and calls `execute` | +| **Multi-turn** | Automatic — SDK loops until stop conditions fire | +| **Stop conditions** | `stepCountIs(n)`, `maxCost(amount)`, `hasToolCall(name)`, or custom | +| **Streaming** | `result.getItemsStream()` for unified event stream | +| **Cost tracking** | `result.getResponse().usage` with token counts | + +The scaffolded app provides everything *around* that loop: React UI, IPC plumbing, SQLite persistence, native OS integrations, window management. + +## How it compares + +| | [create-agent-tui](../create-agent-tui/) | **create-agent-desktop-app** | +|---|---|---| +| UI | Terminal (ANSI escape codes) | Native window (Electron + React) | +| Persistence | JSONL session files | SQLite database (default) | +| Tool display | ANSI formatters (emoji, grouped, minimal) | React components (collapsible, inline, hidden) | +| Input | Readline / raw terminal | HTML textarea + React state | +| Distribution | Node script | Packaged .app / .exe / .AppImage | +| Best for | Power users, dev tools, CI | End users, consumer apps, native experiences | diff --git a/skills/create-agent-desktop-app/SKILL.md b/skills/create-agent-desktop-app/SKILL.md new file mode 100644 index 0000000..7babbf9 --- /dev/null +++ b/skills/create-agent-desktop-app/SKILL.md @@ -0,0 +1,924 @@ +--- +name: create-agent-desktop-app +description: Scaffolds a complete Electron desktop agent app in TypeScript using @openrouter/agent — like create-react-app for desktop AI agents. Generates a React-based chat UI with themes, SQLite persistence, streaming, configurable tools, and native desktop features. Use when building a desktop agent, creating a chat app, scaffolding an Electron agent project, or building a desktop AI assistant. +--- + +# Create Agent Desktop App + +Scaffolds a complete Electron desktop agent app in TypeScript targeting OpenRouter. The generated project uses `@openrouter/agent` for the inner loop (model calls, tool execution, stop conditions) and provides the outer shell: a React chat UI, local persistence, native OS integrations, IPC plumbing, and an entry point. + +Architecture draws from three production desktop agent systems: +- **Claude Cowork** (Anthropic) — Electron + React, permission system, MCP protocol, multi-panel layout +- **OpenWork** (different-ai) — Tauri/Electron + React + Zustand, domain-driven structure, SSE streaming +- **Chorus** (meltylabs) — Tauri + React + shadcn/ui, SQLite chat persistence, Quick Chat overlay, toolsets + +## Prerequisites + +- Node.js 18+ +- `OPENROUTER_API_KEY` from [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys) +- For full SDK reference, see the `openrouter-typescript-sdk` skill +- For terminal agents instead of desktop apps, see the `create-agent-tui` skill + +--- + +## Decision Tree + +| User wants to... | Action | +|---|---| +| Build a new desktop agent app from scratch | Present checklist below → follow Generation Workflow | +| Add tools to an existing app | Read [references/tools.md](references/tools.md) + [references/desktop-tools.md](references/desktop-tools.md), present tool checklist only | +| Add desktop-specific tools (clipboard, notifications, etc.) | Read [references/desktop-tools.md](references/desktop-tools.md) | +| Add a module (persistence, theming, approvals) | Read [references/modules.md](references/modules.md) or the specific reference | +| Add MCP client support | Read [references/mcp-integration.md](references/mcp-integration.md) | +| Add Quick Chat overlay or system tray | Read [references/window-management.md](references/window-management.md) | +| Customize theming / colors | Read [references/theming.md](references/theming.md) | +| Customize chat message rendering | Read [references/chat-ui.md](references/chat-ui.md) | + +--- + +## Interactive Feature Checklist + +Present this as a multi-select checklist. Items marked **ON** are pre-selected defaults. + +### OpenRouter Server Tools (server-side, zero implementation) + +| Tool | Type string | Default | Config | +|------|------------|---------|--------| +| Web Search | `openrouter:web_search` | ON | engine, max_results, domain filtering | +| Datetime | `openrouter:datetime` | ON | timezone | +| Image Generation | `openrouter:image_generation` | OFF | model, quality, size, format | + +Server tools are executed by OpenRouter. No client implementation. + +### User-Defined Tools (generated into src/main/tools/) + +Same core tools as the TUI skill, executing in the Electron main process. + +| Tool | Default | Description | +|------|---------|-------------| +| File Read | ON | Read files with offset/limit, detect images | +| File Write | ON | Write/create files, auto-create directories | +| File Edit | ON | Search-and-replace with diff validation | +| Glob/Find | ON | File discovery by glob pattern | +| Grep/Search | ON | Content search by regex | +| Directory List | ON | List directory contents | +| Shell/Bash | ON | Execute commands with timeout and output capture | +| JS REPL | OFF | Persistent Node.js environment | +| Sub-agent Spawn | OFF | Delegate tasks to child agents | +| Plan/Todo | OFF | Track multi-step task progress | +| Request User Input | OFF | Structured questions rendered as modal dialog | +| Web Fetch | OFF | Fetch and extract text from web pages | +| View Image | OFF | Read local images as base64 | +| Custom Tool Template | ON | Empty skeleton for domain-specific tools | + +### Desktop-Specific Tools (Electron-native) + +See [references/desktop-tools.md](references/desktop-tools.md) for full specs. + +| Tool | Default | Description | +|------|---------|-------------| +| Clipboard Read/Write | OFF | Read/write system clipboard (text + images) | +| Desktop Notifications | OFF | Native OS notifications with click actions | +| Screenshot Capture | OFF | Capture screen/window/region via desktopCapturer | +| System Info | OFF | OS, CPU, memory, disk, displays | +| Open Path/URL | OFF | Open files, folders, URLs in default app | +| File Dialog | OFF | Native open/save file dialogs | + +### App Modules (architectural components) + +| Module | Default | Description | +|--------|---------|-------------| +| Chat Persistence (SQLite) | ON | SQLite database for conversations + messages | +| Chat Persistence (JSONL) | OFF | Lightweight JSONL alternative (mutually exclusive with SQLite) | +| **Model Picker** | ON | Searchable dropdown sourcing OpenRouter tool-capable models; per-conversation selection | +| **Auto-Title Conversations** | ON | First-message summarization via `openrouter/auto` to name each conversation | +| **Working Loader** | ON | Animated "Working" indicator in the assistant slot while waiting for the first token | +| Theming Engine | ON | CSS variable-based themes (Paper & Ink default) with accent color | +| Window Management | ON | Remember window size/position, standard chrome | +| MCP Client Support | OFF | Connect to external MCP tool servers | +| Quick Chat Overlay | OFF | Global-shortcut activated always-on-top mini window | +| System Tray | OFF | Minimize to tray, tray menu, notification badge | +| Context Compaction | OFF | Summarize older messages when context is long | +| System Prompt Composition | OFF | Assemble instructions from static + dynamic context | +| Tool Permissions / Approval | OFF | Gate dangerous tools behind confirmation dialog | +| Structured Event Logging | OFF | Emit events for tool calls, API requests, errors | +| App Auto-Update | OFF | electron-updater for auto-updates from GitHub Releases | + +All default-on modules are controlled by a `features` object in `agent.config.json`; users can disable any of them individually: + +```json +{ + "features": { + "autoTitle": false, + "modelPicker": true, + "warmTheme": true, + "workingLoader": true + } +} +``` + +### Slash Commands (user-facing chat commands) + +| Command | Default | Description | +|---------|---------|-------------| +| `/model` | ON | Switch model via searchable dropdown | +| `/new` | ON | Start a fresh conversation | +| `/help` | ON | List available commands | +| `/compact` | OFF | Manually trigger context compaction | +| `/session` | OFF | Show session metadata and token usage | +| `/export` | OFF | Save conversation as Markdown file | +| `/theme` | OFF | Toggle dark/light/system theme | +| `/clear` | OFF | Clear current conversation display | + +### Visual Customization (single-select per category) + +**Theme** — color scheme. See [references/theming.md](references/theming.md): + +| Style | Default | Description | +|-------|---------|-------------| +| `system` | ON | Follow OS light/dark preference — resolves to `paper` or `ink` | +| `paper` | | Warm beige "Paper & Ink" palette (editorial) | +| `ink` | | Deep ink dark-mode palette | +| Other | | User describes a custom color scheme | + +The default "Paper & Ink" palette pairs a warm paper background (`#F5EFE6`) with a muted terracotta accent (`#B8573A`), a variable serif display font (Fraunces), and Inter Tight for body text. See [references/theming.md](references/theming.md) for the full CSS variable contract. + +**Layout** — overall window structure: + +| Style | Default | Description | +|-------|---------|-------------| +| `sidebar` | ON | Collapsible conversation list + main chat panel | +| `single` | | Full-width chat, no sidebar | +| Other | | User describes a custom layout | + +**Message style** — how chat messages render. See [references/chat-ui.md](references/chat-ui.md): + +| Style | Default | Description | +|-------|---------|-------------| +| `ruled` | ON | User = filled ink bubble right, assistant = ruled ink-on-paper text (editorial) | +| `bubbles` | | Classic tinted bubbles both sides | +| `terminal` | | Monospace, minimal styling, terminal transcript look | +| Other | | User describes a custom message style | + +**Tool display** — how tool calls render in chat. See [references/chat-ui.md](references/chat-ui.md): + +| Style | Default | Description | +|-------|---------|-------------| +| `collapsible` | ON | Expandable cards with tool name, args, result | +| `inline` | | Inline badges with tooltip details | +| `hidden` | | Suppress tool display in chat | +| Other | | User describes a custom tool rendering | + +--- + +## Generation Workflow + +After getting checklist selections, follow this workflow: + +``` +- [ ] Generate package.json with Electron + Vite + React dependencies +- [ ] Generate electron.vite.config.ts (three-target Vite config) +- [ ] Generate tsconfig.json / tsconfig.node.json / tsconfig.web.json +- [ ] Generate src/main/config.ts +- [ ] Generate src/main/tools/index.ts wiring selected tools + server tools +- [ ] Generate selected tool files in src/main/tools/ +- [ ] Generate src/main/agent.ts (core runner with IPC event emission) +- [ ] Generate src/main/ipc-handlers.ts (typed IPC channels) +- [ ] If SQLite persistence: generate src/main/persistence.ts +- [ ] If JSONL persistence: generate src/main/session.ts +- [ ] If Window Management: generate src/main/window-state.ts +- [ ] If Quick Chat Overlay: generate src/main/quick-chat.ts +- [ ] If System Tray: generate src/main/tray.ts +- [ ] Generate src/main/index.ts (Electron app entry point) +- [ ] Generate src/preload/index.ts (contextBridge API) and src/preload/api.d.ts (shared types) +- [ ] Generate src/renderer/index.html +- [ ] Generate src/renderer/main.tsx (React root) +- [ ] Generate src/renderer/App.tsx (root layout) +- [ ] Generate src/renderer/styles/globals.css (Tailwind + theme CSS variables) +- [ ] Generate React components in src/renderer/components/ +- [ ] Generate Zustand stores in src/renderer/stores/ +- [ ] Generate hooks in src/renderer/hooks/ +- [ ] Generate src/renderer/lib/ipc.ts (typed IPC wrappers) +- [ ] Generate .env.example with OPENROUTER_API_KEY= +- [ ] Generate .gitignore +- [ ] Generate resources/icon.png placeholder (tell user to replace with real icon) +- [ ] Verify: cd into project, run npm install, npm run typecheck +``` + +--- + +## Tool Pattern + +All user-defined tools follow the same pattern as `create-agent-tui` using `@openrouter/agent/tool`. Tools execute in the **Electron main process** (full Node.js environment). Example: + +```typescript +import { tool } from '@openrouter/agent/tool'; +import { z } from 'zod'; +import { readFile } from 'fs/promises'; + +export const fileReadTool = tool({ + name: 'file_read', + description: 'Read the contents of a file at the given path', + inputSchema: z.object({ + path: z.string().describe('Absolute path to the file'), + offset: z.number().optional(), + limit: z.number().optional(), + }), + execute: async ({ path, offset, limit }) => { + try { + const content = await readFile(path, 'utf-8'); + const lines = content.split('\n'); + const start = offset ? offset - 1 : 0; + const end = limit ? start + limit : lines.length; + return { + content: lines.slice(start, end).join('\n'), + totalLines: lines.length, + }; + } catch (err: any) { + if (err.code === 'ENOENT') return { error: `File not found: ${path}` }; + return { error: err.message }; + } + }, +}); +``` + +For all filesystem/shell tools, see [references/tools.md](references/tools.md). +For Electron-specific tools (clipboard, notifications, screenshot), see [references/desktop-tools.md](references/desktop-tools.md). + +--- + +## Core Files + +These files are always generated. Adapt based on checklist selections. + +### package.json + +```bash +npm init -y +npm pkg set type=module +npm pkg set main=out/main/index.js +npm pkg set scripts.dev="electron-vite dev" +npm pkg set scripts.build="electron-vite build" +npm pkg set scripts.typecheck="tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json" +npm pkg set scripts.start="electron-vite preview" +npm pkg set scripts.package="electron-vite build && electron-builder" +npm pkg set scripts.postinstall="electron-rebuild -f -w better-sqlite3" +npm install @openrouter/agent@^0.4.0 zod@^4.3.6 glob dotenv +npm install react react-dom react-markdown rehype-highlight remark-gfm zustand +npm install better-sqlite3@^12 nanoid +npm install -D electron@^41 electron-vite@^5 electron-builder vite@^6 +npm install -D @vitejs/plugin-react typescript @types/node @types/react @types/react-dom @types/better-sqlite3 +npm install -D tailwindcss@^4 @tailwindcss/vite@^4 highlight.js +npm install -D @electron/rebuild +``` + +Version pinning matters here: +- `@openrouter/agent@^0.4.0` requires `zod@^4` +- `better-sqlite3@^12` is needed for Electron 41+ compatibility +- `electron-vite@^5` supports Vite 6 + +> **Why `dotenv`?** electron-vite only auto-loads env vars with its own prefixes (`MAIN_VITE_`, `PRELOAD_VITE_`, `RENDERER_VITE_`). A plain `OPENROUTER_API_KEY` in `.env` never reaches `process.env` without explicit loading. Call `dotenv.config({ path: ... })` at the top of `src/main/index.ts` (before `loadConfig()` runs). + +### electron.vite.config.ts + +```typescript +import { resolve } from 'path'; +import { defineConfig, externalizeDepsPlugin } from 'electron-vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + main: { + plugins: [externalizeDepsPlugin()], + build: { outDir: 'out/main', rollupOptions: { input: { index: resolve(__dirname, 'src/main/index.ts') } } }, + }, + preload: { + plugins: [externalizeDepsPlugin()], + build: { outDir: 'out/preload', rollupOptions: { input: { index: resolve(__dirname, 'src/preload/index.ts') } } }, + }, + renderer: { + root: resolve(__dirname, 'src/renderer'), + build: { + outDir: 'out/renderer', + rollupOptions: { input: { index: resolve(__dirname, 'src/renderer/index.html') } }, + }, + plugins: [react(), tailwindcss()], + resolve: { alias: { '@': resolve(__dirname, 'src/renderer') } }, + }, +}); +``` + +### tsconfig.json / tsconfig.node.json / tsconfig.web.json + +**tsconfig.json** — umbrella config: + +```json +{ + "files": [], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.web.json" } + ] +} +``` + +**tsconfig.node.json** — main + preload: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": ["node", "electron-vite/node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "composite": true, + "baseUrl": ".", + "paths": { + "@main/*": ["src/main/*"], + "@preload/*": ["src/preload/*"] + } + }, + "include": ["src/main/**/*.ts", "src/preload/**/*.ts", "src/preload/api.d.ts", "electron.vite.config.ts"] +} +``` + +**tsconfig.web.json** — renderer: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "composite": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/renderer/*"] + } + }, + "include": ["src/renderer/**/*.ts", "src/renderer/**/*.tsx", "src/preload/api.d.ts"] +} +``` + +### src/main/config.ts + +```typescript +import { readFileSync, existsSync } from 'fs'; +import { app } from 'electron'; +import { resolve } from 'path'; + +export interface DisplayConfig { + theme: 'system' | 'paper' | 'ink'; + layout: 'sidebar' | 'single'; + messageStyle: 'ruled' | 'bubbles' | 'terminal'; + toolDisplay: 'collapsible' | 'inline' | 'hidden'; +} + +export interface FeatureFlags { + autoTitle: boolean; + modelPicker: boolean; + warmTheme: boolean; + workingLoader: boolean; +} + +export interface AgentConfig { + apiKey: string; + model: string; + systemPrompt: string; + maxSteps: number; + maxCost: number; + dataDir: string; + display: DisplayConfig; + features: FeatureFlags; +} + +const DEFAULTS: Omit = { + apiKey: '', + model: 'openrouter/auto', + systemPrompt: 'You are a helpful desktop assistant.', + maxSteps: 20, + maxCost: 1.0, + display: { theme: 'system', layout: 'sidebar', messageStyle: 'ruled', toolDisplay: 'collapsible' }, + features: { autoTitle: true, modelPicker: true, warmTheme: true, workingLoader: true }, +}; + +export function loadConfig(overrides: Partial = {}): AgentConfig { + let config: AgentConfig = { ...DEFAULTS, dataDir: app.getPath('userData') }; + const configPath = resolve(config.dataDir, 'agent.config.json'); + if (existsSync(configPath)) { + try { + const file = JSON.parse(readFileSync(configPath, 'utf-8')); + if (file.display) config.display = { ...config.display, ...file.display }; + if (file.features) config.features = { ...config.features, ...file.features }; + config = { ...config, ...file, display: config.display, features: config.features }; + } catch {} + } + if (process.env.OPENROUTER_API_KEY) config.apiKey = process.env.OPENROUTER_API_KEY; + if (overrides.display) config.display = { ...config.display, ...overrides.display }; + if (overrides.features) config.features = { ...config.features, ...overrides.features }; + return { ...config, ...overrides, display: config.display, features: config.features }; +} +``` + +### src/main/tools/index.ts + +```typescript +import { serverTool } from '@openrouter/agent'; +import { fileReadTool } from './file-read.js'; +import { fileWriteTool } from './file-write.js'; +import { fileEditTool } from './file-edit.js'; +import { globTool } from './glob.js'; +import { grepTool } from './grep.js'; +import { listDirTool } from './list-dir.js'; +import { shellTool } from './shell.js'; + +export const tools = [ + fileReadTool, + fileWriteTool, + fileEditTool, + globTool, + grepTool, + listDirTool, + shellTool, + serverTool({ type: 'openrouter:web_search' }), + serverTool({ type: 'openrouter:datetime', parameters: { timezone: 'UTC' } }), +]; +``` + +### src/main/agent.ts + +Agent runner emits events that are forwarded via IPC to the renderer. + +```typescript +import { OpenRouter } from '@openrouter/agent'; +import type { Item } from '@openrouter/agent'; +import { stepCountIs, maxCost } from '@openrouter/agent/stop-conditions'; +import type { AgentConfig } from './config.js'; +import { tools } from './tools/index.js'; + +export type ChatMessage = { role: 'user' | 'assistant' | 'system'; content: string }; + +export type AgentEvent = + | { type: 'text'; delta: string } + | { type: 'tool_call'; name: string; callId: string; args: Record } + | { type: 'tool_result'; name: string; callId: string; output: string } + | { type: 'done'; usage?: { inputTokens?: number; outputTokens?: number; totalCost?: number } } + | { type: 'error'; message: string }; + +export async function runAgent( + config: AgentConfig, + input: string | ChatMessage[], + onEvent: (event: AgentEvent) => void, + signal?: AbortSignal, +): Promise { + const client = new OpenRouter({ apiKey: config.apiKey }); + const result = client.callModel({ + model: config.model, + instructions: config.systemPrompt, + input: input as string | Item[], + tools, + stopWhen: [stepCountIs(config.maxSteps), maxCost(config.maxCost)], + }); + + let lastTextLen = 0; + const callNames = new Map(); + + try { + for await (const item of result.getItemsStream()) { + if (signal?.aborted) break; + if (item.type === 'message') { + const text = item.content + ?.filter((c): c is { type: 'output_text'; text: string } => 'text' in c) + .map((c) => c.text).join('') ?? ''; + if (text.length > lastTextLen) { + onEvent({ type: 'text', delta: text.slice(lastTextLen) }); + lastTextLen = text.length; + } + } else if (item.type === 'function_call') { + callNames.set(item.callId, item.name); + if (item.status === 'completed') { + const args = (() => { try { return item.arguments ? JSON.parse(item.arguments) : {}; } catch { return {}; } })(); + onEvent({ type: 'tool_call', name: item.name, callId: item.callId, args }); + } + } else if (item.type === 'function_call_output') { + const out = typeof item.output === 'string' ? item.output : JSON.stringify(item.output); + onEvent({ + type: 'tool_result', + name: callNames.get(item.callId) ?? 'unknown', + callId: item.callId, + output: out.length > 500 ? out.slice(0, 500) + '…' : out, + }); + } + } + const response = await result.getResponse(); + onEvent({ type: 'done', usage: response.usage }); + } catch (err: any) { + onEvent({ type: 'error', message: err?.message ?? String(err) }); + } +} +``` + +### src/main/ipc-handlers.ts + +Includes handlers for the model picker (`conversations:set-model`, `conversations:get`), conversation renaming, and fire-and-forget auto-title. `config:get` returns only the subset the renderer needs — **never** ship the API key across IPC on every mount. + +```typescript +import { ipcMain, BrowserWindow } from 'electron'; +import { nanoid } from 'nanoid'; +import { loadConfig } from './config.js'; +import { runAgent, type ChatMessage } from './agent.js'; +import { summarizeToTitle } from './title.js'; +import * as db from './persistence.js'; + +const activeStreams = new Map(); + +export function registerIpcHandlers(getWindow: () => BrowserWindow | null) { + ipcMain.handle('config:get', () => { + const c = loadConfig(); + // Only send what the renderer needs. API key stays in main. + return { model: c.model, features: c.features, display: c.display }; + }); + + ipcMain.handle('conversations:list', () => db.listConversations()); + ipcMain.handle('conversations:create', (_e, model: string) => db.createConversation(model)); + ipcMain.handle('conversations:delete', (_e, id: string) => db.deleteConversation(id)); + ipcMain.handle('conversations:rename', (_e, id: string, title: string) => + db.renameConversation(id, title), + ); + ipcMain.handle('conversations:set-model', (_e, id: string, model: string) => + db.setConversationModel(id, model), + ); + ipcMain.handle('conversations:get', (_e, id: string) => db.getConversation(id)); + ipcMain.handle('messages:list', (_e, convId: string) => db.getMessages(convId)); + + ipcMain.handle('agent:send', async (_e, convId: string, userText: string) => { + const streamId = nanoid(); + const controller = new AbortController(); + activeStreams.set(streamId, controller); + + const config = loadConfig(); + const conversation = db.getConversation(convId); + const modelForTurn = conversation?.model ?? config.model; + + // Take the count BEFORE inserting, so we detect the very first user message. + const isFirstUserMessage = db.countMessages(convId) === 0; + + db.addMessage(convId, 'user', userText); + const messages = db.getMessages(convId).map((m) => ({ + role: m.role === 'tool' ? 'system' : m.role, + content: m.content, + })); + + // Fire-and-forget: auto-title the conversation from the first message. + if (isFirstUserMessage && config.features.autoTitle && config.apiKey) { + summarizeToTitle(userText, config.apiKey) + .then((title) => { + if (!title) return; + db.renameConversation(convId, title); + getWindow()?.webContents.send('conversations:renamed', { id: convId, title }); + }) + .catch((err) => console.warn('auto-title failed:', err)); + } + + let assistantText = ''; + const toolCalls: Array<{ callId: string; name: string; args: Record; output?: string }> = []; + + runAgent({ ...config, model: modelForTurn }, messages, (event) => { + getWindow()?.webContents.send('agent:event', { streamId, convId, event }); + + if (event.type === 'text') assistantText += event.delta; + else if (event.type === 'tool_call') + toolCalls.push({ callId: event.callId, name: event.name, args: event.args }); + else if (event.type === 'tool_result') { + const tc = toolCalls.find((t) => t.callId === event.callId); + if (tc) tc.output = event.output; + } else if (event.type === 'done') { + db.addMessage(convId, 'assistant', assistantText, + toolCalls.length ? JSON.stringify(toolCalls) : null); + activeStreams.delete(streamId); + } else if (event.type === 'error') { + activeStreams.delete(streamId); + } + }, controller.signal); + + return { streamId }; + }); + + ipcMain.handle('agent:abort', (_e, streamId: string) => { + activeStreams.get(streamId)?.abort(); + activeStreams.delete(streamId); + }); +} +``` + +### src/main/persistence.ts + +```typescript +import Database from 'better-sqlite3'; +import { app } from 'electron'; +import { join } from 'path'; +import { nanoid } from 'nanoid'; + +let db: Database.Database | null = null; + +export function initDb() { + if (db) return db; + db = new Database(join(app.getPath('userData'), 'chat.db')); + db.pragma('journal_mode = WAL'); + db.exec(` + CREATE TABLE IF NOT EXISTS conversations ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL DEFAULT 'New Conversation', + model TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('user','assistant','system','tool')), + content TEXT NOT NULL, + tool_calls TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id, created_at); + `); + return db; +} + +export function listConversations() { + return initDb().prepare('SELECT * FROM conversations ORDER BY updated_at DESC').all() as Array<{ + id: string; title: string; model: string; created_at: string; updated_at: string; + }>; +} + +export function createConversation(model: string): string { + const id = nanoid(); + initDb().prepare('INSERT INTO conversations (id, model) VALUES (?, ?)').run(id, model); + return id; +} + +export function deleteConversation(id: string) { + initDb().prepare('DELETE FROM conversations WHERE id = ?').run(id); +} + +export function addMessage(convId: string, role: 'user'|'assistant'|'system'|'tool', content: string, toolCalls?: string | null) { + const d = initDb(); + d.prepare('INSERT INTO messages (conversation_id, role, content, tool_calls) VALUES (?, ?, ?, ?)').run(convId, role, content, toolCalls ?? null); + d.prepare("UPDATE conversations SET updated_at = datetime('now') WHERE id = ?").run(convId); +} + +export function getMessages(convId: string) { + return initDb().prepare('SELECT role, content, tool_calls, created_at FROM messages WHERE conversation_id = ? ORDER BY created_at ASC').all(convId) as Array<{ + role: 'user'|'assistant'|'system'|'tool'; content: string; tool_calls: string | null; created_at: string; + }>; +} +``` + +### src/main/index.ts + +```typescript +import { app, BrowserWindow, shell, nativeTheme } from 'electron'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { config as loadEnv } from 'dotenv'; +import { registerIpcHandlers } from './ipc-handlers.js'; +import { initDb } from './persistence.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// Load .env (electron-vite doesn't auto-load plain OPENROUTER_API_KEY — only MAIN_VITE_* etc.) +loadEnv({ path: join(app.getAppPath(), '.env') }); +loadEnv({ path: join(process.cwd(), '.env') }); + +let mainWindow: BrowserWindow | null = null; + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1200, + height: 800, + show: false, + titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', + backgroundColor: nativeTheme.shouldUseDarkColors ? '#1a1a1a' : '#ffffff', + webPreferences: { + // IMPORTANT: electron-vite emits preload as `.mjs` when package.json has "type": "module". + // Reference the .mjs file here, not .js. + preload: join(__dirname, '../preload/index.mjs'), + sandbox: false, + contextIsolation: true, + }, + }); + + mainWindow.on('ready-to-show', () => mainWindow?.show()); + mainWindow.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; }); + + if (process.env['ELECTRON_RENDERER_URL']) { + mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']); + } else { + mainWindow.loadFile(join(__dirname, '../renderer/index.html')); + } +} + +app.whenReady().then(() => { + initDb(); + registerIpcHandlers(() => mainWindow); + createWindow(); + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); +}); + +app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); +``` + +### src/preload/api.d.ts + +Self-contained type surface. Both preload (runtime) and renderer (types-only) import from here. Keeping types in a `.d.ts` file prevents the renderer from trying to compile main/preload source: + +```typescript +export type Conversation = { id: string; title: string; model: string; created_at: string; updated_at: string }; +export type StoredMessage = { role: 'user'|'assistant'|'system'|'tool'; content: string; tool_calls: string | null; created_at: string }; +export type AgentEvent = + | { type: 'text'; delta: string } + | { type: 'tool_call'; name: string; callId: string; args: Record } + | { type: 'tool_result'; name: string; callId: string; output: string } + | { type: 'done'; usage?: { inputTokens?: number; outputTokens?: number; totalCost?: number } } + | { type: 'error'; message: string }; + +export type Api = { + getConfig: () => Promise; + listConversations: () => Promise; + createConversation: (model: string) => Promise; + deleteConversation: (id: string) => Promise; + renameConversation: (id: string, title: string) => Promise; + getMessages: (convId: string) => Promise; + sendMessage: (convId: string, text: string) => Promise<{ streamId: string }>; + abortStream: (streamId: string) => Promise; + onAgentEvent: (cb: (payload: { streamId: string; convId: string; event: AgentEvent }) => void) => () => void; + onThemeChanged: (cb: (isDark: boolean) => void) => () => void; +}; + +declare global { + interface Window { api: Api } +} +``` + +### src/preload/index.ts + +```typescript +import { contextBridge, ipcRenderer } from 'electron'; +import type { Api, AgentEvent } from './api.js'; + +const api: Api = { + getConfig: () => ipcRenderer.invoke('config:get'), + listConversations: () => ipcRenderer.invoke('conversations:list'), + createConversation: (model) => ipcRenderer.invoke('conversations:create', model), + deleteConversation: (id) => ipcRenderer.invoke('conversations:delete', id), + renameConversation: (id, title) => ipcRenderer.invoke('conversations:rename', id, title), + getMessages: (convId) => ipcRenderer.invoke('messages:list', convId), + sendMessage: (convId, text) => ipcRenderer.invoke('agent:send', convId, text), + abortStream: (streamId) => ipcRenderer.invoke('agent:abort', streamId), + onAgentEvent: (cb) => { + const listener = (_: Electron.IpcRendererEvent, payload: Parameters[0]) => cb(payload); + ipcRenderer.on('agent:event', listener); + return () => ipcRenderer.removeListener('agent:event', listener); + }, + onThemeChanged: (cb) => { + const listener = (_: Electron.IpcRendererEvent, isDark: boolean) => cb(isDark); + ipcRenderer.on('theme:changed', listener); + return () => ipcRenderer.removeListener('theme:changed', listener); + }, +}; + +contextBridge.exposeInMainWorld('api', api); +``` + +Renderer imports types with `import type { Conversation } from '../../preload/api.js'` (note the `.js` extension — TypeScript resolves it to the `.d.ts`). + +### src/renderer/index.html + +```html + + + + + + My Agent + + +
+ + + +``` + +### src/renderer/main.tsx + +```tsx +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App.tsx'; +import './styles/globals.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render(); +``` + +### src/renderer/App.tsx + +```tsx +import { useEffect } from 'react'; +import { Sidebar } from './components/Sidebar.tsx'; +import { ChatView } from './components/ChatView.tsx'; +import { useAppStore } from './stores/app.ts'; + +export default function App() { + const theme = useAppStore((s) => s.theme); + const sidebarOpen = useAppStore((s) => s.sidebarOpen); + + useEffect(() => { + const cls = theme === 'system' + ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') + : theme; + document.documentElement.classList.remove('dark', 'light'); + document.documentElement.classList.add(cls); + }, [theme]); + + return ( +
+ {sidebarOpen && } + +
+ ); +} +``` + +### src/renderer/styles/globals.css + +```css +@import "tailwindcss"; + +@layer base { + :root { + --bg-primary: #ffffff; + --bg-secondary: #f5f5f5; + --bg-chat: #ffffff; + --bg-input: #f0f0f0; + --bg-user: #007aff; + --bg-assistant: #f0f0f0; + --text-primary: #1a1a1a; + --text-secondary: #666666; + --text-on-user: #ffffff; + --accent: #007aff; + --border: #e5e5e5; + --radius-msg: 16px; + --radius-card: 8px; + } + .dark { + --bg-primary: #1a1a1a; + --bg-secondary: #242424; + --bg-chat: #1a1a1a; + --bg-input: #2a2a2a; + --bg-user: #0a84ff; + --bg-assistant: #2a2a2a; + --text-primary: #f5f5f5; + --text-secondary: #a0a0a0; + --text-on-user: #ffffff; + --accent: #0a84ff; + --border: #333333; + } + html, body, #root { height: 100%; margin: 0; padding: 0; } + body { font-family: system-ui, -apple-system, sans-serif; } +} +``` + +For detailed React component templates (ChatView, MessageBubble, Sidebar, InputBar, etc.), Zustand stores, hooks, and IPC typed wrappers, see [references/react-renderer.md](references/react-renderer.md) and [references/chat-ui.md](references/chat-ui.md). + +--- + +## Reference Files + +| File | Purpose | +|------|---------| +| [references/electron-main.md](references/electron-main.md) | Main process patterns: BrowserWindow, app lifecycle, IPC handler registration, menu bar, global shortcuts | +| [references/electron-preload.md](references/electron-preload.md) | Preload + contextBridge typed API, shared channel types | +| [references/react-renderer.md](references/react-renderer.md) | Component architecture, composition, Zustand stores, hooks, streaming state management | +| [references/theming.md](references/theming.md) | CSS variable theming, dark/light themes, accent color customization | +| [references/chat-ui.md](references/chat-ui.md) | Message rendering, markdown, code blocks, streaming display, tool call cards | +| [references/persistence.md](references/persistence.md) | SQLite schema, migrations, queries; JSONL alternative | +| [references/tools.md](references/tools.md) | User-defined tool specs (same core set as TUI) | +| [references/desktop-tools.md](references/desktop-tools.md) | Electron-specific tools (clipboard, notifications, screenshot, etc.) | +| [references/modules.md](references/modules.md) | Context compaction, system prompt composition, tool approval, structured logging | +| [references/window-management.md](references/window-management.md) | Window state persistence, Quick Chat overlay, system tray | +| [references/mcp-integration.md](references/mcp-integration.md) | MCP client setup in Electron main process | diff --git a/skills/create-agent-desktop-app/metadata.json b/skills/create-agent-desktop-app/metadata.json new file mode 100644 index 0000000..54c75ca --- /dev/null +++ b/skills/create-agent-desktop-app/metadata.json @@ -0,0 +1,13 @@ +{ + "version": "1.0.0", + "organization": "OpenRouter Inc", + "date": "April 2026", + "abstract": "Scaffolds a complete Electron desktop agent app in TypeScript using @openrouter/agent — React chat UI with themes, SQLite persistence, streaming, configurable tools, and native desktop features.", + "references": [ + "https://openrouter.ai/docs/guides/features/server-tools/overview", + "https://openrouter.ai/docs/guides/features/tool-calling", + "https://github.com/meltylabs/chorus", + "https://github.com/different-ai/openwork", + "https://electron-vite.org" + ] +} diff --git a/skills/create-agent-desktop-app/references/chat-ui.md b/skills/create-agent-desktop-app/references/chat-ui.md new file mode 100644 index 0000000..a1aac56 --- /dev/null +++ b/skills/create-agent-desktop-app/references/chat-ui.md @@ -0,0 +1,316 @@ +# Chat UI + +How messages, markdown, code blocks, and tool calls render in the chat view. + +## Default style: "ruled" + +The scaffold ships with a two-mode message treatment rather than symmetric bubbles: + +- **User messages** — filled ink bubble, right-aligned. Reads as a speech act. +- **Assistant messages** — no bubble. Text rendered on the page with a thin left rule (`border-left: 1px solid var(--border-strong)`). Reads as considered response / journal entry. + +Rationale: this treatment emphasizes the assistant's output as primary content rather than another "side" in a chat. It also scales better for long markdown responses with headers and code blocks — they don't feel like huge bubbles. + +Other tool-display and message-style variants (`bubbles`, `terminal`, `inline`, `hidden`) are documented below. + +## Working loader + +When the user submits a message, an empty assistant "slot" is created immediately so the UI feels responsive. While the model hasn't produced any text or tool calls yet, this slot renders a `` component: + +```tsx +export function WorkingIndicator() { + return ( +
+
+ ); +} +``` + +Paired with this CSS: + +```css +@keyframes working-dot { + 0%, 80%, 100% { transform: translateY(0) scale(0.85); opacity: 0.45; } + 40% { transform: translateY(-2px) scale(1); opacity: 1; } +} +.working-dot { width: 4px; height: 4px; margin: 0 1.5px; border-radius: 9999px; background: currentColor; display: inline-block; animation: working-dot 1.4s ease-in-out infinite; } +.working-dot:nth-child(2) { animation-delay: 0.16s; } +.working-dot:nth-child(3) { animation-delay: 0.32s; } +``` + +The dots "wave" in a staggered bounce. The word "Working" uses the display serif in italic, which hits completely differently from a generic "Thinking…" in sans. It aligns with the same left-rule that assistant text will eventually use, so there's no layout shift when text starts streaming — the rule was already there. + +`MessageBubble` decides to show it when: + +```tsx +const hasAnyContent = message.content || (message.toolCalls && message.toolCalls.length > 0); +const showWorking = message.streaming && !hasAnyContent; +``` + +As soon as the first text delta or tool call lands, `showWorking` becomes false and the real content fades in (`animation: fade-in 0.3s`). + +This is gated by the `workingLoader` feature flag. When disabled, the empty slot simply waits silently. + +## MessageBubble.tsx + +```tsx +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import rehypeHighlight from 'rehype-highlight'; +import type { UIMessage } from '../stores/chat.ts'; +import { ToolCallCard } from './ToolCallCard.tsx'; +import 'highlight.js/styles/github-dark.css'; + +export function MessageBubble({ message }: { message: UIMessage }) { + const isUser = message.role === 'user'; + + if (message.role === 'tool') return null; + + return ( +
+
+ {message.toolCalls?.map((tc) => ( + + ))} + {message.content && ( +
+ { + const isBlock = className?.includes('language-'); + if (isBlock) { + return ( +
+                        {children}
+                      
+ ); + } + return ( + + {children} + + ); + }, + a: ({ href, children }) => ( + { + e.preventDefault(); + if (href) window.open(href, '_blank'); + }} + className="text-[var(--accent)] underline" + > + {children} + + ), + }} + > + {message.content} +
+ {message.streaming && ( + + )} +
+ )} +
+
+ ); +} +``` + +## ToolCallCard.tsx (collapsible variant) + +```tsx +import { useState } from 'react'; + +export function ToolCallCard({ + name, + args, + output, +}: { + name: string; + args: Record; + output?: string; +}) { + const [expanded, setExpanded] = useState(false); + const argSummary = summarizeArgs(name, args); + const status = output === undefined ? 'running' : 'done'; + + return ( +
+ + {expanded && ( +
+
+
Args
+
{JSON.stringify(args, null, 2)}
+
+ {output !== undefined && ( +
+
Output
+
{output}
+
+ )} +
+ )} +
+ ); +} + +function summarizeArgs(name: string, args: Record): string { + const keyMap: Record = { + shell: 'command', file_read: 'path', file_write: 'path', file_edit: 'path', + glob: 'pattern', grep: 'pattern', web_search: 'query', + }; + const key = keyMap[name] ?? Object.keys(args)[0]; + if (!key || !(key in args)) return ''; + const val = String(args[key]); + return val.length > 60 ? val.slice(0, 60) + '…' : val; +} +``` + +## MessageList.tsx + +Auto-scrolls to bottom when new messages arrive. + +```tsx +import { useEffect, useRef } from 'react'; +import { useChatStore } from '../stores/chat.ts'; +import { MessageBubble } from './MessageBubble.tsx'; + +export function MessageList() { + const messages = useChatStore((s) => s.messages); + const endRef = useRef(null); + + useEffect(() => { + endRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + return ( +
+ {messages.map((m, i) => ( + + ))} +
+
+ ); +} +``` + +## LoadingIndicator.tsx + +```tsx +export function LoadingIndicator() { + return ( +
+ + + + + + Thinking… +
+ ); +} +``` + +## Message Style Variants + +### bubbles (default) + +Code shown above: rounded bubbles, user right-aligned, assistant left-aligned. + +### flat + +Replace the bubble layout with full-width messages: + +```tsx +
+
+ + {isUser ? 'You' : 'Assistant'} + +
+
+ {/* markdown content */} +
+
+``` + +### terminal + +Monospace, no bubbles, minimal styling: + +```tsx +
+ {isUser ? '>' : '·'}{' '} + {message.content} +
+``` + +## Tool Display Variants + +### collapsible (default) + +Code shown above: expandable cards with args + output. + +### inline + +Show tool calls as small inline badges: + +```tsx + + {name} + · + {summarizeArgs(name, args)} + +``` + +### hidden + +Don't render `message.toolCalls` at all. + +## Streaming Cursor + +The blinking caret at the end of a streaming message (seen in `MessageBubble` above) is a `` with `animate-pulse`. It's appended at the end of the assistant text during `message.streaming === true`. + +## Performance + +For very long conversations, replace the naive `map` in `MessageList` with virtualization (e.g. `@tanstack/react-virtual`). Typical sessions of <200 messages don't need it. + +Markdown re-renders on every text delta. If that becomes visible lag, memoize completed messages: + +```tsx +const MemoBubble = memo(MessageBubble, (prev, next) => + !prev.message.streaming && !next.message.streaming && prev.message === next.message, +); +``` diff --git a/skills/create-agent-desktop-app/references/desktop-tools.md b/skills/create-agent-desktop-app/references/desktop-tools.md new file mode 100644 index 0000000..ff4c979 --- /dev/null +++ b/skills/create-agent-desktop-app/references/desktop-tools.md @@ -0,0 +1,238 @@ +# Desktop-Specific Tool Specs + +Tools that use Electron / OS APIs. They execute in the **main process** (renderer cannot use these directly because of contextIsolation). + +File location: `src/main/tools/` + +## clipboard + +```typescript +import { tool } from '@openrouter/agent/tool'; +import { z } from 'zod'; +import { clipboard, nativeImage } from 'electron'; + +export const clipboardTool = tool({ + name: 'clipboard', + description: 'Read from or write to the system clipboard', + inputSchema: z.object({ + action: z.enum(['read', 'write', 'read_image']), + text: z.string().optional().describe('Text to write (for action=write)'), + }), + execute: async ({ action, text }) => { + switch (action) { + case 'read': + return { text: clipboard.readText() }; + case 'write': + if (text === undefined) return { error: 'text is required' }; + clipboard.writeText(text); + return { written: true }; + case 'read_image': { + const img = clipboard.readImage(); + if (img.isEmpty()) return { empty: true }; + return { type: 'image', data: img.toDataURL() }; + } + } + }, +}); +``` + +## notifications + +```typescript +import { tool } from '@openrouter/agent/tool'; +import { z } from 'zod'; +import { Notification } from 'electron'; + +export const notificationsTool = tool({ + name: 'show_notification', + description: 'Show a native OS notification', + inputSchema: z.object({ + title: z.string(), + body: z.string().optional(), + urgency: z.enum(['low', 'normal', 'critical']).optional(), + }), + execute: async ({ title, body, urgency }) => { + if (!Notification.isSupported()) return { error: 'Notifications are not supported on this system' }; + const notification = new Notification({ title, body, urgency }); + notification.show(); + return { shown: true }; + }, +}); +``` + +## screenshot + +```typescript +import { tool } from '@openrouter/agent/tool'; +import { z } from 'zod'; +import { desktopCapturer, screen } from 'electron'; + +export const screenshotTool = tool({ + name: 'screenshot', + description: 'Capture a screenshot of the screen or a specific window', + inputSchema: z.object({ + target: z.enum(['screen', 'window']).default('screen'), + sourceId: z.string().optional().describe('Specific source id from list; omit to capture primary screen'), + maxWidth: z.number().optional().describe('Max output width in pixels'), + }), + execute: async ({ target, sourceId, maxWidth }) => { + const { width, height } = screen.getPrimaryDisplay().size; + const sources = await desktopCapturer.getSources({ + types: target === 'screen' ? ['screen'] : ['window'], + thumbnailSize: { width: maxWidth ?? width, height: maxWidth ? Math.round(height * (maxWidth / width)) : height }, + }); + const source = sourceId ? sources.find((s) => s.id === sourceId) : sources[0]; + if (!source) return { error: 'No capture source found' }; + return { + type: 'image', + data: source.thumbnail.toPNG().toString('base64'), + mimeType: 'image/png', + sourceName: source.name, + }; + }, +}); +``` + +## system_info + +```typescript +import { tool } from '@openrouter/agent/tool'; +import { z } from 'zod'; +import { app, screen } from 'electron'; +import os from 'os'; + +export const systemInfoTool = tool({ + name: 'system_info', + description: 'Return system information (OS, memory, CPU, displays)', + inputSchema: z.object({}), + execute: async () => ({ + platform: process.platform, + arch: process.arch, + osRelease: os.release(), + nodeVersion: process.version, + electronVersion: process.versions.electron, + hostname: os.hostname(), + cpus: os.cpus().map((c) => c.model)[0], + totalMemoryGB: +(os.totalmem() / 1024 / 1024 / 1024).toFixed(2), + freeMemoryGB: +(os.freemem() / 1024 / 1024 / 1024).toFixed(2), + uptimeHours: +(os.uptime() / 3600).toFixed(1), + appVersion: app.getVersion(), + displays: screen.getAllDisplays().map((d) => ({ + size: d.size, scaleFactor: d.scaleFactor, primary: d.id === screen.getPrimaryDisplay().id, + })), + }), +}); +``` + +## open_path + +**Security note:** this tool hands file paths to the OS shell. Without a scope limit, a misbehaving or prompt-injected model can launch apps (`/Applications/...`) or open arbitrary files. The version below restricts opens to paths within an explicit allow-list of root directories — set these to the user's working directory, project roots, or whatever your app considers safe. + +```typescript +import { tool } from '@openrouter/agent/tool'; +import { z } from 'zod'; +import { shell } from 'electron'; +import { resolve, relative, isAbsolute } from 'path'; +import { homedir } from 'os'; + +// Configure to your app's trusted roots. Anything outside is rejected. +const ALLOWED_ROOTS = [process.cwd(), resolve(homedir(), 'Documents'), resolve(homedir(), 'Downloads')]; + +function isInsideAllowedRoot(absPath: string): boolean { + return ALLOWED_ROOTS.some((root) => { + const rel = relative(root, absPath); + return rel && !rel.startsWith('..') && !isAbsolute(rel); + }); +} + +export const openPathTool = tool({ + name: 'open_path', + description: 'Open a file, folder, or URL in the default application. Paths are restricted to allowed roots.', + inputSchema: z.object({ + target: z.string().describe('https/http URL, or a file/folder path within an allowed root'), + }), + execute: async ({ target }) => { + if (/^https?:\/\//.test(target)) { + await shell.openExternal(target); + return { opened: true, as: 'external_url' }; + } + const abs = resolve(target); + if (!isInsideAllowedRoot(abs)) { + return { error: `Refused to open path outside allowed roots: ${abs}` }; + } + const err = await shell.openPath(abs); + if (err) return { error: err }; + return { opened: true, as: 'path', resolved: abs }; + }, +}); +``` + +## file_dialog + +```typescript +import { tool } from '@openrouter/agent/tool'; +import { z } from 'zod'; +import { dialog, BrowserWindow } from 'electron'; + +export const fileDialogTool = tool({ + name: 'file_dialog', + description: 'Show a native file open or save dialog', + inputSchema: z.object({ + mode: z.enum(['open', 'save']), + title: z.string().optional(), + defaultPath: z.string().optional(), + filters: z.array(z.object({ + name: z.string(), + extensions: z.array(z.string()), + })).optional(), + multiSelections: z.boolean().optional(), + }), + execute: async ({ mode, title, defaultPath, filters, multiSelections }) => { + const win = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; + if (mode === 'open') { + const result = await dialog.showOpenDialog(win, { + title, + defaultPath, + filters, + properties: multiSelections ? ['openFile', 'multiSelections'] : ['openFile'], + }); + if (result.canceled) return { canceled: true }; + return { paths: result.filePaths }; + } else { + const result = await dialog.showSaveDialog(win, { title, defaultPath, filters }); + if (result.canceled) return { canceled: true }; + return { path: result.filePath }; + } + }, +}); +``` + +## Wiring into the Tool Registry + +Add selected desktop tools to `src/main/tools/index.ts`: + +```typescript +import { clipboardTool } from './clipboard.js'; +import { notificationsTool } from './notifications.js'; +import { screenshotTool } from './screenshot.js'; +// ... + +export const tools = [ + fileReadTool, + fileWriteTool, + // ... core tools + clipboardTool, + notificationsTool, + screenshotTool, + // ... desktop tools + serverTool({ type: 'openrouter:web_search' }), +]; +``` + +## Permissions + +Screenshot and notifications may require OS-level permission on macOS: +- **Screen recording**: System Settings → Privacy & Security → Screen Recording +- **Notifications**: Granted automatically on first `new Notification({...}).show()` call + +If the user denies screen recording, `desktopCapturer.getSources` returns an empty array. Handle that case and surface a helpful message. diff --git a/skills/create-agent-desktop-app/references/electron-main.md b/skills/create-agent-desktop-app/references/electron-main.md new file mode 100644 index 0000000..6a2545b --- /dev/null +++ b/skills/create-agent-desktop-app/references/electron-main.md @@ -0,0 +1,172 @@ +# Electron Main Process + +The main process is the entry point of the Electron app. It owns the Node.js runtime, manages BrowserWindow instances, handles the application lifecycle, and hosts the agent loop + tools. + +> **Preload extension: `.mjs`, not `.js`.** Because `package.json` has `"type": "module"`, electron-vite 5 emits the preload bundle as `out/preload/index.mjs`. The main process must reference `../preload/index.mjs` in `webPreferences.preload`. Using `.js` will fail with "Unable to load preload script" and `window.api` will be undefined in the renderer. + +## src/main/index.ts + +Full main entry point with lifecycle and IPC registration: + +```typescript +import { app, BrowserWindow, shell, nativeTheme, Menu } from 'electron'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; +import { registerIpcHandlers } from './ipc-handlers.js'; +import { initDb } from './persistence.js'; +import { restoreWindowState, saveWindowState } from './window-state.js'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +let mainWindow: BrowserWindow | null = null; + +function createWindow() { + const state = restoreWindowState({ width: 1200, height: 800 }); + + mainWindow = new BrowserWindow({ + ...state, + show: false, + minWidth: 700, + minHeight: 500, + titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', + trafficLightPosition: process.platform === 'darwin' ? { x: 16, y: 16 } : undefined, + backgroundColor: nativeTheme.shouldUseDarkColors ? '#1a1a1a' : '#ffffff', + webPreferences: { + preload: join(__dirname, '../preload/index.mjs'), + sandbox: false, + contextIsolation: true, + nodeIntegration: false, + }, + }); + + mainWindow.on('ready-to-show', () => mainWindow?.show()); + mainWindow.on('close', () => { + if (mainWindow) saveWindowState(mainWindow); + }); + + // Route new-window links to the system browser + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + shell.openExternal(url); + return { action: 'deny' }; + }); + + // Dev: load Vite server. Prod: load built HTML. + if (process.env['ELECTRON_RENDERER_URL']) { + mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']); + } else { + mainWindow.loadFile(join(__dirname, '../renderer/index.html')); + } +} + +app.whenReady().then(() => { + initDb(); + registerIpcHandlers(() => mainWindow); + createWindow(); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); + }); + + // Respond to OS theme changes + nativeTheme.on('updated', () => { + mainWindow?.webContents.send('theme:changed', nativeTheme.shouldUseDarkColors); + }); +}); + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit(); +}); +``` + +## Application Menu + +For a polished native experience, install an application menu: + +```typescript +function buildMenu() { + const template: Electron.MenuItemConstructorOptions[] = [ + ...(process.platform === 'darwin' ? [{ + label: app.name, + submenu: [ + { role: 'about' as const }, + { type: 'separator' as const }, + { role: 'services' as const }, + { type: 'separator' as const }, + { role: 'hide' as const }, + { role: 'hideOthers' as const }, + { role: 'unhide' as const }, + { type: 'separator' as const }, + { role: 'quit' as const }, + ], + }] : []), + { + label: 'File', + submenu: [ + { label: 'New Conversation', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('menu:new-conversation') }, + { type: 'separator' }, + process.platform === 'darwin' ? { role: 'close' } : { role: 'quit' }, + ], + }, + { role: 'editMenu' }, + { role: 'viewMenu' }, + { role: 'windowMenu' }, + ]; + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +} +``` + +Call `buildMenu()` inside `app.whenReady().then(...)` before `createWindow()`. + +## Global Shortcuts + +For a Quick Chat overlay or any global shortcut: + +```typescript +import { globalShortcut } from 'electron'; + +app.whenReady().then(() => { + globalShortcut.register('CommandOrControl+Shift+Space', () => { + mainWindow?.isVisible() ? mainWindow.hide() : mainWindow?.show(); + }); +}); + +app.on('will-quit', () => globalShortcut.unregisterAll()); +``` + +## IPC Handler Pattern + +Register all handlers in one place. Pass a `getWindow` accessor so handlers can call `webContents.send` without capturing a stale reference: + +```typescript +// ipc-handlers.ts +export function registerIpcHandlers(getWindow: () => BrowserWindow | null) { + ipcMain.handle('config:get', () => loadConfig()); + ipcMain.handle('agent:send', async (_e, convId, text) => { + // ... stream agent events via getWindow()?.webContents.send('agent:event', ...) + }); +} +``` + +The `getWindow` accessor lets you handle edge cases like the window being destroyed mid-stream. + +## Security + +- **Always** set `contextIsolation: true` and `nodeIntegration: false` on BrowserWindow. +- **Never** load untrusted remote URLs in the main window. +- Use `setWindowOpenHandler` to route external links to the system browser. +- Validate any user-supplied paths that reach `shell.openPath()` or `shell.openExternal()`. +- Validate / constrain arguments to IPC handlers — don't assume the renderer sends well-formed data. + +## Error Handling + +Unhandled promise rejections should not crash the app. Add process-level handlers: + +```typescript +process.on('uncaughtException', (err) => { + console.error('Uncaught exception:', err); +}); +process.on('unhandledRejection', (reason) => { + console.error('Unhandled rejection:', reason); +}); +``` + +For renderer errors, the contextBridge API should surface errors as rejected promises from `ipcRenderer.invoke()`. diff --git a/skills/create-agent-desktop-app/references/electron-preload.md b/skills/create-agent-desktop-app/references/electron-preload.md new file mode 100644 index 0000000..566fba1 --- /dev/null +++ b/skills/create-agent-desktop-app/references/electron-preload.md @@ -0,0 +1,141 @@ +# Electron Preload + Typed IPC + +The preload script runs before the renderer process loads. It uses `contextBridge` to expose a controlled, typed API to the renderer — no `nodeIntegration`, no direct access to Node or Electron APIs. + +## Why contextBridge + +With `contextIsolation: true`, the renderer cannot access Node or Electron APIs directly. The preload script is the **only** way to expose main-process functionality, and it runs in an isolated world so a compromised renderer cannot escape. + +## Two-file pattern + +Keep the **types** in a `.d.ts` file and the **runtime** in a `.ts` file. This prevents the renderer project from trying to compile preload/main source files: + +- `src/preload/api.d.ts` — types only (`Api`, `AgentEvent`, `Conversation`, etc.) with `declare global { interface Window { api: Api } }` +- `src/preload/index.ts` — runtime; imports types from `./api.js` (TS resolves the `.js` extension to the `.d.ts`) + +The renderer imports types from `../../preload/api.js`. The renderer never sees `preload/index.ts`, so its tsconfig can stay isolated from main/preload source. + +## src/preload/api.d.ts + +```typescript +export type Conversation = { + id: string; + title: string; + model: string; + created_at: string; + updated_at: string; +}; + +export type StoredMessage = { + role: 'user' | 'assistant' | 'system' | 'tool'; + content: string; + tool_calls: string | null; + created_at: string; +}; + +export type AgentEvent = + | { type: 'text'; delta: string } + | { type: 'tool_call'; name: string; callId: string; args: Record } + | { type: 'tool_result'; name: string; callId: string; output: string } + | { type: 'done'; usage?: { inputTokens?: number; outputTokens?: number; totalCost?: number } } + | { type: 'error'; message: string }; + +export type Api = { + getConfig: () => Promise; + listConversations: () => Promise; + createConversation: (model: string) => Promise; + deleteConversation: (id: string) => Promise; + renameConversation: (id: string, title: string) => Promise; + getMessages: (convId: string) => Promise; + sendMessage: (convId: string, text: string) => Promise<{ streamId: string }>; + abortStream: (streamId: string) => Promise; + onAgentEvent: (cb: (payload: { streamId: string; convId: string; event: AgentEvent }) => void) => () => void; + onThemeChanged: (cb: (isDark: boolean) => void) => () => void; +}; + +declare global { + interface Window { + api: Api; + } +} +``` + +## src/preload/index.ts + +```typescript +import { contextBridge, ipcRenderer } from 'electron'; +import type { Api, AgentEvent } from './api.js'; + +const api: Api = { + getConfig: () => ipcRenderer.invoke('config:get'), + listConversations: () => ipcRenderer.invoke('conversations:list'), + createConversation: (model) => ipcRenderer.invoke('conversations:create', model), + deleteConversation: (id) => ipcRenderer.invoke('conversations:delete', id), + renameConversation: (id, title) => ipcRenderer.invoke('conversations:rename', id, title), + getMessages: (convId) => ipcRenderer.invoke('messages:list', convId), + sendMessage: (convId, text) => ipcRenderer.invoke('agent:send', convId, text), + abortStream: (streamId) => ipcRenderer.invoke('agent:abort', streamId), + + onAgentEvent: (cb) => { + const listener = ( + _: Electron.IpcRendererEvent, + payload: { streamId: string; convId: string; event: AgentEvent }, + ) => cb(payload); + ipcRenderer.on('agent:event', listener); + return () => ipcRenderer.removeListener('agent:event', listener); + }, + + onThemeChanged: (cb) => { + const listener = (_: Electron.IpcRendererEvent, isDark: boolean) => cb(isDark); + ipcRenderer.on('theme:changed', listener); + return () => ipcRenderer.removeListener('theme:changed', listener); + }, +}; + +contextBridge.exposeInMainWorld('api', api); +``` + +## Usage in the Renderer + +```tsx +import type { Conversation } from '../../preload/api.js'; + +const conversations: Conversation[] = await window.api.listConversations(); +await window.api.sendMessage(convId, 'Hello'); + +const unsubscribe = window.api.onAgentEvent(({ event }) => { + if (event.type === 'text') appendText(event.delta); +}); +// call unsubscribe() on unmount +``` + +## tsconfig setup + +**tsconfig.web.json** (renderer): +```json +{ + "include": ["src/renderer/**/*.ts", "src/renderer/**/*.tsx", "src/preload/api.d.ts"] +} +``` + +**tsconfig.node.json** (main + preload): +```json +{ + "include": ["src/main/**/*.ts", "src/preload/**/*.ts", "src/preload/api.d.ts", "electron.vite.config.ts"] +} +``` + +Both projects include `api.d.ts`. Only the node project includes preload's source. + +## Why not use `ipcRenderer.send` / `ipcRenderer.on` directly? + +- `invoke`/`handle` is a request-response pattern with promise semantics. Errors thrown in handlers become rejected promises on the renderer — no manual error-channel plumbing. +- `send`/`on` is fire-and-forget. Use it only for one-way streams (e.g. `agent:event` during streaming). + +## Pitfalls + +- **Never** expose `ipcRenderer` directly via `contextBridge`. That would give the renderer unrestricted IPC access, defeating isolation. +- The API object must only contain **functions and serializable values**. You cannot expose classes or objects with non-enumerable methods. +- Event payloads must be **cloneable** (structured clone algorithm). That means no functions, no circular refs. +- Always return an unsubscribe function from `on*` methods so components can clean up. +- When the main process imports `AgentEvent` in `agent.ts`, it also imports from `../preload/api.js` — the `.d.ts` file is the single source of truth for the cross-process type surface. diff --git a/skills/create-agent-desktop-app/references/mcp-integration.md b/skills/create-agent-desktop-app/references/mcp-integration.md new file mode 100644 index 0000000..8c6aed7 --- /dev/null +++ b/skills/create-agent-desktop-app/references/mcp-integration.md @@ -0,0 +1,193 @@ +# MCP Client Integration + +Connect to external Model Context Protocol servers and expose their tools to the agent. + +## Install + +```bash +npm install @modelcontextprotocol/sdk +``` + +## src/main/mcp.ts + +```typescript +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { tool } from '@openrouter/agent/tool'; +import { z } from 'zod'; +import type { Tool as AgentTool } from '@openrouter/agent'; + +export type McpServerConfig = { + name: string; + command: string; + args?: string[]; + env?: Record; +}; + +const clients = new Map(); + +export async function connectMcpServer(config: McpServerConfig): Promise { + const client = new Client({ name: config.name, version: '1.0.0' }, { capabilities: {} }); + const transport = new StdioClientTransport({ + command: config.command, + args: config.args ?? [], + env: { ...process.env, ...config.env } as Record, + }); + + await client.connect(transport); + clients.set(config.name, client); + + const { tools: mcpTools } = await client.listTools(); + + return mcpTools.map((t) => { + const schema = jsonSchemaToZod(t.inputSchema); + return tool({ + name: `${config.name}__${t.name}`, + description: t.description ?? `${t.name} from ${config.name}`, + inputSchema: schema, + execute: async (input) => { + try { + const result = await client.callTool({ name: t.name, arguments: input }); + if (result.isError) return { error: textContent(result.content) }; + return { content: textContent(result.content) }; + } catch (err: any) { + return { error: err.message }; + } + }, + }); + }); +} + +export async function disconnectAll() { + for (const client of clients.values()) await client.close().catch(() => {}); + clients.clear(); +} + +function textContent(content: unknown): string { + if (!Array.isArray(content)) return String(content); + return content + .map((c: any) => (c.type === 'text' ? c.text : JSON.stringify(c))) + .join('\n'); +} + +function jsonSchemaToZod(schema: any): z.ZodTypeAny { + if (!schema || schema.type !== 'object') return z.object({}); + const shape: Record = {}; + for (const [key, prop] of Object.entries(schema.properties ?? {}) as [string, any][]) { + let field: z.ZodTypeAny; + switch (prop.type) { + case 'string': field = z.string(); break; + case 'number': + case 'integer': field = z.number(); break; + case 'boolean': field = z.boolean(); break; + case 'array': field = z.array(z.any()); break; + default: field = z.any(); + } + if (prop.description) field = field.describe(prop.description); + if (!schema.required?.includes(key)) field = field.optional(); + shape[key] = field; + } + return z.object(shape); +} +``` + +## User Configuration + +Let users add MCP servers via a config file, e.g. `mcp.json` in the app data dir: + +```json +{ + "servers": [ + { + "name": "filesystem", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/Documents"] + }, + { + "name": "github", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { "GITHUB_TOKEN": "ghp_..." } + } + ] +} +``` + +Load in `main/index.ts`: + +```typescript +import { connectMcpServer } from './mcp.js'; +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; + +async function loadMcpTools() { + const configPath = join(app.getPath('userData'), 'mcp.json'); + if (!existsSync(configPath)) return []; + const cfg = JSON.parse(readFileSync(configPath, 'utf-8')) as { servers: McpServerConfig[] }; + const allTools = await Promise.all(cfg.servers.map(connectMcpServer)); + return allTools.flat(); +} + +app.whenReady().then(async () => { + // ... + const mcpTools = await loadMcpTools(); + // Pass mcpTools to the agent runner along with built-in tools +}); +``` + +## Passing MCP Tools to the Agent + +The simplest pattern: extend the tool list dynamically. + +```typescript +// In agent.ts, accept tools as a parameter: +export async function runAgent(config, input, tools, onEvent, signal) { + const result = client.callModel({ /* ... */, tools }); + // ... +} +``` + +Then in `ipc-handlers.ts`: + +```typescript +import { tools as builtInTools } from './tools/index.js'; + +let mcpToolList: any[] = []; + +export function setMcpTools(t: any[]) { mcpToolList = t; } + +// In agent:send handler: +runAgent(config, messages, [...builtInTools, ...mcpToolList], onEvent, controller.signal); +``` + +Call `setMcpTools(await loadMcpTools())` after connecting. + +## Settings UI + +Expose a settings pane where the user can add/remove MCP servers, and reload the tool list without restarting: + +```typescript +ipcMain.handle('mcp:reload', async () => { + await disconnectAll(); + const tools = await loadMcpTools(); + setMcpTools(tools); + return tools.length; +}); +``` + +The renderer calls `window.api.reloadMcp()` after saving the config file. + +## Cleanup + +Disconnect on app quit: + +```typescript +app.on('before-quit', () => disconnectAll()); +``` + +## Pitfalls + +- **Stdio transports spawn child processes.** They inherit your env unless you replace it; keep `PATH` etc. by merging as above. +- **JSON Schema → Zod conversion is lossy.** Complex schemas (enums, nested objects, anyOf) need handcrafted mapping or a more sophisticated converter like `json-schema-to-zod`. +- **Tool name collisions.** Prefix MCP tools with the server name (`filesystem__read_file`) to avoid clashes with built-ins. +- **Server crashes.** Wrap the connect step in try/catch per server so one bad config doesn't block startup. diff --git a/skills/create-agent-desktop-app/references/modules.md b/skills/create-agent-desktop-app/references/modules.md new file mode 100644 index 0000000..baf819f --- /dev/null +++ b/skills/create-agent-desktop-app/references/modules.md @@ -0,0 +1,275 @@ +# App Modules + +Architectural components that live in the main process (alongside the agent runner). Each is optional — enable via the checklist. + +## Auto-Title Conversations (default on) + +After the user's first message, the main process makes a small extra call to `openrouter/auto` asking for a 3–6 word title. It rewrites the SQLite `conversations.title` column and pushes a `conversations:renamed` event to the renderer, which refreshes the sidebar. + +### src/main/title.ts + +```typescript +import { OpenRouter } from '@openrouter/agent'; +import { stepCountIs } from '@openrouter/agent/stop-conditions'; + +const INSTRUCTIONS = + 'Summarize the user\'s request in 3 to 6 words, Title Case, no quotes, no trailing punctuation. Return only the title, nothing else.'; + +export async function summarizeToTitle(firstUserMessage: string, apiKey: string): Promise { + try { + const client = new OpenRouter({ apiKey }); + const result = await client.callModel({ + model: 'openrouter/auto', + instructions: INSTRUCTIONS, + input: firstUserMessage.slice(0, 2000), + stopWhen: [stepCountIs(1)], + }).getResponse(); + const raw = (result.outputText ?? '').trim(); + const cleaned = raw.replace(/^["'`]|["'`]$/g, '').replace(/[.!?]+$/, '').trim(); + if (!cleaned) return null; + return cleaned.length > 60 ? cleaned.slice(0, 57) + '…' : cleaned; + } catch (err) { + console.warn('summarizeToTitle failed:', err); + return null; + } +} +``` + +Wire into `ipc-handlers.ts`'s `agent:send` handler. **Fire and forget** — don't `await` or the user's first message stalls while the summary runs: + +```typescript +const isFirstUserMessage = db.countMessages(convId) === 0; +db.addMessage(convId, 'user', userText); + +if (isFirstUserMessage && config.features.autoTitle && config.apiKey) { + summarizeToTitle(userText, config.apiKey).then((title) => { + if (!title) return; + db.renameConversation(convId, title); + getWindow()?.webContents.send('conversations:renamed', { id: convId, title }); + }); +} +``` + +The renderer's `useConversations` hook subscribes to `onConversationRenamed` and refreshes the sidebar. Total additional cost per conversation: one `openrouter/auto` call with a one-step stop condition — typically under a cent. + +Controlled by `config.features.autoTitle` — setting it to `false` reverts to "New Conversation" for every new chat. + +--- + +## Context Compaction + +Summarizes older messages when the conversation grows long, keeping the context window bounded. + +### src/main/compaction.ts + +```typescript +import { OpenRouter } from '@openrouter/agent'; +import type { ChatMessage } from './agent.js'; + +const DEFAULT_THRESHOLD_TOKENS = 32_000; +const KEEP_RECENT = 10; + +export async function maybeCompact( + client: OpenRouter, + messages: ChatMessage[], + usageTokens: number, + model: string, + threshold = DEFAULT_THRESHOLD_TOKENS, +): Promise { + if (usageTokens < threshold) return null; + if (messages.length <= KEEP_RECENT + 2) return null; + + const toSummarize = messages.slice(0, messages.length - KEEP_RECENT); + const recent = messages.slice(messages.length - KEEP_RECENT); + + const summary = await client.callModel({ + model, + instructions: 'Summarize the following conversation concisely, preserving key decisions, facts, and context needed to continue.', + input: toSummarize.map((m) => `${m.role}: ${m.content}`).join('\n\n'), + }).getResponse(); + + return [ + { role: 'system', content: `Earlier conversation summary:\n\n${summary.outputText}` }, + ...recent, + ]; +} +``` + +Call `maybeCompact` before each turn. If it returns a new array, store it and use it as the next input. + +## System Prompt Composition + +Assembles the system prompt from static instructions + dynamic context (project info, environment, custom rules files). + +### src/main/system-prompt.ts + +```typescript +import { readFile } from 'fs/promises'; +import { existsSync } from 'fs'; +import { resolve } from 'path'; +import os from 'os'; + +export async function buildSystemPrompt(basePrompt: string, opts: { + cwd?: string; + rulesFiles?: string[]; + includeEnvironment?: boolean; +} = {}): Promise { + const parts: string[] = [basePrompt]; + + if (opts.includeEnvironment) { + parts.push('', '## Environment', `- OS: ${process.platform} (${os.release()})`, `- CWD: ${opts.cwd ?? process.cwd()}`, `- Date: ${new Date().toISOString().slice(0, 10)}`); + } + + for (const file of opts.rulesFiles ?? []) { + const path = resolve(file); + if (!existsSync(path)) continue; + const content = await readFile(path, 'utf-8'); + parts.push('', `## From ${file}`, content); + } + + return parts.join('\n'); +} +``` + +Read `AGENT.md` or `.agent-rules` from the current project directory. The agent automatically picks up custom rules. + +## Tool Permissions / Approval + +Gate dangerous tools behind a confirmation dialog rendered in the UI. + +### src/main/approval.ts + +```typescript +import { BrowserWindow, ipcMain } from 'electron'; +import { nanoid } from 'nanoid'; + +const pending = new Map void>(); + +ipcMain.handle('approval:reply', (_, id: string, approved: boolean) => { + pending.get(id)?.(approved); + pending.delete(id); +}); + +const DANGEROUS = new Set(['shell', 'file_write', 'file_edit', 'open_path']); +const sessionApproved = new Set(); + +export async function requireApproval( + window: BrowserWindow | null, + toolName: string, + args: Record, +): Promise { + if (!DANGEROUS.has(toolName)) return true; + if (sessionApproved.has(toolName)) return true; + if (!window) return false; + + const id = nanoid(); + window.webContents.send('approval:request', { id, toolName, args }); + const approved = await new Promise((resolve) => pending.set(id, resolve)); + if (approved) sessionApproved.add(toolName); + return approved; +} +``` + +The renderer listens on `approval:request` and shows a modal dialog with "Allow once", "Allow for session", "Deny". + +Wrap tool execution in the agent runner: + +```typescript +// In agent.ts, wrap each tool's execute: +const originalExecute = shellTool.execute; +shellTool.execute = async (args, ctx) => { + const approved = await requireApproval(getWindow(), 'shell', args as any); + if (!approved) return { error: 'Denied by user' }; + return originalExecute(args, ctx); +}; +``` + +## Structured Event Logging + +Emits structured events (JSON) for tool calls, API requests, and errors. Useful for debugging and analytics. + +### src/main/logger.ts + +```typescript +import { app } from 'electron'; +import { createWriteStream, type WriteStream } from 'fs'; +import { join } from 'path'; + +let stream: WriteStream | null = null; + +function getStream() { + if (stream) return stream; + stream = createWriteStream(join(app.getPath('userData'), 'events.jsonl'), { flags: 'a' }); + return stream; +} + +export type AppEvent = + | { type: 'agent_start'; model: string; convId: string } + | { type: 'agent_end'; convId: string; tokens: number; cost: number } + | { type: 'tool_call'; name: string; args: Record; convId: string } + | { type: 'tool_result'; name: string; durationMs: number; convId: string; error?: string } + | { type: 'error'; message: string; stack?: string }; + +export function logEvent(event: AppEvent) { + const line = JSON.stringify({ ...event, ts: new Date().toISOString() }) + '\n'; + getStream().write(line); +} + +app.on('before-quit', () => stream?.end()); +``` + +Wire into the agent runner: + +```typescript +// In ipc-handlers.ts, inside agent:send handler +logEvent({ type: 'agent_start', model: config.model, convId }); +// ... stream events, emit tool_call / tool_result from the runAgent callback +``` + +## App Auto-Update + +Use `electron-updater` to ship updates via GitHub Releases. + +```bash +npm install electron-updater +``` + +### src/main/updater.ts + +```typescript +import { autoUpdater } from 'electron-updater'; +import { dialog } from 'electron'; + +export function setupAutoUpdater() { + autoUpdater.autoDownload = false; + + autoUpdater.on('update-available', async () => { + const { response } = await dialog.showMessageBox({ + type: 'info', + message: 'An update is available. Download now?', + buttons: ['Yes', 'Later'], + }); + if (response === 0) autoUpdater.downloadUpdate(); + }); + + autoUpdater.on('update-downloaded', async () => { + const { response } = await dialog.showMessageBox({ + type: 'info', + message: 'Update ready. Restart to apply?', + buttons: ['Restart', 'Later'], + }); + if (response === 0) autoUpdater.quitAndInstall(); + }); + + autoUpdater.checkForUpdates().catch((err) => console.warn('update check failed:', err)); +} +``` + +Call `setupAutoUpdater()` in `app.whenReady().then(...)` after `createWindow()`. Configure the publish provider in `electron-builder.yml`: + +```yaml +publish: + provider: github + owner: your-github-org + repo: your-repo +``` diff --git a/skills/create-agent-desktop-app/references/persistence.md b/skills/create-agent-desktop-app/references/persistence.md new file mode 100644 index 0000000..4c8ac63 --- /dev/null +++ b/skills/create-agent-desktop-app/references/persistence.md @@ -0,0 +1,259 @@ +# Chat Persistence + +Two options: **SQLite** (default, via `better-sqlite3`) and **JSONL** (lightweight alternative). Choose one — they're mutually exclusive. + +## SQLite (default) + +### Schema + +```sql +CREATE TABLE conversations ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL DEFAULT 'New Conversation', + model TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('user','assistant','system','tool')), + content TEXT NOT NULL, + tool_calls TEXT, -- JSON-encoded array of tool call objects + token_count INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_messages_conversation ON messages(conversation_id, created_at); +``` + +### Full persistence.ts + +```typescript +import Database from 'better-sqlite3'; +import { app } from 'electron'; +import { join } from 'path'; +import { nanoid } from 'nanoid'; + +let db: Database.Database | null = null; + +const SCHEMA = ` + CREATE TABLE IF NOT EXISTS conversations ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL DEFAULT 'New Conversation', + model TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('user','assistant','system','tool')), + content TEXT NOT NULL, + tool_calls TEXT, + token_count INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id, created_at); +`; + +export function initDb() { + if (db) return db; + const path = join(app.getPath('userData'), 'chat.db'); + db = new Database(path); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + db.exec(SCHEMA); + return db; +} + +export type Conversation = { + id: string; title: string; model: string; created_at: string; updated_at: string; +}; + +export type Message = { + role: 'user'|'assistant'|'system'|'tool'; + content: string; + tool_calls: string | null; + created_at: string; +}; + +export function listConversations(): Conversation[] { + return initDb().prepare('SELECT * FROM conversations ORDER BY updated_at DESC').all() as Conversation[]; +} + +export function createConversation(model: string, title?: string): string { + const id = nanoid(); + initDb() + .prepare('INSERT INTO conversations (id, title, model) VALUES (?, ?, ?)') + .run(id, title ?? 'New Conversation', model); + return id; +} + +export function renameConversation(id: string, title: string) { + initDb().prepare('UPDATE conversations SET title = ? WHERE id = ?').run(title, id); +} + +export function deleteConversation(id: string) { + initDb().prepare('DELETE FROM conversations WHERE id = ?').run(id); +} + +export function addMessage( + convId: string, + role: 'user'|'assistant'|'system'|'tool', + content: string, + toolCalls?: string | null, + tokenCount?: number, +) { + const d = initDb(); + const tx = d.transaction(() => { + d.prepare( + 'INSERT INTO messages (conversation_id, role, content, tool_calls, token_count) VALUES (?, ?, ?, ?, ?)' + ).run(convId, role, content, toolCalls ?? null, tokenCount ?? null); + d.prepare("UPDATE conversations SET updated_at = datetime('now') WHERE id = ?").run(convId); + }); + tx(); +} + +export function getMessages(convId: string): Message[] { + return initDb() + .prepare('SELECT role, content, tool_calls, created_at FROM messages WHERE conversation_id = ? ORDER BY created_at ASC') + .all(convId) as Message[]; +} + +export function searchConversations(query: string): Conversation[] { + return initDb() + .prepare(` + SELECT DISTINCT c.* FROM conversations c + LEFT JOIN messages m ON m.conversation_id = c.id + WHERE c.title LIKE ? OR m.content LIKE ? + ORDER BY c.updated_at DESC + LIMIT 50 + `) + .all(`%${query}%`, `%${query}%`) as Conversation[]; +} +``` + +### better-sqlite3 + Electron + +`better-sqlite3` is a native Node module. It must be compiled against Electron's Node version (not the system Node). Add `@electron/rebuild` as a postinstall step: + +```json +{ + "scripts": { + "postinstall": "electron-rebuild -f -w better-sqlite3" + } +} +``` + +If you see `NODE_MODULE_VERSION mismatch`, run `npm run postinstall` manually. + +### Migrations + +For future schema changes, keep a `schema_version` table: + +```sql +CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY); +INSERT OR IGNORE INTO schema_version (version) VALUES (1); +``` + +Then in `initDb`: + +```typescript +const version = (db.prepare('SELECT version FROM schema_version').get() as { version: number }).version; +if (version < 2) { + db.exec('ALTER TABLE messages ADD COLUMN ...'); + db.prepare('UPDATE schema_version SET version = 2').run(); +} +``` + +## JSONL (alternative) + +For simpler apps that don't need queries or full-text search: + +### src/main/session.ts + +```typescript +import { app } from 'electron'; +import { readdirSync, readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, unlinkSync, statSync } from 'fs'; +import { join } from 'path'; +import { nanoid } from 'nanoid'; + +function sessionsDir() { + const dir = join(app.getPath('userData'), 'sessions'); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + return dir; +} + +export type Conversation = { id: string; title: string; model: string; created_at: string; updated_at: string }; +export type Message = { role: 'user'|'assistant'|'system'|'tool'; content: string; tool_calls: string | null; created_at: string }; + +export function listConversations(): Conversation[] { + const files = readdirSync(sessionsDir()).filter((f) => f.endsWith('.jsonl')); + return files.map((f) => { + const path = join(sessionsDir(), f); + const first = readFileSync(path, 'utf-8').split('\n').find(Boolean); + const header = first ? JSON.parse(first) : {}; + return { + id: f.replace('.jsonl', ''), + title: header.title ?? 'New Conversation', + model: header.model ?? 'unknown', + created_at: header.created_at ?? new Date(statSync(path).birthtime).toISOString(), + updated_at: new Date(statSync(path).mtime).toISOString(), + }; + }).sort((a, b) => b.updated_at.localeCompare(a.updated_at)); +} + +export function createConversation(model: string): string { + const id = nanoid(); + const now = new Date().toISOString(); + writeFileSync( + join(sessionsDir(), `${id}.jsonl`), + JSON.stringify({ type: 'header', title: 'New Conversation', model, created_at: now }) + '\n', + ); + return id; +} + +export function deleteConversation(id: string) { + const path = join(sessionsDir(), `${id}.jsonl`); + if (existsSync(path)) unlinkSync(path); +} + +export function addMessage(convId: string, role: Message['role'], content: string, toolCalls?: string | null) { + appendFileSync( + join(sessionsDir(), `${convId}.jsonl`), + JSON.stringify({ type: 'message', role, content, tool_calls: toolCalls ?? null, created_at: new Date().toISOString() }) + '\n', + ); +} + +export function getMessages(convId: string): Message[] { + const path = join(sessionsDir(), `${convId}.jsonl`); + if (!existsSync(path)) return []; + return readFileSync(path, 'utf-8') + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + .filter((r) => r.type === 'message') + .map((r) => ({ role: r.role, content: r.content, tool_calls: r.tool_calls, created_at: r.created_at })); +} +``` + +## Where the data lives + +`app.getPath('userData')` returns the standard per-user data directory: +- macOS: `~/Library/Application Support//` +- Windows: `%APPDATA%\\` +- Linux: `~/.config//` + +Everything stays local. No cloud sync, no telemetry. + +## When to choose which + +| | SQLite | JSONL | +|---|---|---| +| Pros | Fast queries, full-text search, ACID transactions | Zero deps, human-readable, no native rebuild | +| Cons | Native module to rebuild per Electron version | No queries, slow full-text search, rewriting = rewriting | +| Use when | You want search, filters, multiple indexes | You just need to persist + replay | + +Default to SQLite unless you have a specific reason to avoid native modules (e.g. minimal bundle size). diff --git a/skills/create-agent-desktop-app/references/react-renderer.md b/skills/create-agent-desktop-app/references/react-renderer.md new file mode 100644 index 0000000..414b87c --- /dev/null +++ b/skills/create-agent-desktop-app/references/react-renderer.md @@ -0,0 +1,401 @@ +# React Renderer + +The renderer process is a standard React 19 + TypeScript app, loaded inside Electron's BrowserWindow. It never touches Node or Electron APIs directly — all such access goes through `window.api` (provided by the preload script). + +## Model Picker + +A searchable model selector lives to the right of the chat input. It fetches the list of OpenRouter models that support tool calling, caches them in memory for the session, and filters client-side by substring. + +```tsx +// src/renderer/components/ModelPicker.tsx (excerpt) +let cache: Model[] | null = null; +let inflight: Promise | null = null; + +async function fetchModels(): Promise { + if (cache) return cache; + if (inflight) return inflight; + inflight = fetch('https://openrouter.ai/api/v1/models?supported_parameters=tools') + .then((r) => r.json()) + .then((j: { data: Model[] }) => (cache = j.data ?? [])) + .catch(() => []) + .finally(() => { inflight = null; }); + return inflight; +} +``` + +The picker renders as a small pill button showing the current model's short name (`openrouter/auto` → `auto`). Clicking opens a floating panel with a search input and a scrollable list. `openrouter/auto` is pinned at the top as a featured entry regardless of search. + +### Per-conversation model + +Each conversation has its own `model` column in SQLite. `ChatView` pulls it on mount, owns it as local state, and writes through on change: + +```tsx +const handleModelChange = async (model: string) => { + setCurrentModel(model); + if (activeConvId) await window.api.setConversationModel(activeConvId, model); +}; +``` + +The main process reads `conversation.model` for each turn (`ipc-handlers.ts` `agent:send`) — if the user switches mid-conversation, the next turn uses the new model without affecting stored messages. + +### CSP + +The renderer's default Content-Security-Policy in `index.html` already allows `connect-src 'self' https:`, which covers the OpenRouter models endpoint. No preload plumbing needed — the `fetch` happens in the renderer. + +### Feature flag + +Controlled by `config.features.modelPicker`. When disabled, `InputBar` hides the picker entirely, and every conversation runs with whatever model was last set (initially `openrouter/auto`). + +--- + +## Component Architecture + +``` +App.tsx Root layout, theme application +├── Sidebar.tsx Collapsible conversation list +│ └── ConversationItem.tsx List entry with rename/delete +├── ChatView.tsx Main chat area +│ ├── MessageList.tsx Scrollable message list +│ │ └── MessageBubble.tsx One user/assistant message +│ │ └── ToolCallCard.tsx Collapsible tool display +│ ├── LoadingIndicator.tsx Streaming/thinking indicator +│ └── InputBar.tsx Chat input + submit +├── ModelSelector.tsx Searchable model dropdown modal +├── SettingsPanel.tsx Settings drawer/modal +└── WelcomeScreen.tsx Empty state for first launch +``` + +## Zustand Stores + +### src/renderer/stores/chat.ts + +```typescript +import { create } from 'zustand'; +import type { StoredMessage } from '../../preload/api.js'; + +export type UIMessage = { + role: 'user' | 'assistant' | 'system' | 'tool'; + content: string; + toolCalls?: Array<{ callId: string; name: string; args: Record; output?: string }>; + streaming?: boolean; + created_at?: string; +}; + +type ChatState = { + activeConvId: string | null; + messages: UIMessage[]; + streamingId: string | null; + + setActive: (id: string | null) => void; + loadMessages: (messages: StoredMessage[]) => void; + appendUser: (text: string) => void; + startAssistant: () => void; + appendAssistantText: (delta: string) => void; + addToolCall: (callId: string, name: string, args: Record) => void; + addToolResult: (callId: string, output: string) => void; + finishAssistant: () => void; + setStreaming: (id: string | null) => void; +}; + +export const useChatStore = create((set) => ({ + activeConvId: null, + messages: [], + streamingId: null, + + setActive: (id) => set({ activeConvId: id, messages: [], streamingId: null }), + + loadMessages: (stored) => + set({ + messages: stored.map((m) => ({ + role: m.role, + content: m.content, + toolCalls: m.tool_calls ? JSON.parse(m.tool_calls) : undefined, + created_at: m.created_at, + })), + }), + + appendUser: (text) => + set((s) => ({ messages: [...s.messages, { role: 'user', content: text }] })), + + startAssistant: () => + set((s) => ({ + messages: [...s.messages, { role: 'assistant', content: '', streaming: true, toolCalls: [] }], + })), + + appendAssistantText: (delta) => + set((s) => { + const msgs = [...s.messages]; + const last = msgs[msgs.length - 1]; + if (last?.role === 'assistant') msgs[msgs.length - 1] = { ...last, content: last.content + delta }; + return { messages: msgs }; + }), + + addToolCall: (callId, name, args) => + set((s) => { + const msgs = [...s.messages]; + const last = msgs[msgs.length - 1]; + if (last?.role === 'assistant') { + msgs[msgs.length - 1] = { + ...last, + toolCalls: [...(last.toolCalls ?? []), { callId, name, args }], + }; + } + return { messages: msgs }; + }), + + addToolResult: (callId, output) => + set((s) => { + const msgs = [...s.messages]; + const last = msgs[msgs.length - 1]; + if (last?.role === 'assistant') { + msgs[msgs.length - 1] = { + ...last, + toolCalls: (last.toolCalls ?? []).map((t) => (t.callId === callId ? { ...t, output } : t)), + }; + } + return { messages: msgs }; + }), + + finishAssistant: () => + set((s) => { + const msgs = [...s.messages]; + const last = msgs[msgs.length - 1]; + if (last?.role === 'assistant') msgs[msgs.length - 1] = { ...last, streaming: false }; + return { messages: msgs, streamingId: null }; + }), + + setStreaming: (id) => set({ streamingId: id }), +})); +``` + +### src/renderer/stores/app.ts + +```typescript +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +type AppState = { + theme: 'system' | 'dark' | 'light'; + sidebarOpen: boolean; + setTheme: (t: 'system' | 'dark' | 'light') => void; + toggleSidebar: () => void; +}; + +export const useAppStore = create()( + persist( + (set) => ({ + theme: 'system', + sidebarOpen: true, + setTheme: (t) => set({ theme: t }), + toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })), + }), + { name: 'app-store' }, + ), +); +``` + +## Hooks + +### src/renderer/hooks/useAgent.ts + +Wires up the IPC event stream to the chat store: + +```typescript +import { useEffect } from 'react'; +import { useChatStore } from '../stores/chat.ts'; + +export function useAgentEvents() { + useEffect(() => { + const unsubscribe = window.api.onAgentEvent(({ event }) => { + const store = useChatStore.getState(); + switch (event.type) { + case 'text': + store.appendAssistantText(event.delta); + break; + case 'tool_call': + store.addToolCall(event.callId, event.name, event.args); + break; + case 'tool_result': + store.addToolResult(event.callId, event.output); + break; + case 'done': + case 'error': + store.finishAssistant(); + break; + } + }); + return unsubscribe; + }, []); +} + +export async function sendMessage(convId: string, text: string) { + const store = useChatStore.getState(); + store.appendUser(text); + store.startAssistant(); + const { streamId } = await window.api.sendMessage(convId, text); + store.setStreaming(streamId); +} +``` + +### src/renderer/hooks/useConversations.ts + +```typescript +import { useEffect, useState, useCallback } from 'react'; +import type { Conversation } from '../../preload/api.js'; + +export function useConversations() { + const [conversations, setConversations] = useState([]); + + const refresh = useCallback(async () => { + setConversations(await window.api.listConversations()); + }, []); + + useEffect(() => { refresh(); }, [refresh]); + + const create = useCallback(async (model: string) => { + const id = await window.api.createConversation(model); + await refresh(); + return id; + }, [refresh]); + + const remove = useCallback(async (id: string) => { + await window.api.deleteConversation(id); + await refresh(); + }, [refresh]); + + return { conversations, refresh, create, remove }; +} +``` + +## Key Components + +### ChatView.tsx + +```tsx +import { useEffect } from 'react'; +import { useChatStore } from '../stores/chat.ts'; +import { useAgentEvents, sendMessage } from '../hooks/useAgent.ts'; +import { MessageList } from './MessageList.tsx'; +import { InputBar } from './InputBar.tsx'; +import { WelcomeScreen } from './WelcomeScreen.tsx'; + +export function ChatView() { + const activeConvId = useChatStore((s) => s.activeConvId); + const loadMessages = useChatStore((s) => s.loadMessages); + + useAgentEvents(); + + useEffect(() => { + if (!activeConvId) return; + window.api.getMessages(activeConvId).then(loadMessages); + }, [activeConvId, loadMessages]); + + if (!activeConvId) return ; + + return ( +
+ + sendMessage(activeConvId, text)} /> +
+ ); +} +``` + +### InputBar.tsx + +```tsx +import { useState, useRef, type KeyboardEvent } from 'react'; + +export function InputBar({ onSubmit }: { onSubmit: (text: string) => void }) { + const [text, setText] = useState(''); + const ref = useRef(null); + + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + const t = text.trim(); + if (t) { onSubmit(t); setText(''); } + } + }; + + return ( +
+