Restored from origin/main (b4663fb): - .github/ workflows and issue templates - .gitignore (proper exclusions) - .opencode/agent/web_developer.md - AGENTS.md, BUILD.md, PROGRESS.md - dev-docs/ (9 architecture/implementation docs) - docs/screenshots/ (4 UI screenshots) - images/ (CodeNomad icons) - package-lock.json (dependency lockfile) - tasks/ (25+ project task files) Also restored original source files that were modified: - packages/ui/src/App.tsx - packages/ui/src/lib/logger.ts - packages/ui/src/stores/instances.ts - packages/server/src/server/routes/workspaces.ts - packages/server/src/workspaces/manager.ts - packages/server/src/workspaces/runtime.ts - packages/server/package.json Kept new additions: - Install-*.bat/.sh (enhanced installers) - Launch-*.bat/.sh (new launchers) - README.md (SEO optimized with GLM 4.7)
643 lines
16 KiB
Markdown
643 lines
16 KiB
Markdown
# 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/*"]
|
|
}
|
|
}
|
|
}
|
|
```
|