Files
NomadArch/dev-docs/technical-implementation.md
Gemini AI 157449a9ad restore: recover deleted documentation, CI/CD, and infrastructure files
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)
2025-12-23 13:03:48 +04:00

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/*"]
}
}
}
```