v0.5.0: NomadArch - Binary-Free Mode Release
Some checks failed
Release Binaries / release (push) Has been cancelled
Some checks failed
Release Binaries / release (push) Has been cancelled
Features: - Binary-Free Mode: No OpenCode binary required - NomadArch Native mode with free Zen models - Native session management - Provider routing (Zen, Qwen, Z.AI) - Fixed MCP connection with explicit connectAll() - Updated installers and launchers for all platforms - UI binary selector with Native option Free Models Available: - GPT-5 Nano (400K context) - Grok Code Fast 1 (256K context) - GLM-4.7 (205K context) - Doubao Seed Code (256K context) - Big Pickle (200K context)
This commit is contained in:
642
dev-docs/technical-implementation.md
Normal file
642
dev-docs/technical-implementation.md
Normal file
@@ -0,0 +1,642 @@
|
||||
# Technical Implementation Details
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Core Technologies
|
||||
|
||||
- **Electron** v28+ - Desktop application wrapper
|
||||
- **SolidJS** v1.8+ - Reactive UI framework
|
||||
- **TypeScript** v5.3+ - Type-safe development
|
||||
- **Vite** v5+ - Fast build tool and dev server
|
||||
|
||||
### UI & Styling
|
||||
|
||||
- **TailwindCSS** v4+ - Utility-first styling
|
||||
- **Kobalte** - Accessible UI primitives for SolidJS
|
||||
- **Shiki** - Syntax highlighting for code blocks
|
||||
- **Marked** - Markdown parsing
|
||||
- **Lucide** - Icon library
|
||||
|
||||
### Communication
|
||||
|
||||
- **OpenCode SDK** (@opencode-ai/sdk) - API client
|
||||
- **EventSource API** - Server-sent events
|
||||
- **Node Child Process** - Process management
|
||||
|
||||
### Development Tools
|
||||
|
||||
- **electron-vite** - Electron + Vite integration
|
||||
- **electron-builder** - Application packaging
|
||||
- **ESLint** - Code linting
|
||||
- **Prettier** - Code formatting
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
packages/opencode-client/
|
||||
├── electron/
|
||||
│ ├── main/
|
||||
│ │ ├── main.ts # Electron main entry
|
||||
│ │ ├── window.ts # Window management
|
||||
│ │ ├── process-manager.ts # OpenCode server spawning
|
||||
│ │ ├── ipc.ts # IPC handlers
|
||||
│ │ └── menu.ts # Application menu
|
||||
│ ├── preload/
|
||||
│ │ └── index.ts # Preload script (IPC bridge)
|
||||
│ └── resources/
|
||||
│ └── icon.png # Application icon
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── instance-tabs.tsx # Level 1 tabs
|
||||
│ │ ├── session-tabs.tsx # Level 2 tabs
|
||||
│ │ ├── message-stream-v2.tsx # Messages display (normalized store)
|
||||
│ │ ├── message-item.tsx # Single message
|
||||
│ │ ├── tool-call.tsx # Tool execution display
|
||||
│ │ ├── prompt-input.tsx # Input with attachments
|
||||
│ │ ├── agent-selector.tsx # Agent dropdown
|
||||
│ │ ├── model-selector.tsx # Model dropdown
|
||||
│ │ ├── session-picker.tsx # Startup modal
|
||||
│ │ ├── logs-view.tsx # Server logs
|
||||
│ │ └── empty-state.tsx # No instances view
|
||||
│ ├── stores/
|
||||
│ │ ├── instances.ts # Instance state
|
||||
│ │ ├── sessions.ts # Session state per instance
|
||||
│ │ └── ui.ts # UI state (active tabs, etc)
|
||||
│ ├── lib/
|
||||
│ │ ├── sdk-manager.ts # SDK client management
|
||||
│ │ ├── sse-manager.ts # SSE connection handling
|
||||
│ │ ├── port-finder.ts # Find available ports
|
||||
│ │ └── markdown.ts # Markdown rendering utils
|
||||
│ ├── hooks/
|
||||
│ │ ├── use-instance.ts # Instance operations
|
||||
│ │ ├── use-session.ts # Session operations
|
||||
│ │ └── use-messages.ts # Message operations
|
||||
│ ├── types/
|
||||
│ │ ├── instance.ts # Instance types
|
||||
│ │ ├── session.ts # Session types
|
||||
│ │ └── message.ts # Message types
|
||||
│ ├── App.tsx # Root component
|
||||
│ ├── main.tsx # Renderer entry
|
||||
│ └── index.css # Global styles
|
||||
├── docs/ # Documentation
|
||||
├── tasks/ # Task tracking
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── electron.vite.config.ts
|
||||
├── tailwind.config.js
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### Instance Store
|
||||
|
||||
```typescript
|
||||
interface InstanceState {
|
||||
instances: Map<string, Instance>
|
||||
activeInstanceId: string | null
|
||||
|
||||
// Actions
|
||||
createInstance(folder: string): Promise<void>
|
||||
removeInstance(id: string): Promise<void>
|
||||
setActiveInstance(id: string): void
|
||||
}
|
||||
|
||||
interface Instance {
|
||||
id: string // UUID
|
||||
folder: string // Absolute path
|
||||
port: number // Server port
|
||||
pid: number // Process ID
|
||||
status: InstanceStatus
|
||||
client: OpenCodeClient // SDK client
|
||||
eventSource: EventSource | null // SSE connection
|
||||
sessions: Map<string, Session>
|
||||
activeSessionId: string | null
|
||||
logs: LogEntry[]
|
||||
}
|
||||
|
||||
type InstanceStatus =
|
||||
| "starting" // Server spawning
|
||||
| "ready" // Server connected
|
||||
| "error" // Failed to start
|
||||
| "stopped" // Server killed
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: number
|
||||
level: "info" | "error" | "warn"
|
||||
message: string
|
||||
}
|
||||
```
|
||||
|
||||
### Session Store
|
||||
|
||||
```typescript
|
||||
interface SessionState {
|
||||
// Per instance
|
||||
getSessions(instanceId: string): Session[]
|
||||
getActiveSession(instanceId: string): Session | null
|
||||
|
||||
// Actions
|
||||
createSession(instanceId: string, agent: string): Promise<Session>
|
||||
deleteSession(instanceId: string, sessionId: string): Promise<void>
|
||||
setActiveSession(instanceId: string, sessionId: string): void
|
||||
updateSession(instanceId: string, sessionId: string, updates: Partial<Session>): void
|
||||
}
|
||||
|
||||
interface Session {
|
||||
id: string
|
||||
instanceId: string
|
||||
title: string
|
||||
parentId: string | null
|
||||
agent: string
|
||||
model: {
|
||||
providerId: string
|
||||
modelId: string
|
||||
}
|
||||
version: string
|
||||
time: { created: number; updated: number }
|
||||
revert?: {
|
||||
messageID?: string
|
||||
partID?: string
|
||||
snapshot?: string
|
||||
diff?: string
|
||||
}
|
||||
}
|
||||
|
||||
// Message content lives in the normalized message-v2 store
|
||||
// keyed by instanceId/sessionId/messageId
|
||||
|
||||
type SessionStatus =
|
||||
| "idle" // No activity
|
||||
| "streaming" // Assistant responding
|
||||
| "error" // Error occurred
|
||||
|
||||
```
|
||||
|
||||
### UI Store
|
||||
|
||||
```typescript
|
||||
interface UIState {
|
||||
// Tab state
|
||||
instanceTabOrder: string[]
|
||||
sessionTabOrder: Map<string, string[]> // instanceId -> sessionIds
|
||||
|
||||
// Modal state
|
||||
showSessionPicker: string | null // instanceId or null
|
||||
showSettings: boolean
|
||||
|
||||
// Actions
|
||||
reorderInstanceTabs(newOrder: string[]): void
|
||||
reorderSessionTabs(instanceId: string, newOrder: string[]): void
|
||||
openSessionPicker(instanceId: string): void
|
||||
closeSessionPicker(): void
|
||||
}
|
||||
```
|
||||
|
||||
## Process Management
|
||||
|
||||
### Server Spawning
|
||||
|
||||
**Strategy:** Spawn with port 0 (random), parse stdout for actual port
|
||||
|
||||
```typescript
|
||||
interface ProcessManager {
|
||||
spawn(folder: string): Promise<ProcessInfo>
|
||||
kill(pid: number): Promise<void>
|
||||
restart(pid: number, folder: string): Promise<ProcessInfo>
|
||||
}
|
||||
|
||||
interface ProcessInfo {
|
||||
pid: number
|
||||
port: number
|
||||
stdout: Readable
|
||||
stderr: Readable
|
||||
}
|
||||
|
||||
// Implementation approach:
|
||||
// 1. Check if opencode binary exists
|
||||
// 2. Spawn: spawn('opencode', ['serve', '--port', '0'], { cwd: folder })
|
||||
// 3. Listen to stdout
|
||||
// 4. Parse line matching: "Server listening on port 4096"
|
||||
// 5. Resolve promise with port
|
||||
// 6. Timeout after 10 seconds
|
||||
```
|
||||
|
||||
### Port Parsing
|
||||
|
||||
```typescript
|
||||
// Expected output from opencode serve:
|
||||
// > Starting OpenCode server...
|
||||
// > Server listening on port 4096
|
||||
// > API available at http://localhost:4096
|
||||
|
||||
function parsePort(output: string): number | null {
|
||||
const match = output.match(/port (\d+)/)
|
||||
return match ? parseInt(match[1], 10) : null
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
**Server fails to start:**
|
||||
|
||||
- Parse stderr for error message
|
||||
- Display in instance tab with retry button
|
||||
- Common errors: Port in use, permission denied, binary not found
|
||||
|
||||
**Server crashes after start:**
|
||||
|
||||
- Detect via process 'exit' event
|
||||
- Attempt auto-restart once
|
||||
- If restart fails, show error state
|
||||
- Preserve session data for manual restart
|
||||
|
||||
## Communication Layer
|
||||
|
||||
### SDK Client Management
|
||||
|
||||
```typescript
|
||||
interface SDKManager {
|
||||
createClient(port: number): OpenCodeClient
|
||||
destroyClient(port: number): void
|
||||
getClient(port: number): OpenCodeClient | null
|
||||
}
|
||||
|
||||
// One client per instance
|
||||
// Client lifecycle tied to instance lifecycle
|
||||
```
|
||||
|
||||
### SSE Event Handling
|
||||
|
||||
```typescript
|
||||
interface SSEManager {
|
||||
connect(instanceId: string, port: number): void
|
||||
disconnect(instanceId: string): void
|
||||
|
||||
// Event routing
|
||||
onMessageUpdate(handler: (instanceId: string, event: MessageUpdateEvent) => void): void
|
||||
onSessionUpdate(handler: (instanceId: string, event: SessionUpdateEvent) => void): void
|
||||
onError(handler: (instanceId: string, error: Error) => void): void
|
||||
}
|
||||
|
||||
// Event flow:
|
||||
// 1. EventSource connects to /event endpoint
|
||||
// 2. Events arrive as JSON
|
||||
// 3. Route to correct instance store
|
||||
// 4. Update reactive state
|
||||
// 5. UI auto-updates via signals
|
||||
```
|
||||
|
||||
### Reconnection Logic
|
||||
|
||||
```typescript
|
||||
// SSE disconnects:
|
||||
// - Network issue
|
||||
// - Server restart
|
||||
// - Tab sleep (browser optimization)
|
||||
|
||||
class SSEConnection {
|
||||
private reconnectAttempts = 0
|
||||
private maxReconnectAttempts = 5
|
||||
private reconnectDelay = 1000 // Start with 1s
|
||||
|
||||
reconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
this.emitError(new Error("Max reconnection attempts reached"))
|
||||
return
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.connect()
|
||||
this.reconnectAttempts++
|
||||
this.reconnectDelay *= 2 // Exponential backoff
|
||||
}, this.reconnectDelay)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Message Rendering
|
||||
|
||||
### Markdown Processing
|
||||
|
||||
```typescript
|
||||
// Use Marked + Shiki for syntax highlighting
|
||||
import { marked } from "marked"
|
||||
import { markedHighlight } from "marked-highlight"
|
||||
import { getHighlighter } from "shiki"
|
||||
|
||||
const highlighter = await getHighlighter({
|
||||
themes: ["github-dark", "github-light"],
|
||||
langs: ["typescript", "javascript", "python", "bash", "json"],
|
||||
})
|
||||
|
||||
marked.use(
|
||||
markedHighlight({
|
||||
highlight(code, lang) {
|
||||
return highlighter.codeToHtml(code, {
|
||||
lang,
|
||||
theme: isDark ? "github-dark" : "github-light",
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
### Tool Call Rendering
|
||||
|
||||
```typescript
|
||||
interface ToolCallComponent {
|
||||
tool: string // "bash", "edit", "read"
|
||||
input: any // Tool-specific input
|
||||
output?: any // Tool-specific output
|
||||
status: "pending" | "running" | "success" | "error"
|
||||
expanded: boolean // Collapse state
|
||||
}
|
||||
|
||||
// Render logic:
|
||||
// - Default: Collapsed, show summary
|
||||
// - Click: Toggle expanded state
|
||||
// - Running: Show spinner
|
||||
// - Complete: Show checkmark
|
||||
// - Error: Show error icon + message
|
||||
```
|
||||
|
||||
### Streaming Updates
|
||||
|
||||
```typescript
|
||||
// Messages stream in via SSE
|
||||
// Update strategy: Replace existing message parts
|
||||
|
||||
function handleMessagePartUpdate(event: MessagePartEvent) {
|
||||
const session = getSession(event.sessionId)
|
||||
const message = session.messages.find((m) => m.id === event.messageId)
|
||||
|
||||
if (!message) {
|
||||
// New message
|
||||
session.messages.push(createMessage(event))
|
||||
} else {
|
||||
// Update existing
|
||||
const partIndex = message.parts.findIndex((p) => p.id === event.partId)
|
||||
if (partIndex === -1) {
|
||||
message.parts.push(event.part)
|
||||
} else {
|
||||
message.parts[partIndex] = event.part
|
||||
}
|
||||
}
|
||||
|
||||
// SolidJS reactivity triggers re-render
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
**MVP Approach: Don't optimize prematurely**
|
||||
|
||||
### Message Rendering (MVP)
|
||||
|
||||
**Simple approach - no optimization:**
|
||||
|
||||
```typescript
|
||||
// Render all messages - no virtual scrolling, no limits
|
||||
<For each={messages()}>
|
||||
{(message) => <MessageItem message={message} />}
|
||||
</For>
|
||||
|
||||
// SolidJS will handle reactivity efficiently
|
||||
// Only optimize if users report issues
|
||||
```
|
||||
|
||||
### State Update Batching
|
||||
|
||||
**Not needed for MVP:**
|
||||
|
||||
- SolidJS reactivity is efficient enough
|
||||
- SSE updates will just trigger normal re-renders
|
||||
- Add batching only if performance issues arise
|
||||
|
||||
### Memory Management
|
||||
|
||||
**Not needed for MVP:**
|
||||
|
||||
- No message limits
|
||||
- No pruning
|
||||
- No lazy loading
|
||||
- Let users create as many messages as they want
|
||||
- Optimize later if problems occur
|
||||
|
||||
**When to add optimizations (post-MVP):**
|
||||
|
||||
- Users report slowness with large sessions
|
||||
- Measurable performance degradation
|
||||
- Memory usage becomes problematic
|
||||
- See Phase 8 tasks for virtual scrolling and optimization
|
||||
|
||||
## IPC Communication
|
||||
|
||||
### Main Process → Renderer
|
||||
|
||||
```typescript
|
||||
// Events sent from main to renderer
|
||||
type MainToRenderer = {
|
||||
"instance:started": { id: string; port: number; pid: number }
|
||||
"instance:error": { id: string; error: string }
|
||||
"instance:stopped": { id: string }
|
||||
"instance:log": { id: string; entry: LogEntry }
|
||||
}
|
||||
```
|
||||
|
||||
### Renderer → Main Process
|
||||
|
||||
```typescript
|
||||
// Commands sent from renderer to main
|
||||
type RendererToMain = {
|
||||
"folder:select": () => Promise<string | null>
|
||||
"instance:create": (folder: string) => Promise<{ port: number; pid: number }>
|
||||
"instance:stop": (pid: number) => Promise<void>
|
||||
"app:quit": () => void
|
||||
}
|
||||
```
|
||||
|
||||
### Preload Script (Bridge)
|
||||
|
||||
```typescript
|
||||
// Expose safe IPC methods to renderer
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
selectFolder: () => ipcRenderer.invoke("folder:select"),
|
||||
createInstance: (folder: string) => ipcRenderer.invoke("instance:create", folder),
|
||||
stopInstance: (pid: number) => ipcRenderer.invoke("instance:stop", pid),
|
||||
onInstanceStarted: (callback) => ipcRenderer.on("instance:started", callback),
|
||||
onInstanceError: (callback) => ipcRenderer.on("instance:error", callback),
|
||||
})
|
||||
```
|
||||
|
||||
## Error Handling Strategy
|
||||
|
||||
### Network Errors
|
||||
|
||||
```typescript
|
||||
// HTTP request fails
|
||||
try {
|
||||
const response = await client.session.list()
|
||||
} catch (error) {
|
||||
if (error.code === "ECONNREFUSED") {
|
||||
// Server not responding
|
||||
showError("Cannot connect to server. Is it running?")
|
||||
} else if (error.code === "ETIMEDOUT") {
|
||||
// Request timeout
|
||||
showError("Request timed out. Retry?", { retry: true })
|
||||
} else {
|
||||
// Unknown error
|
||||
showError(error.message)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### SSE Errors
|
||||
|
||||
```typescript
|
||||
eventSource.onerror = (error) => {
|
||||
// Connection lost
|
||||
if (eventSource.readyState === EventSource.CLOSED) {
|
||||
// Attempt reconnect
|
||||
reconnectSSE()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### User Input Errors
|
||||
|
||||
```typescript
|
||||
// Validate before sending
|
||||
function validatePrompt(text: string): string | null {
|
||||
if (!text.trim()) {
|
||||
return "Message cannot be empty"
|
||||
}
|
||||
if (text.length > 10000) {
|
||||
return "Message too long (max 10000 characters)"
|
||||
}
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
## Security Measures
|
||||
|
||||
### IPC Security
|
||||
|
||||
- Use `contextIsolation: true`
|
||||
- Whitelist allowed IPC channels
|
||||
- Validate all data from renderer
|
||||
- No `nodeIntegration` in renderer
|
||||
|
||||
### Process Security
|
||||
|
||||
- Spawn OpenCode with user permissions only
|
||||
- No shell execution of user input
|
||||
- Sanitize file paths
|
||||
|
||||
### Content Security
|
||||
|
||||
- Sanitize markdown before rendering
|
||||
- Use DOMPurify for HTML sanitization
|
||||
- No `dangerouslySetInnerHTML` without sanitization
|
||||
- CSP headers in renderer
|
||||
|
||||
## Testing Strategy (Future)
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- State management logic
|
||||
- Utility functions
|
||||
- Message parsing
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- Process spawning
|
||||
- SDK client operations
|
||||
- SSE event handling
|
||||
|
||||
### E2E Tests
|
||||
|
||||
- Complete user flows
|
||||
- Multi-instance scenarios
|
||||
- Error recovery
|
||||
|
||||
## Build & Packaging
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
npm run dev # Start Electron + Vite dev server
|
||||
npm run dev:main # Main process only
|
||||
npm run dev:renderer # Renderer only
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
npm run build # Build all
|
||||
npm run build:main # Build main process
|
||||
npm run build:renderer # Build renderer
|
||||
npm run package # Create distributable
|
||||
```
|
||||
|
||||
### Distribution
|
||||
|
||||
- macOS: DMG + auto-update
|
||||
- Windows: NSIS installer + auto-update
|
||||
- Linux: AppImage + deb/rpm
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### electron.vite.config.ts
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from "electron-vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ["electron"],
|
||||
},
|
||||
},
|
||||
},
|
||||
preload: {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ["electron"],
|
||||
},
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
plugins: [solid()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": "/src",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### tsconfig.json
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user