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

16 KiB

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

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

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

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

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

// 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

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

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

// 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

// 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

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

// 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:

// 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

// 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

// 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)

// 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

// 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

eventSource.onerror = (error) => {
  // Connection lost
  if (eventSource.readyState === EventSource.CLOSED) {
    // Attempt reconnect
    reconnectSSE()
  }
}

User Input Errors

// 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

npm run dev          # Start Electron + Vite dev server
npm run dev:main     # Main process only
npm run dev:renderer # Renderer only

Production

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

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

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "lib": ["ES2020", "DOM"],
    "jsx": "preserve",
    "jsxImportSource": "solid-js",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}