v0.5.0: NomadArch - Binary-Free Mode Release
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:
Gemini AI
2025-12-26 11:27:03 +04:00
Unverified
commit 1d427f4cf5
407 changed files with 100777 additions and 0 deletions

177
tasks/README.md Normal file
View File

@@ -0,0 +1,177 @@
# Task Management
This directory contains the task breakdown for building CodeNomad.
## Structure
- `todo/` - Tasks waiting to be worked on
- `done/` - Completed tasks (moved from todo/)
## Task Naming Convention
Tasks are numbered sequentially with a descriptive name:
```
001-project-setup.md
002-empty-state-ui.md
003-process-manager.md
...
```
## Task Format
Each task file contains:
1. **Goal** - What this task achieves
2. **Prerequisites** - What must be done first
3. **Acceptance Criteria** - Checklist of requirements
4. **Steps** - Detailed implementation guide
5. **Testing Checklist** - How to verify completion
6. **Dependencies** - What blocks/is blocked by this task
7. **Estimated Time** - Rough time estimate
8. **Notes** - Additional context
## Workflow
### Starting a Task
1. Read the task file thoroughly
2. Ensure prerequisites are met
3. Check dependencies are complete
4. Create a feature branch: `feature/task-XXX-name`
### Working on a Task
1. Follow steps in order
2. Check off acceptance criteria as you complete them
3. Run tests frequently
4. Commit regularly with descriptive messages
### Completing a Task
1. Verify all acceptance criteria met
2. Run full testing checklist
3. Update task file with any notes/changes
4. Move task from `todo/` to `done/`
5. Create PR for review
## Current Tasks
### Phase 1: Foundation (Tasks 001-005)
- [x] 001 - Project Setup
- [x] 002 - Empty State UI
- [x] 003 - Process Manager
- [x] 004 - SDK Integration
- [x] 005 - Session Picker Modal
### Phase 2: Core Chat (Tasks 006-010)
- [x] 006 - Instance & Session Tabs
- [x] 007 - Message Display
- [x] 008 - SSE Integration
- [x] 009 - Prompt Input (Basic)
- [x] 010 - Tool Call Rendering
### Phase 3: Essential Features (Tasks 011-015)
- [x] 011 - Agent/Model Selectors
- [x] 012 - Markdown Rendering
- [x] 013 - Logs Tab
- [ ] 014 - Error Handling
- [ ] 015 - Keyboard Shortcuts
### Phase 4: Multi-Instance (Tasks 016-020)
- [ ] 016 - Instance Tabs
- [ ] 017 - Instance Persistence
- [ ] 018 - Child Session Handling
- [ ] 019 - Instance Lifecycle
- [ ] 020 - Multiple SDK Clients
### Phase 5: Advanced Input (Tasks 021-025)
- [ ] 021 - Slash Commands
- [ ] 022 - File Attachments
- [ ] 023 - Drag & Drop
- [ ] 024 - Attachment Chips
- [ ] 025 - Input History
### Phase 6: Polish (Tasks 026-030)
- [ ] 026 - Message Actions
- [ ] 027 - Search in Session
- [ ] 028 - Session Management
- [ ] 029 - Settings UI
- [ ] 030 - Native Menus
### Phase 7: System Integration (Tasks 031-035)
- [ ] 031 - System Tray
- [ ] 032 - Notifications
- [ ] 033 - Auto-updater
- [ ] 034 - Crash Reporting
- [ ] 035 - Performance Profiling
### Phase 8: Advanced (Tasks 036-040)
- [ ] 036 - Virtual Scrolling
- [ ] 037 - Advanced Search
- [ ] 038 - Workspace Management
- [ ] 039 - Theme Customization
- [ ] 040 - Plugin System
## Priority Levels
Tasks are prioritized as follows:
- **P0 (MVP)**: Must have for first release (Tasks 001-015)
- **P1 (Beta)**: Important for beta (Tasks 016-030)
- **P2 (v1.0)**: Should have for v1.0 (Tasks 031-035)
- **P3 (Future)**: Nice to have (Tasks 036-040)
## Dependencies Graph
```
001 (Setup)
├─ 002 (Empty State)
│ └─ 003 (Process Manager)
│ └─ 004 (SDK Integration)
│ └─ 005 (Session Picker)
│ ├─ 006 (Tabs)
│ │ └─ 007 (Messages)
│ │ └─ 008 (SSE)
│ │ └─ 009 (Input)
│ │ └─ 010 (Tool Calls)
│ │ └─ 011-015 (Essential Features)
│ │ └─ 016-020 (Multi-Instance)
│ │ └─ 021-025 (Advanced Input)
│ │ └─ 026-030 (Polish)
│ │ └─ 031-035 (System)
│ │ └─ 036-040 (Advanced)
```
## Tips
- **Don't skip steps** - They're ordered for a reason
- **Test as you go** - Don't wait until the end
- **Keep tasks small** - Break down if >1 day of work
- **Document issues** - Note any blockers or problems
- **Ask questions** - If unclear, ask before proceeding
## Tracking Progress
Update this file as tasks complete:
- Change `[ ]` to `[x]` in the task list
- Move completed task files to `done/`
- Update build roadmap doc
## Getting Help
If stuck on a task:
1. Review prerequisites and dependencies
2. Check related documentation in `docs/`
3. Review similar patterns in existing code
4. Ask for clarification on unclear requirements

View File

@@ -0,0 +1,262 @@
# Task 001: Project Setup & Boilerplate
## Goal
Set up the basic Electron + SolidJS + Vite project structure with all necessary dependencies and configuration files.
## Prerequisites
- Node.js 18+ installed
- Bun package manager
- OpenCode CLI installed and accessible in PATH
## Acceptance Criteria
- [ ] Project structure matches documented layout
- [ ] All dependencies installed
- [ ] Dev server starts successfully
- [ ] Electron window launches
- [ ] Hot reload works for renderer
- [ ] TypeScript compilation works
- [ ] Basic "Hello World" renders
## Steps
### 1. Initialize Package
- Create `package.json` with project metadata
- Set `name`: `@opencode-ai/client`
- Set `version`: `0.1.0`
- Set `type`: `module`
- Set `main`: `dist/main/main.js`
### 2. Install Core Dependencies
**Production:**
- `electron` ^28.0.0
- `solid-js` ^1.8.0
- `@solidjs/router` ^0.13.0
- `@opencode-ai/sdk` (from workspace)
**Development:**
- `electron-vite` ^2.0.0
- `electron-builder` ^24.0.0
- `vite` ^5.0.0
- `vite-plugin-solid` ^2.10.0
- `typescript` ^5.3.0
- `tailwindcss` ^4.0.0
- `@tailwindcss/vite` ^4.0.0
**UI Libraries:**
- `@kobalte/core` ^0.13.0
- `shiki` ^1.0.0
- `marked` ^12.0.0
- `lucide-solid` ^0.300.0
### 3. Create Directory Structure
```
packages/opencode-client/
├── electron/
│ ├── main/
│ │ └── main.ts
│ ├── preload/
│ │ └── index.ts
│ └── resources/
│ └── icon.png
├── src/
│ ├── components/
│ ├── stores/
│ ├── lib/
│ ├── hooks/
│ ├── types/
│ ├── App.tsx
│ ├── main.tsx
│ └── index.css
├── docs/
├── tasks/
│ ├── todo/
│ └── done/
├── package.json
├── tsconfig.json
├── tsconfig.node.json
├── electron.vite.config.ts
├── tailwind.config.js
├── .gitignore
└── README.md
```
### 4. Configure TypeScript
**tsconfig.json** (for renderer):
- `target`: ES2020
- `module`: ESNext
- `jsx`: preserve
- `jsxImportSource`: solid-js
- `moduleResolution`: bundler
- `strict`: true
- Path alias: `@/*``./src/*`
**tsconfig.node.json** (for main & preload):
- `target`: ES2020
- `module`: ESNext
- `moduleResolution`: bundler
- Include: `electron/**/*.ts`
### 5. Configure Electron Vite
**electron.vite.config.ts:**
- Main process config: External electron
- Preload config: External electron
- Renderer config:
- SolidJS plugin
- TailwindCSS plugin
- Path alias resolution
- Dev server port: 3000
### 6. Configure TailwindCSS
**tailwind.config.js:**
- Content: `['./src/**/*.{ts,tsx}']`
- Theme: Default (will customize later)
- Plugins: None initially
**src/index.css:**
```css
@import "tailwindcss";
```
### 7. Create Main Process Entry
**electron/main/main.ts:**
- Import app, BrowserWindow from electron
- Set up window creation
- Window size: 1400x900
- Min size: 800x600
- Web preferences:
- preload: path to preload script
- contextIsolation: true
- nodeIntegration: false
- Load URL based on environment:
- Dev: http://localhost:3000
- Prod: Load dist/index.html
- Handle app lifecycle:
- ready event
- window-all-closed (quit on non-macOS)
- activate (recreate window on macOS)
### 8. Create Preload Script
**electron/preload/index.ts:**
- Import contextBridge, ipcRenderer
- Expose electronAPI object:
- Placeholder methods for future IPC
- Type definitions for window.electronAPI
### 9. Create Renderer Entry
**src/main.tsx:**
- Import render from solid-js/web
- Import App component
- Render to #root element
**src/App.tsx:**
- Basic component with "Hello CodeNomad"
- Display environment info
- Basic styling with TailwindCSS
**index.html:**
- Root div with id="root"
- Link to src/main.tsx
### 10. Add Scripts to package.json
```json
{
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json",
"preview": "electron-vite preview",
"package:mac": "electron-builder --mac",
"package:win": "electron-builder --win",
"package:linux": "electron-builder --linux"
}
}
```
### 11. Configure Electron Builder
**electron-builder.yml** or in package.json:
- appId: ai.opencode.client
- Product name: CodeNomad
- Build resources: electron/resources
- Files to include: dist/, package.json
- Directories:
- output: release
- buildResources: electron/resources
- Platform-specific configs (basic)
### 12. Add .gitignore
```
node_modules/
dist/
release/
.DS_Store
*.log
.vite/
.electron-vite/
```
### 13. Create README
- Project description
- Prerequisites
- Installation instructions
- Development commands
- Build commands
- Architecture overview link
## Verification Steps
1. Run `bun install`
2. Run `bun run dev`
3. Verify Electron window opens
4. Verify "Hello CodeNomad" displays
5. Make a change to App.tsx
6. Verify hot reload updates UI
7. Run `bun run typecheck`
8. Verify no TypeScript errors
9. Run `bun run build`
10. Verify dist/ folder created
## Dependencies for Next Tasks
- Task 002 (Empty State) depends on this
- Task 003 (Process Manager) depends on this
## Estimated Time
2-3 hours
## Notes
- Keep this minimal - just the skeleton
- Don't add any business logic yet
- Focus on getting build pipeline working
- Use official Electron + Vite + Solid templates as reference

View File

@@ -0,0 +1,280 @@
# Task 002: Empty State UI & Folder Selection
## Goal
Create the initial empty state interface that appears when no instances are running, with folder selection capability.
## Prerequisites
- Task 001 completed (project setup)
- Basic understanding of SolidJS components
- Electron IPC understanding
## Acceptance Criteria
- [ ] Empty state displays when no instances exist
- [ ] "Select Folder" button visible and styled
- [ ] Clicking button triggers Electron dialog
- [ ] Selected folder path displays temporarily
- [ ] UI matches design spec (centered, clean)
- [ ] Keyboard shortcut Cmd/Ctrl+N works
- [ ] Error handling for cancelled selection
## Steps
### 1. Create Empty State Component
**src/components/empty-state.tsx:**
**Structure:**
- Centered container
- Large folder icon (from lucide-solid)
- Subheading: "Select a folder to start coding with AI"
- Primary button: "Select Folder"
- Helper text: "Keyboard shortcut: Cmd/Ctrl+N"
**Styling:**
- Use TailwindCSS utilities
- Center vertically and horizontally
- Max width: 500px
- Padding: 32px
- Icon size: 64px
- Text sizes: Heading 24px, body 16px, helper 14px
- Colors: Follow design spec (light/dark mode)
**Props:**
- `onSelectFolder: () => void` - Callback when button clicked
### 2. Create UI Store
**src/stores/ui.ts:**
**State:**
```typescript
interface UIStore {
hasInstances: boolean
selectedFolder: string | null
isSelectingFolder: boolean
}
```
**Signals:**
- `hasInstances` - Reactive boolean
- `selectedFolder` - Reactive string or null
- `isSelectingFolder` - Reactive boolean (loading state)
**Actions:**
- `setHasInstances(value: boolean)`
- `setSelectedFolder(path: string | null)`
- `setIsSelectingFolder(value: boolean)`
### 3. Implement IPC for Folder Selection
**electron/main/main.ts additions:**
**IPC Handler:**
- Register handler for 'dialog:selectFolder'
- Use `dialog.showOpenDialog()` with:
- `properties: ['openDirectory']`
- Title: "Select Project Folder"
- Button label: "Select"
- Return selected folder path or null if cancelled
- Handle errors gracefully
**electron/preload/index.ts additions:**
**Expose method:**
```typescript
electronAPI: {
selectFolder: () => Promise<string | null>
}
```
**Type definitions:**
```typescript
interface ElectronAPI {
selectFolder: () => Promise<string | null>
}
declare global {
interface Window {
electronAPI: ElectronAPI
}
}
```
### 4. Update App Component
**src/App.tsx:**
**Logic:**
- Import UI store
- Import EmptyState component
- Check if `hasInstances` is false
- If false, render EmptyState
- If true, render placeholder for instance UI (future)
**Folder selection handler:**
```typescript
async function handleSelectFolder() {
setIsSelectingFolder(true)
try {
const folder = await window.electronAPI.selectFolder()
if (folder) {
setSelectedFolder(folder)
// TODO: Will trigger instance creation in Task 003
console.log("Selected folder:", folder)
}
} catch (error) {
console.error("Folder selection failed:", error)
// TODO: Show error toast (Task 010)
} finally {
setIsSelectingFolder(false)
}
}
```
### 5. Add Keyboard Shortcut
**electron/main/menu.ts (new file):**
**Create application menu:**
- File menu:
- New Instance (Cmd/Ctrl+N)
- Click: Send 'menu:newInstance' to renderer
- Separator
- Quit (Cmd/Ctrl+Q)
**Platform-specific menu:**
- macOS: Include app menu with About, Hide, etc.
- Windows/Linux: Standard File menu
**Register menu in main.ts:**
- Import Menu, buildFromTemplate
- Create menu structure
- Set as application menu
**electron/preload/index.ts additions:**
```typescript
electronAPI: {
onNewInstance: (callback: () => void) => void
}
```
**src/App.tsx additions:**
- Listen for 'newInstance' event
- Trigger handleSelectFolder when received
### 6. Add Loading State
**Button states:**
- Default: "Select Folder"
- Loading: "Selecting..." with spinner icon
- Disabled when isSelectingFolder is true
**Spinner component:**
- Use lucide-solid Loader2 icon
- Add spin animation class
- Size: 16px
### 7. Add Validation
**Folder validation (in handler):**
- Check if folder exists
- Check if readable
- Check if it's actually a directory
- Show appropriate error if invalid
**Error messages:**
- "Folder does not exist"
- "Cannot access folder (permission denied)"
- "Please select a directory, not a file"
### 8. Style Refinements
**Responsive behavior:**
- Works at minimum window size (800x600)
- Maintains centering
- Text remains readable
**Accessibility:**
- Button has proper ARIA labels
- Keyboard focus visible
- Screen reader friendly text
**Theme support:**
- Test in light mode
- Test in dark mode (use prefers-color-scheme)
- Icons and text have proper contrast
### 9. Add Helpful Context
**Additional helper text:**
- "Examples: ~/projects/my-app"
- "You can have multiple instances of the same folder"
**Icon improvements:**
- Use animated folder icon (optional)
- Add subtle entrance animation (fade in)
## Testing Checklist
**Manual Tests:**
1. Launch app → Empty state appears
2. Click "Select Folder" → Dialog opens
3. Select folder → Path logged to console
4. Cancel dialog → No error, back to empty state
5. Press Cmd/Ctrl+N → Dialog opens
6. Select non-directory → Error shown
7. Select restricted folder → Permission error shown
8. Resize window → Layout stays centered
**Edge Cases:**
- Very long folder paths (ellipsis)
- Special characters in folder name
- Folder on network drive
- Folder that gets deleted while selected
## Dependencies
- **Blocks:** Task 003 (needs folder path to create instance)
- **Blocked by:** Task 001 (needs project setup)
## Estimated Time
2-3 hours
## Notes
- Keep UI simple and clean
- Focus on UX - clear messaging
- Don't implement instance creation yet (that's Task 003)
- Log selected folder to console for verification
- Prepare for state management patterns used in later tasks

View File

@@ -0,0 +1,430 @@
# Task 003: OpenCode Server Process Management
## Goal
Implement the ability to spawn, manage, and kill OpenCode server processes from the Electron main process.
## Prerequisites
- Task 001 completed (project setup)
- Task 002 completed (folder selection working)
- OpenCode CLI installed and in PATH
- Understanding of Node.js child_process API
## Acceptance Criteria
- [ ] Can spawn `opencode serve` for a folder
- [ ] Parses stdout to extract port number
- [ ] Returns port and PID to renderer
- [ ] Handles spawn errors gracefully
- [ ] Can kill process on command
- [ ] Captures and forwards stdout/stderr
- [ ] Timeout protection (10 seconds)
- [ ] Process cleanup on app quit
## Steps
### 1. Create Process Manager Module
**electron/main/process-manager.ts:**
**Exports:**
```typescript
interface ProcessInfo {
pid: number
port: number
}
interface ProcessManager {
spawn(folder: string): Promise<ProcessInfo>
kill(pid: number): Promise<void>
getStatus(pid: number): "running" | "stopped" | "unknown"
getAllProcesses(): Map<number, ProcessMeta>
}
interface ProcessMeta {
pid: number
port: number
folder: string
startTime: number
childProcess: ChildProcess
}
```
### 2. Implement Spawn Logic
**spawn(folder: string):**
**Pre-flight checks:**
- Verify `opencode` binary exists in PATH
- Use `which opencode` or `where opencode`
- If not found, reject with helpful error
- Verify folder exists and is directory
- Use `fs.stat()` to check
- If invalid, reject with error
- Verify folder is readable
- Check permissions
- If denied, reject with error
**Process spawning:**
- Use `child_process.spawn()`
- Command: `opencode`
- Args: `['serve', '--port', '0']`
- Port 0 = random available port
- Options:
- `cwd`: The selected folder
- `stdio`: `['ignore', 'pipe', 'pipe']`
- stdin: ignore
- stdout: pipe (we'll read it)
- stderr: pipe (for errors)
- `env`: Inherit process.env
- `shell`: false (security)
**Port extraction:**
- Listen to stdout data events
- Buffer output line by line
- Regex match: `/Server listening on port (\d+)/` or similar
- Extract port number when found
- Store process metadata
- Resolve promise with { pid, port }
**Timeout handling:**
- Set 10 second timeout
- If port not found within timeout:
- Kill the process
- Reject promise with timeout error
- Clear timeout once port found
**Error handling:**
- Listen to process 'error' event
- Common: ENOENT (binary not found)
- Reject promise immediately
- Listen to process 'exit' event
- If exits before port found:
- Read stderr buffer
- Reject with exit code and stderr
### 3. Implement Kill Logic
**kill(pid: number):**
**Find process:**
- Look up pid in internal Map
- If not found, reject with "Process not found"
**Graceful shutdown:**
- Send SIGTERM signal first
- Wait 2 seconds
- If still running, send SIGKILL
- Remove from internal Map
- Resolve when process exits
**Cleanup:**
- Close stdio streams
- Remove all event listeners
- Free resources
### 4. Implement Status Check
**getStatus(pid: number):**
**Check if running:**
- On Unix: Use `process.kill(pid, 0)`
- Returns true if running
- Throws if not running
- On Windows: Use tasklist or similar
- Return 'running', 'stopped', or 'unknown'
### 5. Add Process Tracking
**Internal state:**
```typescript
const processes = new Map<number, ProcessMeta>()
```
**Track all spawned processes:**
- Add on successful spawn
- Remove on kill or exit
- Use for cleanup on app quit
### 6. Implement Auto-cleanup
**On app quit:**
- Listen to app 'before-quit' event
- Kill all tracked processes
- Wait for all to exit (with timeout)
- Prevent quit until cleanup done
**On process crash:**
- Listen to process 'exit' event
- If unexpected exit:
- Log error
- Notify renderer via IPC
- Remove from tracking
### 7. Add Logging
**Log output forwarding:**
- Listen to stdout/stderr
- Parse into lines
- Send to renderer via IPC events
- Event: 'instance:log'
- Payload: { pid, level: 'info' | 'error', message }
**Log important events:**
- Process spawned
- Port discovered
- Process exited
- Errors occurred
### 8. Add IPC Handlers
**electron/main/ipc.ts (new file):**
**Register handlers:**
```typescript
ipcMain.handle("process:spawn", async (event, folder: string) => {
return await processManager.spawn(folder)
})
ipcMain.handle("process:kill", async (event, pid: number) => {
return await processManager.kill(pid)
})
ipcMain.handle("process:status", async (event, pid: number) => {
return processManager.getStatus(pid)
})
```
**Send events:**
```typescript
// When process exits unexpectedly
webContents.send("process:exited", { pid, code, signal })
// When log output received
webContents.send("process:log", { pid, level, message })
```
### 9. Update Preload Script
**electron/preload/index.ts additions:**
**Expose methods:**
```typescript
electronAPI: {
spawnServer: (folder: string) => Promise<{ pid: number, port: number }>
killServer: (pid: number) => Promise<void>
getServerStatus: (pid: number) => Promise<string>
onServerExited: (callback: (data: any) => void) => void
onServerLog: (callback: (data: any) => void) => void
}
```
**Type definitions:**
```typescript
interface ProcessInfo {
pid: number
port: number
}
interface ElectronAPI {
// ... previous methods
spawnServer: (folder: string) => Promise<ProcessInfo>
killServer: (pid: number) => Promise<void>
getServerStatus: (pid: number) => Promise<"running" | "stopped" | "unknown">
onServerExited: (callback: (data: { pid: number; code: number }) => void) => void
onServerLog: (callback: (data: { pid: number; level: string; message: string }) => void) => void
}
```
### 10. Create Instance Store
**src/stores/instances.ts:**
**State:**
```typescript
interface Instance {
id: string // UUID
folder: string
port: number
pid: number
status: "starting" | "ready" | "error" | "stopped"
error?: string
}
interface InstanceStore {
instances: Map<string, Instance>
activeInstanceId: string | null
}
```
**Actions:**
```typescript
async function createInstance(folder: string) {
const id = generateId()
// Add with 'starting' status
instances.set(id, {
id,
folder,
port: 0,
pid: 0,
status: "starting",
})
try {
// Spawn server
const { pid, port } = await window.electronAPI.spawnServer(folder)
// Update with port and pid
instances.set(id, {
...instances.get(id)!,
port,
pid,
status: "ready",
})
return id
} catch (error) {
// Update with error
instances.set(id, {
...instances.get(id)!,
status: "error",
error: error.message,
})
throw error
}
}
async function removeInstance(id: string) {
const instance = instances.get(id)
if (!instance) return
// Kill server
if (instance.pid) {
await window.electronAPI.killServer(instance.pid)
}
// Remove from store
instances.delete(id)
// If was active, clear active
if (activeInstanceId === id) {
activeInstanceId = null
}
}
```
### 11. Wire Up Folder Selection
**src/App.tsx updates:**
**After folder selected:**
```typescript
async function handleSelectFolder() {
const folder = await window.electronAPI.selectFolder()
if (!folder) return
try {
const instanceId = await createInstance(folder)
setActiveInstance(instanceId)
// Hide empty state, show instance UI
setHasInstances(true)
} catch (error) {
console.error("Failed to create instance:", error)
// TODO: Show error toast
}
}
```
**Listen for process exit:**
```typescript
onMount(() => {
window.electronAPI.onServerExited(({ pid }) => {
// Find instance by PID
const instance = Array.from(instances.values()).find((i) => i.pid === pid)
if (instance) {
// Update status
instances.set(instance.id, {
...instance,
status: "stopped",
})
// TODO: Show notification (Task 010)
}
})
})
```
## Testing Checklist
**Manual Tests:**
1. Select folder → Server spawns
2. Console shows "Spawned PID: XXX, Port: YYYY"
3. Check `ps aux | grep opencode` → Process running
4. Quit app → Process killed
5. Select invalid folder → Error shown
6. Select without opencode installed → Helpful error
7. Spawn multiple instances → All tracked
8. Kill one instance → Others continue running
**Error Cases:**
- opencode not in PATH
- Permission denied on folder
- Port already in use (should not happen with port 0)
- Server crashes immediately
- Timeout (server takes >10s to start)
**Edge Cases:**
- Very long folder path
- Folder with spaces in name
- Folder on network drive (slow to spawn)
- Multiple instances same folder (different ports)
## Dependencies
- **Blocks:** Task 004 (needs running server to connect SDK)
- **Blocked by:** Task 001, Task 002
## Estimated Time
4-5 hours
## Notes
- Security: Never use shell execution with user input
- Cross-platform: Test on macOS, Windows, Linux
- Error messages must be actionable
- Log everything for debugging
- Consider rate limiting (max 10 instances?)
- Memory: Track process memory usage (future enhancement)

View File

@@ -0,0 +1,504 @@
# Task 004: SDK Client Integration & Session Management
## Goal
Integrate the OpenCode SDK to communicate with running servers, fetch session lists, and manage session lifecycle.
## Prerequisites
- Task 003 completed (server spawning works)
- OpenCode SDK package available
- Understanding of HTTP/REST APIs
- Understanding of SolidJS reactivity
## Acceptance Criteria
- [ ] SDK client created per instance
- [ ] Can fetch session list from server
- [ ] Can create new session
- [ ] Can get session details
- [ ] Can delete session
- [ ] Client lifecycle tied to instance lifecycle
- [ ] Error handling for network failures
- [ ] Proper TypeScript types throughout
## Steps
### 1. Create SDK Manager Module
**src/lib/sdk-manager.ts:**
**Purpose:**
- Manage SDK client instances
- One client per server (per port)
- Create, retrieve, destroy clients
**Interface:**
```typescript
interface SDKManager {
createClient(port: number): OpenCodeClient
getClient(port: number): OpenCodeClient | null
destroyClient(port: number): void
destroyAll(): void
}
```
**Implementation details:**
- Store clients in Map<port, client>
- Create client with base URL: `http://localhost:${port}`
- Handle client creation errors
- Clean up on destroy
### 2. Update Instance Store
**src/stores/instances.ts additions:**
**Add client to Instance:**
```typescript
interface Instance {
// ... existing fields
client: OpenCodeClient | null
}
```
**Update createInstance:**
- After server spawns successfully
- Create SDK client for that port
- Store in instance.client
- Handle client creation errors
**Update removeInstance:**
- Destroy SDK client before removing
- Call sdkManager.destroyClient(port)
### 3. Create Session Store
**src/stores/sessions.ts:**
**State structure:**
```typescript
interface Session {
id: string
instanceId: string
title: string
parentId: string | null
agent: string
model: {
providerId: string
modelId: string
}
time: {
created: number
updated: number
}
}
interface SessionStore {
// Sessions grouped by instance
sessions: Map<string, Map<string, Session>>
// Active session per instance
activeSessionId: Map<string, string>
}
```
**Core actions:**
```typescript
// Fetch all sessions for an instance
async function fetchSessions(instanceId: string): Promise<void>
// Create new session
async function createSession(instanceId: string, agent: string): Promise<Session>
// Delete session
async function deleteSession(instanceId: string, sessionId: string): Promise<void>
// Set active session
function setActiveSession(instanceId: string, sessionId: string): void
// Get active session
function getActiveSession(instanceId: string): Session | null
// Get all sessions for instance
function getSessions(instanceId: string): Session[]
```
### 4. Implement Session Fetching
**fetchSessions implementation:**
```typescript
async function fetchSessions(instanceId: string) {
const instance = instances.get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
try {
const response = await instance.client.session.list()
// Convert API response to Session objects
const sessionMap = new Map<string, Session>()
for (const apiSession of response.data) {
sessionMap.set(apiSession.id, {
id: apiSession.id,
instanceId,
title: apiSession.title || "Untitled",
parentId: apiSession.parentId || null,
agent: "", // Will be populated from messages
model: { providerId: "", modelId: "" },
time: {
created: apiSession.time.created,
updated: apiSession.time.updated,
},
})
}
sessions.set(instanceId, sessionMap)
} catch (error) {
console.error("Failed to fetch sessions:", error)
throw error
}
}
```
### 5. Implement Session Creation
**createSession implementation:**
```typescript
async function createSession(instanceId: string, agent: string): Promise<Session> {
const instance = instances.get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
try {
const response = await instance.client.session.create({
// OpenCode API might need specific params
})
const session: Session = {
id: response.data.id,
instanceId,
title: "New Session",
parentId: null,
agent,
model: { providerId: "", modelId: "" },
time: {
created: Date.now(),
updated: Date.now(),
},
}
// Add to store
const instanceSessions = sessions.get(instanceId) || new Map()
instanceSessions.set(session.id, session)
sessions.set(instanceId, instanceSessions)
return session
} catch (error) {
console.error("Failed to create session:", error)
throw error
}
}
```
### 6. Implement Session Deletion
**deleteSession implementation:**
```typescript
async function deleteSession(instanceId: string, sessionId: string): Promise<void> {
const instance = instances.get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
try {
await instance.client.session.delete({ path: { id: sessionId } })
// Remove from store
const instanceSessions = sessions.get(instanceId)
if (instanceSessions) {
instanceSessions.delete(sessionId)
}
// Clear active if it was active
if (activeSessionId.get(instanceId) === sessionId) {
activeSessionId.delete(instanceId)
}
} catch (error) {
console.error("Failed to delete session:", error)
throw error
}
}
```
### 7. Implement Agent & Model Fetching
**Fetch available agents:**
```typescript
interface Agent {
name: string
description: string
mode: string
}
async function fetchAgents(instanceId: string): Promise<Agent[]> {
const instance = instances.get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
try {
const response = await instance.client.agent.list()
return response.data.filter((agent) => agent.mode !== "subagent")
} catch (error) {
console.error("Failed to fetch agents:", error)
return []
}
}
```
**Fetch available models:**
```typescript
interface Provider {
id: string
name: string
models: Model[]
}
interface Model {
id: string
name: string
providerId: string
}
async function fetchProviders(instanceId: string): Promise<Provider[]> {
const instance = instances.get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
try {
const response = await instance.client.config.providers()
return response.data.providers.map((provider) => ({
id: provider.id,
name: provider.name,
models: Object.entries(provider.models).map(([id, model]) => ({
id,
name: model.name,
providerId: provider.id,
})),
}))
} catch (error) {
console.error("Failed to fetch providers:", error)
return []
}
}
```
### 8. Add Error Handling
**Network error handling:**
```typescript
function handleSDKError(error: any): string {
if (error.code === "ECONNREFUSED") {
return "Cannot connect to server. Is it running?"
}
if (error.code === "ETIMEDOUT") {
return "Request timed out. Please try again."
}
if (error.response?.status === 404) {
return "Resource not found"
}
if (error.response?.status === 500) {
return "Server error. Check logs."
}
return error.message || "Unknown error occurred"
}
```
**Retry logic (for transient failures):**
```typescript
async function withRetry<T>(fn: () => Promise<T>, maxRetries = 3, delay = 1000): Promise<T> {
let lastError
for (let i = 0; i < maxRetries; i++) {
try {
return await fn()
} catch (error) {
lastError = error
if (i < maxRetries - 1) {
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
}
throw lastError
}
```
### 9. Add Loading States
**Track loading states:**
```typescript
interface LoadingState {
fetchingSessions: Map<string, boolean>
creatingSession: Map<string, boolean>
deletingSession: Map<string, Set<string>>
}
const loading: LoadingState = {
fetchingSessions: new Map(),
creatingSession: new Map(),
deletingSession: new Map(),
}
```
**Use in actions:**
```typescript
async function fetchSessions(instanceId: string) {
loading.fetchingSessions.set(instanceId, true)
try {
// ... fetch logic
} finally {
loading.fetchingSessions.set(instanceId, false)
}
}
```
### 10. Wire Up to Instance Creation
**src/stores/instances.ts updates:**
**After server ready:**
```typescript
async function createInstance(folder: string) {
// ... spawn server ...
// Create SDK client
const client = sdkManager.createClient(port)
// Update instance
instances.set(id, {
...instances.get(id)!,
port,
pid,
client,
status: "ready",
})
// Fetch initial data
try {
await fetchSessions(id)
await fetchAgents(id)
await fetchProviders(id)
} catch (error) {
console.error("Failed to fetch initial data:", error)
// Don't fail instance creation, just log
}
return id
}
```
### 11. Add Type Safety
**src/types/session.ts:**
```typescript
export interface Session {
id: string
instanceId: string
title: string
parentId: string | null
agent: string
model: {
providerId: string
modelId: string
}
time: {
created: number
updated: number
}
}
export interface Agent {
name: string
description: string
mode: string
}
export interface Provider {
id: string
name: string
models: Model[]
}
export interface Model {
id: string
name: string
providerId: string
}
```
## Testing Checklist
**Manual Tests:**
1. Create instance → Sessions fetched automatically
2. Console shows session list
3. Create new session → Appears in list
4. Delete session → Removed from list
5. Network fails → Error message shown
6. Server not running → Graceful error
**Error Cases:**
- Server not responding (ECONNREFUSED)
- Request timeout
- 404 on session endpoint
- 500 server error
- Invalid session ID
**Edge Cases:**
- No sessions exist (empty list)
- Many sessions (100+)
- Session with very long title
- Parent-child session relationships
## Dependencies
- **Blocks:** Task 005 (needs session data)
- **Blocked by:** Task 003 (needs running server)
## Estimated Time
3-4 hours
## Notes
- Keep SDK calls isolated in store actions
- All SDK calls should have error handling
- Consider caching to reduce API calls
- Log all API calls for debugging
- Handle slow connections gracefully

View File

@@ -0,0 +1,333 @@
# Task 005: Session Picker Modal
## Goal
Create the session picker modal that appears when an instance starts, allowing users to resume an existing session or create a new one.
## Prerequisites
- Task 004 completed (SDK integration, session fetching)
- Understanding of modal/dialog patterns
- Kobalte UI primitives knowledge
## Acceptance Criteria
- [ ] Modal appears after instance becomes ready
- [ ] Displays list of existing sessions
- [ ] Shows session metadata (title, timestamp)
- [ ] Allows creating new session with agent selection
- [ ] Can close modal (cancels instance creation)
- [ ] Keyboard navigation works (up/down, enter)
- [ ] Properly styled and accessible
- [ ] Loading states during fetch
## Steps
### 1. Create Session Picker Component
**src/components/session-picker.tsx:**
**Props:**
```typescript
interface SessionPickerProps {
instanceId: string
open: boolean
onClose: () => void
onSessionSelect: (sessionId: string) => void
onNewSession: (agent: string) => void
}
```
**Structure:**
- Modal backdrop (semi-transparent overlay)
- Modal dialog (centered card)
- Header: "OpenCode • {folder}"
- Section 1: Resume session list
- Separator: "or"
- Section 2: Create new session
- Footer: Cancel button
### 2. Use Kobalte Dialog
**Implementation approach:**
```typescript
import { Dialog } from '@kobalte/core'
<Dialog.Root open={props.open} onOpenChange={(open) => !open && props.onClose()}>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
{/* Modal content */}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
```
**Styling:**
- Overlay: Dark background, 50% opacity
- Content: White card, max-width 500px, centered
- Padding: 24px
- Border radius: 8px
- Shadow: Large elevation
### 3. Create Session List Section
**Resume Section:**
- Header: "Resume a session:"
- List of sessions (max 10 recent)
- Each item shows:
- Title (truncated at 50 chars)
- Relative timestamp ("2h ago")
- Hover state
- Active selection state
**Session Item Component:**
```typescript
interface SessionItemProps {
session: Session
selected: boolean
onClick: () => void
}
```
**Empty state:**
- Show when no sessions exist
- Text: "No previous sessions"
- Muted styling
**Scrollable:**
- If >5 sessions, add scroll
- Max height: 300px
### 4. Create New Session Section
**Structure:**
- Header: "Start new session:"
- Agent selector dropdown
- "Start" button
**Agent Selector:**
- Dropdown using Kobalte Select
- Shows agent name
- Grouped by category if applicable
- Default: "Build" agent
**Start Button:**
- Primary button style
- Click triggers onNewSession callback
- Disabled while creating
### 5. Add Loading States
**While fetching sessions:**
- Show skeleton list (3-4 placeholder items)
- Shimmer animation
**While fetching agents:**
- Agent dropdown shows "Loading..."
- Disabled state
**While creating session:**
- Start button shows spinner
- Disabled state
- Text changes to "Creating..."
### 6. Wire Up to Instance Store
**Show modal after instance ready:**
**src/stores/ui.ts additions:**
```typescript
interface UIStore {
sessionPickerInstance: string | null
}
function showSessionPicker(instanceId: string) {
sessionPickerInstance = instanceId
}
function hideSessionPicker() {
sessionPickerInstance = null
}
```
**src/stores/instances.ts updates:**
```typescript
async function createInstance(folder: string) {
// ... spawn and connect ...
// Show session picker
showSessionPicker(id)
return id
}
```
### 7. Handle Session Selection
**Resume session:**
```typescript
function handleSessionSelect(sessionId: string) {
setActiveSession(instanceId, sessionId)
hideSessionPicker()
// Will trigger session display in Task 006
}
```
**Create new session:**
```typescript
async function handleNewSession(agent: string) {
try {
const session = await createSession(instanceId, agent)
setActiveSession(instanceId, session.id)
hideSessionPicker()
} catch (error) {
// Show error toast (Task 010)
console.error("Failed to create session:", error)
}
}
```
### 8. Handle Cancel
**Close modal:**
```typescript
function handleClose() {
// Remove instance since user cancelled
await removeInstance(instanceId)
hideSessionPicker()
}
```
**Confirmation if needed:**
- If server already started, ask "Stop server?"
- Otherwise, just close
### 9. Add Keyboard Navigation
**Keyboard shortcuts:**
- Up/Down: Navigate session list
- Enter: Select highlighted session
- Escape: Close modal (cancel)
- Tab: Cycle through sections
**Implement focus management:**
- Auto-focus first session on open
- Trap focus within modal
- Restore focus on close
### 10. Add Accessibility
**ARIA attributes:**
- `role="dialog"`
- `aria-labelledby="dialog-title"`
- `aria-describedby="dialog-description"`
- `aria-modal="true"`
**Screen reader support:**
- Announce "X sessions available"
- Announce selection changes
- Clear focus indicators
### 11. Style Refinements
**Light/Dark mode:**
- Test in both themes
- Ensure contrast meets WCAG AA
- Use CSS variables for colors
**Responsive:**
- Works at minimum window size
- Mobile-friendly (future web version)
- Scales text appropriately
**Animations:**
- Fade in backdrop (200ms)
- Scale in content (200ms)
- Smooth transitions on hover
### 12. Update App Component
**src/App.tsx:**
**Render session picker:**
```typescript
<Show when={ui.sessionPickerInstance}>
{(instanceId) => (
<SessionPicker
instanceId={instanceId()}
open={true}
onClose={() => ui.hideSessionPicker()}
onSessionSelect={(id) => handleSessionSelect(instanceId(), id)}
onNewSession={(agent) => handleNewSession(instanceId(), agent)}
/>
)}
</Show>
```
## Testing Checklist
**Manual Tests:**
1. Create instance → Modal appears
2. Shows session list if sessions exist
3. Shows empty state if no sessions
4. Click session → Modal closes, session activates
5. Select agent, click Start → New session created
6. Press Escape → Modal closes, instance removed
7. Keyboard navigation works
8. Screen reader announces content
**Edge Cases:**
- No sessions + no agents (error state)
- Very long session titles (truncate)
- Many sessions (scroll works)
- Create session fails (error shown)
- Slow network (loading states)
## Dependencies
- **Blocks:** Task 006 (needs active session)
- **Blocked by:** Task 004 (needs session data)
## Estimated Time
3-4 hours
## Notes
- Keep modal simple and focused
- Clear call-to-action
- Don't overwhelm with options
- Loading states crucial for UX
- Consider adding search if >20 sessions (future)

View File

@@ -0,0 +1,591 @@
# Task 006: Instance & Session Tabs
## Goal
Create the two-level tab navigation system: instance tabs (Level 1) and session tabs (Level 2) that allow users to switch between projects and conversations.
## Prerequisites
- Task 005 completed (Session picker modal, active session selection)
- Understanding of tab navigation patterns
- Familiarity with SolidJS For/Show components
- Knowledge of keyboard accessibility
## Acceptance Criteria
- [ ] Instance tabs render at top level
- [ ] Session tabs render below instance tabs for active instance
- [ ] Can switch between instance tabs
- [ ] Can switch between session tabs within an instance
- [ ] Active tab is visually highlighted
- [ ] Tab labels show appropriate text (folder name, session title)
- [ ] Close buttons work on tabs (with confirmation)
- [ ] "+" button creates new instance/session
- [ ] Keyboard navigation works (Cmd/Ctrl+1-9 for tabs)
- [ ] Tabs scroll horizontally when many exist
- [ ] Properly styled and accessible
## Steps
### 1. Create Instance Tabs Component
**src/components/instance-tabs.tsx:**
**Props:**
```typescript
interface InstanceTabsProps {
instances: Map<string, Instance>
activeInstanceId: string | null
onSelect: (instanceId: string) => void
onClose: (instanceId: string) => void
onNew: () => void
}
```
**Structure:**
```tsx
<div class="instance-tabs">
<div class="tabs-container">
<For each={Array.from(instances.entries())}>
{([id, instance]) => (
<InstanceTab
instance={instance}
active={id === activeInstanceId}
onSelect={() => onSelect(id)}
onClose={() => onClose(id)}
/>
)}
</For>
<button class="new-tab-button" onClick={onNew}>
+
</button>
</div>
</div>
```
**Styling:**
- Horizontal layout
- Background: Secondary background color
- Border bottom: 1px solid border color
- Height: 40px
- Padding: 0 8px
- Overflow-x: auto (for many tabs)
### 2. Create Instance Tab Item Component
**src/components/instance-tab.tsx:**
**Props:**
```typescript
interface InstanceTabProps {
instance: Instance
active: boolean
onSelect: () => void
onClose: () => void
}
```
**Structure:**
```tsx
<button class={`instance-tab ${active ? "active" : ""}`} onClick={onSelect}>
<span class="tab-icon">📁</span>
<span class="tab-label">{formatFolderName(instance.folder)}</span>
<button
class="tab-close"
onClick={(e) => {
e.stopPropagation()
onClose()
}}
>
×
</button>
</button>
```
**Styling:**
- Display: inline-flex
- Align items center
- Gap: 8px
- Padding: 8px 12px
- Border radius: 6px 6px 0 0
- Max width: 200px
- Truncate text with ellipsis
- Active: Background accent color
- Inactive: Transparent background
- Hover: Light background
**Folder Name Formatting:**
```typescript
function formatFolderName(path: string): string {
const name = path.split("/").pop() || path
return `~/${name}`
}
```
**Handle Duplicates:**
- If multiple instances have same folder name, add counter
- Example: `~/project`, `~/project (2)`, `~/project (3)`
### 3. Create Session Tabs Component
**src/components/session-tabs.tsx:**
**Props:**
```typescript
interface SessionTabsProps {
instanceId: string
sessions: Map<string, Session>
activeSessionId: string | null
onSelect: (sessionId: string) => void
onClose: (sessionId: string) => void
onNew: () => void
}
```
**Structure:**
```tsx
<div class="session-tabs">
<div class="tabs-container">
<For each={Array.from(sessions.entries())}>
{([id, session]) => (
<SessionTab
session={session}
active={id === activeSessionId}
onSelect={() => onSelect(id)}
onClose={() => onClose(id)}
/>
)}
</For>
<SessionTab special="logs" active={activeSessionId === "logs"} onSelect={() => onSelect("logs")} />
<button class="new-tab-button" onClick={onNew}>
+
</button>
</div>
</div>
```
**Styling:**
- Similar to instance tabs but smaller
- Height: 36px
- Font size: 13px
- Less prominent than instance tabs
### 4. Create Session Tab Item Component
**src/components/session-tab.tsx:**
**Props:**
```typescript
interface SessionTabProps {
session?: Session
special?: "logs"
active: boolean
onSelect: () => void
onClose?: () => void
}
```
**Structure:**
```tsx
<button class={`session-tab ${active ? "active" : ""} ${special ? "special" : ""}`} onClick={onSelect}>
<span class="tab-label">{special === "logs" ? "Logs" : session?.title || "Untitled"}</span>
<Show when={!special && onClose}>
<button
class="tab-close"
onClick={(e) => {
e.stopPropagation()
onClose?.()
}}
>
×
</button>
</Show>
</button>
```
**Styling:**
- Max width: 150px
- Truncate with ellipsis
- Active: Underline or bold text
- Logs tab: Slightly different color/icon
### 5. Add Tab State Management
**src/stores/ui.ts updates:**
```typescript
interface UIState {
instanceTabOrder: string[]
sessionTabOrder: Map<string, string[]>
reorderInstanceTabs: (newOrder: string[]) => void
reorderSessionTabs: (instanceId: string, newOrder: string[]) => void
}
const [instanceTabOrder, setInstanceTabOrder] = createSignal<string[]>([])
const [sessionTabOrder, setSessionTabOrder] = createSignal<Map<string, string[]>>(new Map())
function reorderInstanceTabs(newOrder: string[]) {
setInstanceTabOrder(newOrder)
}
function reorderSessionTabs(instanceId: string, newOrder: string[]) {
setSessionTabOrder((prev) => {
const next = new Map(prev)
next.set(instanceId, newOrder)
return next
})
}
```
### 6. Wire Up Tab Selection
**src/stores/instances.ts updates:**
```typescript
function setActiveInstance(id: string) {
activeInstanceId = id
// Auto-select first session or show session picker
const instance = instances.get(id)
if (instance) {
const sessions = Array.from(instance.sessions.values())
if (sessions.length > 0 && !instance.activeSessionId) {
instance.activeSessionId = sessions[0].id
}
}
}
function setActiveSession(instanceId: string, sessionId: string) {
const instance = instances.get(instanceId)
if (instance) {
instance.activeSessionId = sessionId
}
}
```
### 7. Handle Tab Close Actions
**Close Instance Tab:**
```typescript
async function handleCloseInstance(instanceId: string) {
const confirmed = await showConfirmDialog({
title: "Stop OpenCode instance?",
message: `This will stop the server for ${instance.folder}`,
confirmText: "Stop Instance",
destructive: true,
})
if (confirmed) {
await removeInstance(instanceId)
}
}
```
**Close Session Tab:**
```typescript
async function handleCloseSession(instanceId: string, sessionId: string) {
const session = getInstance(instanceId)?.sessions.get(sessionId)
if (session && session.messages.length > 0) {
const confirmed = await showConfirmDialog({
title: "Delete session?",
message: `This will permanently delete "${session.title}"`,
confirmText: "Delete",
destructive: true,
})
if (!confirmed) return
}
await deleteSession(instanceId, sessionId)
// Switch to another session
const instance = getInstance(instanceId)
const remainingSessions = Array.from(instance.sessions.values())
if (remainingSessions.length > 0) {
setActiveSession(instanceId, remainingSessions[0].id)
} else {
// Show session picker
showSessionPicker(instanceId)
}
}
```
### 8. Handle New Tab Buttons
**New Instance:**
```typescript
async function handleNewInstance() {
const folder = await window.electronAPI.selectFolder()
if (folder) {
await createInstance(folder)
}
}
```
**New Session:**
```typescript
async function handleNewSession(instanceId: string) {
// For now, use default agent
// Later (Task 011) will show agent selector
const session = await createSession(instanceId, "build")
setActiveSession(instanceId, session.id)
}
```
### 9. Update App Layout
**src/App.tsx:**
```tsx
<div class="app">
<Show when={instances.size > 0} fallback={<EmptyState />}>
<InstanceTabs
instances={instances()}
activeInstanceId={activeInstanceId()}
onSelect={setActiveInstance}
onClose={handleCloseInstance}
onNew={handleNewInstance}
/>
<Show when={activeInstance()}>
{(instance) => (
<>
<SessionTabs
instanceId={instance().id}
sessions={instance().sessions}
activeSessionId={instance().activeSessionId}
onSelect={(id) => setActiveSession(instance().id, id)}
onClose={(id) => handleCloseSession(instance().id, id)}
onNew={() => handleNewSession(instance().id)}
/>
<div class="content-area">
{/* Message stream and input will go here in Task 007 */}
<Show when={instance().activeSessionId === "logs"}>
<LogsView logs={instance().logs} />
</Show>
<Show when={instance().activeSessionId !== "logs"}>
<div class="placeholder">Session content will appear here (Task 007)</div>
</Show>
</div>
</>
)}
</Show>
</Show>
</div>
```
### 10. Add Keyboard Shortcuts
**Keyboard navigation:**
```typescript
// src/lib/keyboard.ts
export function setupTabKeyboardShortcuts() {
window.addEventListener("keydown", (e) => {
// Cmd/Ctrl + 1-9: Switch instance tabs
if ((e.metaKey || e.ctrlKey) && e.key >= "1" && e.key <= "9") {
e.preventDefault()
const index = parseInt(e.key) - 1
const instances = Array.from(instanceStore.instances.keys())
if (instances[index]) {
setActiveInstance(instances[index])
}
}
// Cmd/Ctrl + N: New instance
if ((e.metaKey || e.ctrlKey) && e.key === "n") {
e.preventDefault()
handleNewInstance()
}
// Cmd/Ctrl + T: New session
if ((e.metaKey || e.ctrlKey) && e.key === "t") {
e.preventDefault()
if (activeInstanceId()) {
handleNewSession(activeInstanceId()!)
}
}
// Cmd/Ctrl + W: Close current tab
if ((e.metaKey || e.ctrlKey) && e.key === "w") {
e.preventDefault()
const instanceId = activeInstanceId()
const instance = getInstance(instanceId)
if (instance?.activeSessionId && instance.activeSessionId !== "logs") {
handleCloseSession(instanceId!, instance.activeSessionId)
}
}
})
}
```
**Call in main.tsx:**
```typescript
import { setupTabKeyboardShortcuts } from "./lib/keyboard"
onMount(() => {
setupTabKeyboardShortcuts()
})
```
### 11. Add Accessibility
**ARIA attributes:**
```tsx
<div role="tablist" aria-label="Instance tabs">
<button
role="tab"
aria-selected={active}
aria-controls={`instance-panel-${instance.id}`}
>
...
</button>
</div>
<div
role="tabpanel"
id={`instance-panel-${instance.id}`}
aria-labelledby={`instance-tab-${instance.id}`}
>
{/* Session tabs */}
</div>
```
**Focus management:**
- Tab key cycles through tabs
- Arrow keys navigate within tab list
- Focus indicators visible
- Skip links for screen readers
### 12. Style Refinements
**Horizontal scroll:**
```css
.tabs-container {
display: flex;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
}
.tabs-container::-webkit-scrollbar {
height: 4px;
}
.tabs-container::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 2px;
}
```
**Tab animations:**
```css
.instance-tab,
.session-tab {
transition: background-color 150ms ease;
}
.instance-tab:hover,
.session-tab:hover {
background-color: var(--hover-background);
}
.instance-tab.active,
.session-tab.active {
background-color: var(--active-background);
}
```
**Close button styling:**
```css
.tab-close {
opacity: 0;
transition: opacity 150ms ease;
}
.instance-tab:hover .tab-close,
.session-tab:hover .tab-close {
opacity: 1;
}
.tab-close:hover {
background-color: var(--danger-background);
color: var(--danger-color);
}
```
## Testing Checklist
**Manual Tests:**
1. Create instance → Instance tab appears
2. Click instance tab → Switches active instance
3. Session tabs appear below active instance
4. Click session tab → Switches active session
5. Click "+" on instance tabs → Opens folder picker
6. Click "+" on session tabs → Creates new session
7. Click close on instance tab → Shows confirmation, closes
8. Click close on session tab → Closes session
9. Cmd/Ctrl+1 switches to first instance
10. Cmd/Ctrl+N opens new instance
11. Cmd/Ctrl+T creates new session
12. Cmd/Ctrl+W closes active session
13. Tabs scroll when many exist
14. Logs tab always visible and non-closable
15. Tab labels truncate long names
**Edge Cases:**
- Only one instance (no scrolling needed)
- Many instances (>10, horizontal scroll)
- No sessions in instance (only Logs tab visible)
- Duplicate folder names (counter added)
- Very long folder/session names (ellipsis)
- Close last session (session picker appears)
- Switch instance while session is streaming
## Dependencies
- **Blocks:** Task 007 (needs tab structure to display messages)
- **Blocked by:** Task 005 (needs session selection to work)
## Estimated Time
4-5 hours
## Notes
- Keep tab design clean and minimal
- Don't over-engineer tab reordering (can add later)
- Focus on functionality over fancy animations
- Ensure keyboard accessibility from the start
- Tab state will persist in Task 017
- Context menus for tabs can be added in Task 026

View File

@@ -0,0 +1,812 @@
# Task 007: Message Display
## Goal
Create the message display component that renders user and assistant messages in a scrollable stream, showing message content, tool calls, and streaming states.
> Note: This legacy task predates `message-stream-v2` and the normalized message store; the new implementation lives under `packages/ui/src/components/message-stream-v2.tsx`.
## Prerequisites
- Task 006 completed (Tab navigation in place)
- Understanding of message part structure from OpenCode SDK
- Familiarity with markdown rendering
- Knowledge of SolidJS For/Show components
## Acceptance Criteria
- [ ] Messages render in chronological order
- [ ] User messages display with correct styling
- [ ] Assistant messages display with agent label
- [ ] Text content renders properly
- [ ] Tool calls display inline with collapse/expand
- [ ] Auto-scroll to bottom on new messages
- [ ] Manual scroll up disables auto-scroll
- [ ] "Scroll to bottom" button appears when scrolled up
- [ ] Empty state shows when no messages
- [ ] Loading state shows when fetching messages
- [ ] Timestamps display for each message
- [ ] Messages are accessible and keyboard-navigable
## Steps
### 1. Define Message Types
**src/types/message.ts:**
```typescript
export interface Message {
id: string
sessionId: string
type: "user" | "assistant"
parts: MessagePart[]
timestamp: number
status: "sending" | "sent" | "streaming" | "complete" | "error"
}
export type MessagePart = TextPart | ToolCallPart | ToolResultPart | ErrorPart
export interface TextPart {
type: "text"
text: string
}
export interface ToolCallPart {
type: "tool_call"
id: string
tool: string
input: any
status: "pending" | "running" | "success" | "error"
}
export interface ToolResultPart {
type: "tool_result"
toolCallId: string
output: any
error?: string
}
export interface ErrorPart {
type: "error"
message: string
}
```
### 2. Create Message Stream Component
**src/components/message-stream.tsx:**
```typescript
import { For, Show, createSignal, onMount, onCleanup } from "solid-js"
import { Message } from "../types/message"
import MessageItem from "./message-item"
interface MessageStreamProps {
sessionId: string
messages: Message[]
loading?: boolean
}
export default function MessageStream(props: MessageStreamProps) {
let containerRef: HTMLDivElement | undefined
const [autoScroll, setAutoScroll] = createSignal(true)
const [showScrollButton, setShowScrollButton] = createSignal(false)
function scrollToBottom() {
if (containerRef) {
containerRef.scrollTop = containerRef.scrollHeight
setAutoScroll(true)
setShowScrollButton(false)
}
}
function handleScroll() {
if (!containerRef) return
const { scrollTop, scrollHeight, clientHeight } = containerRef
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50
setAutoScroll(isAtBottom)
setShowScrollButton(!isAtBottom)
}
onMount(() => {
if (autoScroll()) {
scrollToBottom()
}
})
// Auto-scroll when new messages arrive
const messagesLength = () => props.messages.length
createEffect(() => {
messagesLength() // Track changes
if (autoScroll()) {
setTimeout(scrollToBottom, 0)
}
})
return (
<div class="message-stream-container">
<div
ref={containerRef}
class="message-stream"
onScroll={handleScroll}
>
<Show when={!props.loading && props.messages.length === 0}>
<div class="empty-state">
<div class="empty-state-content">
<h3>Start a conversation</h3>
<p>Type a message below or try:</p>
<ul>
<li><code>/init-project</code></li>
<li>Ask about your codebase</li>
<li>Attach files with <code>@</code></li>
</ul>
</div>
</div>
</Show>
<Show when={props.loading}>
<div class="loading-state">
<div class="spinner" />
<p>Loading messages...</p>
</div>
</Show>
<For each={props.messages}>
{(message) => (
<MessageItem message={message} />
)}
</For>
</div>
<Show when={showScrollButton()}>
<button
class="scroll-to-bottom"
onClick={scrollToBottom}
aria-label="Scroll to bottom"
>
</button>
</Show>
</div>
)
}
```
### 3. Create Message Item Component
**src/components/message-item.tsx:**
```typescript
import { For, Show } from "solid-js"
import { Message } from "../types/message"
import MessagePart from "./message-part"
interface MessageItemProps {
message: Message
}
export default function MessageItem(props: MessageItemProps) {
const isUser = () => props.message.type === "user"
const timestamp = () => {
const date = new Date(props.message.timestamp)
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
return (
<div class={`message-item ${isUser() ? "user" : "assistant"}`}>
<div class="message-header">
<span class="message-sender">
{isUser() ? "You" : "Assistant"}
</span>
<span class="message-timestamp">{timestamp()}</span>
</div>
<div class="message-content">
<For each={props.message.parts}>
{(part) => <MessagePart part={part} />}
</For>
</div>
<Show when={props.message.status === "error"}>
<div class="message-error">
Message failed to send
</div>
</Show>
</div>
)
}
```
### 4. Create Message Part Component
**src/components/message-part.tsx:**
```typescript
import { Show, Match, Switch } from "solid-js"
import { MessagePart as MessagePartType } from "../types/message"
import ToolCall from "./tool-call"
interface MessagePartProps {
part: MessagePartType
}
export default function MessagePart(props: MessagePartProps) {
return (
<Switch>
<Match when={props.part.type === "text"}>
<div class="message-text">
{(props.part as any).text}
</div>
</Match>
<Match when={props.part.type === "tool_call"}>
<ToolCall toolCall={props.part as any} />
</Match>
<Match when={props.part.type === "error"}>
<div class="message-error-part">
{(props.part as any).message}
</div>
</Match>
</Switch>
)
}
```
### 5. Create Tool Call Component
**src/components/tool-call.tsx:**
```typescript
import { createSignal, Show } from "solid-js"
import { ToolCallPart } from "../types/message"
interface ToolCallProps {
toolCall: ToolCallPart
}
export default function ToolCall(props: ToolCallProps) {
const [expanded, setExpanded] = createSignal(false)
const statusIcon = () => {
switch (props.toolCall.status) {
case "pending":
return "⏳"
case "running":
return "⏳"
case "success":
return "✓"
case "error":
return "✗"
default:
return ""
}
}
const statusClass = () => {
return `tool-call-status-${props.toolCall.status}`
}
function toggleExpanded() {
setExpanded(!expanded())
}
function formatToolSummary() {
// Create a brief summary of the tool call
const { tool, input } = props.toolCall
switch (tool) {
case "bash":
return `bash: ${input.command}`
case "edit":
return `edit ${input.filePath}`
case "read":
return `read ${input.filePath}`
case "write":
return `write ${input.filePath}`
default:
return `${tool}`
}
}
return (
<div class={`tool-call ${statusClass()}`}>
<button
class="tool-call-header"
onClick={toggleExpanded}
aria-expanded={expanded()}
>
<span class="tool-call-icon">
{expanded() ? "▼" : "▶"}
</span>
<span class="tool-call-summary">
{formatToolSummary()}
</span>
<span class="tool-call-status">
{statusIcon()}
</span>
</button>
<Show when={expanded()}>
<div class="tool-call-details">
<div class="tool-call-section">
<h4>Input:</h4>
<pre><code>{JSON.stringify(props.toolCall.input, null, 2)}</code></pre>
</div>
<Show when={props.toolCall.status === "success" || props.toolCall.status === "error"}>
<div class="tool-call-section">
<h4>Output:</h4>
<pre><code>{formatToolOutput()}</code></pre>
</div>
</Show>
</div>
</Show>
</div>
)
function formatToolOutput() {
// This will be enhanced in later tasks
// For now, just stringify
return "Output will be displayed here"
}
}
```
### 6. Add Message Store Integration
**src/stores/sessions.ts updates:**
```typescript
interface Session {
// ... existing fields
messages: Message[]
}
async function loadMessages(instanceId: string, sessionId: string) {
const instance = getInstance(instanceId)
if (!instance) return
try {
// Fetch messages from SDK
const response = await instance.client.session.getMessages(sessionId)
// Update session with messages
const session = instance.sessions.get(sessionId)
if (session) {
session.messages = response.messages.map(transformMessage)
}
} catch (error) {
console.error("Failed to load messages:", error)
throw error
}
}
function transformMessage(apiMessage: any): Message {
return {
id: apiMessage.id,
sessionId: apiMessage.sessionId,
type: apiMessage.type,
parts: apiMessage.parts || [],
timestamp: apiMessage.timestamp || Date.now(),
status: "complete",
}
}
```
### 7. Update App to Show Messages
**src/App.tsx updates:**
```tsx
<Show when={instance().activeSessionId !== "logs"}>
{() => {
const session = instance().sessions.get(instance().activeSessionId!)
return (
<Show when={session} fallback={<div>Session not found</div>}>
{(s) => <MessageStream sessionId={s().id} messages={s().messages} loading={false} />}
</Show>
)
}}
</Show>
```
### 8. Add Styling
**src/components/message-stream.css:**
```css
.message-stream-container {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.message-stream {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.message-item {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
border-radius: 8px;
max-width: 85%;
}
.message-item.user {
align-self: flex-end;
background-color: var(--user-message-bg);
}
.message-item.assistant {
align-self: flex-start;
background-color: var(--assistant-message-bg);
}
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.message-sender {
font-weight: 600;
font-size: 14px;
}
.message-timestamp {
font-size: 12px;
color: var(--text-muted);
}
.message-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.message-text {
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
}
.tool-call {
margin: 8px 0;
border: 1px solid var(--border-color);
border-radius: 6px;
overflow: hidden;
}
.tool-call-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
width: 100%;
background-color: var(--secondary-bg);
border: none;
cursor: pointer;
font-family: monospace;
font-size: 13px;
}
.tool-call-header:hover {
background-color: var(--hover-bg);
}
.tool-call-icon {
font-size: 10px;
}
.tool-call-summary {
flex: 1;
text-align: left;
}
.tool-call-status {
font-size: 14px;
}
.tool-call-status-success {
border-left: 3px solid var(--success-color);
}
.tool-call-status-error {
border-left: 3px solid var(--error-color);
}
.tool-call-status-running {
border-left: 3px solid var(--warning-color);
}
.tool-call-details {
padding: 12px;
background-color: var(--code-bg);
display: flex;
flex-direction: column;
gap: 12px;
}
.tool-call-section h4 {
font-size: 12px;
font-weight: 600;
margin-bottom: 4px;
color: var(--text-muted);
}
.tool-call-section pre {
margin: 0;
padding: 8px;
background-color: var(--background);
border-radius: 4px;
overflow-x: auto;
}
.tool-call-section code {
font-family: monospace;
font-size: 12px;
line-height: 1.4;
}
.scroll-to-bottom {
position: absolute;
bottom: 16px;
right: 16px;
width: 40px;
height: 40px;
border-radius: 50%;
background-color: var(--accent-color);
color: white;
border: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
cursor: pointer;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: transform 150ms ease;
}
.scroll-to-bottom:hover {
transform: scale(1.1);
}
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
}
.empty-state-content {
text-align: center;
max-width: 400px;
}
.empty-state-content h3 {
font-size: 18px;
margin-bottom: 12px;
}
.empty-state-content p {
font-size: 14px;
color: var(--text-muted);
margin-bottom: 16px;
}
.empty-state-content ul {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.empty-state-content li {
font-size: 14px;
color: var(--text-muted);
}
.empty-state-content code {
background-color: var(--code-bg);
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
font-size: 13px;
}
.loading-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 48px;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-color);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
```
### 9. Add CSS Variables
**src/index.css updates:**
```css
:root {
/* Message colors */
--user-message-bg: #e3f2fd;
--assistant-message-bg: #f5f5f5;
/* Status colors */
--success-color: #4caf50;
--error-color: #f44336;
--warning-color: #ff9800;
/* Code colors */
--code-bg: #f8f8f8;
}
[data-theme="dark"] {
--user-message-bg: #1e3a5f;
--assistant-message-bg: #2a2a2a;
--code-bg: #1a1a1a;
}
```
### 10. Load Messages on Session Switch
**src/hooks/use-session.ts:**
```typescript
import { createEffect } from "solid-js"
export function useSession(instanceId: string, sessionId: string) {
createEffect(() => {
// Load messages when session becomes active
if (sessionId && sessionId !== "logs") {
loadMessages(instanceId, sessionId).catch(console.error)
}
})
}
```
**Use in App.tsx:**
```tsx
<Show when={session}>
{(s) => {
useSession(instance().id, s().id)
return <MessageStream sessionId={s().id} messages={s().messages} loading={false} />
}}
</Show>
```
### 11. Add Accessibility
**ARIA attributes:**
```tsx
<div
class="message-stream"
role="log"
aria-live="polite"
aria-atomic="false"
aria-label="Message history"
>
{/* Messages */}
</div>
<div
class="message-item"
role="article"
aria-label={`${isUser() ? "Your" : "Assistant"} message at ${timestamp()}`}
>
{/* Message content */}
</div>
```
**Keyboard navigation:**
- Messages should be accessible via Tab key
- Tool calls can be expanded with Enter/Space
- Screen readers announce new messages
### 12. Handle Long Messages
**Text wrapping:**
```css
.message-text {
overflow-wrap: break-word;
word-wrap: break-word;
hyphens: auto;
}
```
**Code blocks (for now, just basic):**
```css
.message-text pre {
overflow-x: auto;
padding: 8px;
background-color: var(--code-bg);
border-radius: 4px;
}
```
## Testing Checklist
**Manual Tests:**
1. Empty session shows empty state
2. Messages load when switching sessions
3. User messages appear on right
4. Assistant messages appear on left
5. Timestamps display correctly
6. Tool calls appear inline
7. Tool calls expand/collapse on click
8. Auto-scroll works for new messages
9. Manual scroll up disables auto-scroll
10. Scroll to bottom button appears/works
11. Long messages wrap correctly
12. Multiple messages display properly
13. Messages are keyboard accessible
**Edge Cases:**
- Session with 1 message
- Session with 100+ messages
- Messages with very long text
- Messages with no parts
- Tool calls with large output
- Rapid message updates
- Switching sessions while loading
## Dependencies
- **Blocks:** Task 008 (SSE will update these messages in real-time)
- **Blocked by:** Task 006 (needs tab structure)
## Estimated Time
4-5 hours
## Notes
- Keep styling simple for now - markdown rendering comes in Task 012
- Tool output formatting will be enhanced in Task 010
- Focus on basic text display and structure
- Don't optimize for virtual scrolling yet (MVP principle)
- Message actions (copy, edit, etc.) come in Task 026
- This is the foundation for real-time updates in Task 008

View File

@@ -0,0 +1,445 @@
# Task 008: SSE Integration - Real-time Message Streaming
> Note: References to `message-stream.tsx` here are legacy; the current UI uses `message-stream-v2.tsx` with the normalized message store.
## Status: TODO
## Objective
Implement Server-Sent Events (SSE) integration to enable real-time message streaming from OpenCode servers. Each instance will maintain its own EventSource connection to receive live updates for sessions and messages.
## Prerequisites
- Task 006 (Instance/Session tabs) complete
- Task 007 (Message display) complete
- SDK client configured per instance
- Understanding of EventSource API
## Context
The OpenCode server emits events via SSE at the `/events` endpoint. These events include:
- Message updates (streaming content)
- Session updates (new sessions, title changes)
- Tool execution status updates
- Server status changes
We need to:
1. Create an SSE manager to handle connections
2. Connect one EventSource per instance
3. Route events to the correct instance/session
4. Update reactive state to trigger UI updates
5. Implement reconnection logic for dropped connections
## Implementation Steps
### Step 1: Create SSE Manager Module
Create `src/lib/sse-manager.ts`:
```typescript
import { createSignal } from "solid-js"
interface SSEConnection {
instanceId: string
eventSource: EventSource
reconnectAttempts: number
status: "connecting" | "connected" | "disconnected" | "error"
}
interface MessageUpdateEvent {
type: "message_updated"
sessionId: string
messageId: string
parts: any[]
status: string
}
interface SessionUpdateEvent {
type: "session_updated"
session: any
}
class SSEManager {
private connections = new Map<string, SSEConnection>()
private maxReconnectAttempts = 5
private baseReconnectDelay = 1000
connect(instanceId: string, port: number): void {
if (this.connections.has(instanceId)) {
this.disconnect(instanceId)
}
const url = `http://localhost:${port}/events`
const eventSource = new EventSource(url)
const connection: SSEConnection = {
instanceId,
eventSource,
reconnectAttempts: 0,
status: "connecting",
}
this.connections.set(instanceId, connection)
eventSource.onopen = () => {
connection.status = "connected"
connection.reconnectAttempts = 0
console.log(`[SSE] Connected to instance ${instanceId}`)
}
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
this.handleEvent(instanceId, data)
} catch (error) {
console.error("[SSE] Failed to parse event:", error)
}
}
eventSource.onerror = () => {
connection.status = "error"
console.error(`[SSE] Connection error for instance ${instanceId}`)
this.handleReconnect(instanceId, port)
}
}
disconnect(instanceId: string): void {
const connection = this.connections.get(instanceId)
if (connection) {
connection.eventSource.close()
this.connections.delete(instanceId)
console.log(`[SSE] Disconnected from instance ${instanceId}`)
}
}
private handleEvent(instanceId: string, event: any): void {
switch (event.type) {
case "message_updated":
this.onMessageUpdate?.(instanceId, event as MessageUpdateEvent)
break
case "session_updated":
this.onSessionUpdate?.(instanceId, event as SessionUpdateEvent)
break
default:
console.warn("[SSE] Unknown event type:", event.type)
}
}
private handleReconnect(instanceId: string, port: number): void {
const connection = this.connections.get(instanceId)
if (!connection) return
if (connection.reconnectAttempts >= this.maxReconnectAttempts) {
console.error(`[SSE] Max reconnection attempts reached for ${instanceId}`)
connection.status = "disconnected"
return
}
const delay = this.baseReconnectDelay * Math.pow(2, connection.reconnectAttempts)
connection.reconnectAttempts++
console.log(`[SSE] Reconnecting to ${instanceId} in ${delay}ms (attempt ${connection.reconnectAttempts})`)
setTimeout(() => {
this.connect(instanceId, port)
}, delay)
}
onMessageUpdate?: (instanceId: string, event: MessageUpdateEvent) => void
onSessionUpdate?: (instanceId: string, event: SessionUpdateEvent) => void
getStatus(instanceId: string): SSEConnection["status"] | null {
return this.connections.get(instanceId)?.status ?? null
}
}
export const sseManager = new SSEManager()
```
### Step 2: Integrate SSE Manager with Instance Store
Update `src/stores/instances.ts` to use SSE manager:
```typescript
import { sseManager } from "../lib/sse-manager"
// In createInstance function, after SDK client is created:
async function createInstance(folder: string) {
// ... existing code to spawn server and create SDK client ...
// Connect SSE
sseManager.connect(instance.id, port)
// Set up event handlers
sseManager.onMessageUpdate = (instanceId, event) => {
handleMessageUpdate(instanceId, event)
}
sseManager.onSessionUpdate = (instanceId, event) => {
handleSessionUpdate(instanceId, event)
}
}
// In removeInstance function:
async function removeInstance(id: string) {
// Disconnect SSE before removing
sseManager.disconnect(id)
// ... existing cleanup code ...
}
```
### Step 3: Handle Message Update Events
Create message update handler in instance store:
```typescript
function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent) {
const instance = instances.get(instanceId)
if (!instance) return
const session = instance.sessions.get(event.sessionId)
if (!session) return
// Find or create message
let message = session.messages.find((m) => m.id === event.messageId)
if (!message) {
// New message - add it
message = {
id: event.messageId,
sessionId: event.sessionId,
type: "assistant", // Determine from event
parts: event.parts,
timestamp: Date.now(),
status: event.status,
}
session.messages.push(message)
} else {
// Update existing message
message.parts = event.parts
message.status = event.status
}
// Trigger reactivity - update the map reference
instances.set(instanceId, { ...instance })
}
```
### Step 4: Handle Session Update Events
Create session update handler:
```typescript
function handleSessionUpdate(instanceId: string, event: SessionUpdateEvent) {
const instance = instances.get(instanceId)
if (!instance) return
const existingSession = instance.sessions.get(event.session.id)
if (!existingSession) {
// New session - add it
const newSession = {
id: event.session.id,
instanceId,
title: event.session.title || "Untitled",
parentId: event.session.parentId,
agent: event.session.agent,
model: event.session.model,
messages: [],
status: "idle",
createdAt: Date.now(),
updatedAt: Date.now(),
}
instance.sessions.set(event.session.id, newSession)
// Auto-create tab for child sessions
if (event.session.parentId) {
console.log(`[SSE] New child session created: ${event.session.id}`)
// Optionally auto-switch to new session
// instance.activeSessionId = event.session.id
}
} else {
// Update existing session
existingSession.title = event.session.title || existingSession.title
existingSession.agent = event.session.agent || existingSession.agent
existingSession.model = event.session.model || existingSession.model
existingSession.updatedAt = Date.now()
}
// Trigger reactivity
instances.set(instanceId, { ...instance })
}
```
### Step 5: Add Connection Status Indicator
Update `src/components/message-stream.tsx` to show connection status:
```typescript
import { sseManager } from "../lib/sse-manager"
function MessageStream(props) {
const connectionStatus = () => sseManager.getStatus(props.instanceId)
return (
<div class="flex flex-col h-full">
{/* Connection status indicator */}
<div class="flex items-center justify-end px-4 py-2 text-xs text-gray-500">
{connectionStatus() === "connected" && (
<span class="flex items-center gap-1">
<div class="w-2 h-2 bg-green-500 rounded-full" />
Connected
</span>
)}
{connectionStatus() === "connecting" && (
<span class="flex items-center gap-1">
<div class="w-2 h-2 bg-yellow-500 rounded-full animate-pulse" />
Connecting...
</span>
)}
{connectionStatus() === "error" && (
<span class="flex items-center gap-1">
<div class="w-2 h-2 bg-red-500 rounded-full" />
Disconnected
</span>
)}
</div>
{/* Existing message list */}
{/* ... */}
</div>
)
}
```
### Step 6: Test SSE Connection
Create a test utility to verify SSE is working:
```typescript
// In browser console or test file:
async function testSSE() {
// Manually trigger a message
const response = await fetch("http://localhost:4096/session/SESSION_ID/message", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: "Hello, world!",
attachments: [],
}),
})
// Check console for SSE events
// Should see message_updated events arriving
}
```
### Step 7: Handle Edge Cases
Add error handling for:
```typescript
// Connection drops during message streaming
// - Reconnect logic should handle this automatically
// - Messages should resume from last known state
// Multiple instances with different ports
// - Each instance has its own EventSource
// - Events routed correctly via instanceId
// Instance removed while connected
// - EventSource closed before instance cleanup
// - No memory leaks
// Page visibility changes (browser tab inactive)
// - EventSource may pause, reconnect on focus
// - Consider using Page Visibility API to manage connections
```
## Testing Checklist
### Manual Testing
- [ ] Open instance, verify SSE connection established
- [ ] Send message, verify streaming events arrive
- [ ] Check browser DevTools Network tab for SSE connection
- [ ] Verify connection status indicator shows "Connected"
- [ ] Kill server process, verify reconnection attempts
- [ ] Restart server, verify successful reconnection
- [ ] Open multiple instances, verify independent connections
- [ ] Switch between instances, verify events route correctly
- [ ] Close instance tab, verify EventSource closed cleanly
### Testing Message Streaming
- [ ] Send message, watch events in console
- [ ] Verify message parts update in real-time
- [ ] Check assistant response streams character by character
- [ ] Verify tool calls appear as they execute
- [ ] Confirm message status updates (streaming → complete)
### Testing Child Sessions
- [ ] Trigger action that creates child session
- [ ] Verify session_updated event received
- [ ] Confirm new session tab appears
- [ ] Check parentId correctly set
### Testing Reconnection
- [ ] Disconnect network, verify reconnection attempts
- [ ] Reconnect network, verify successful reconnection
- [ ] Verify exponential backoff delays
- [ ] Confirm max attempts limit works
## Acceptance Criteria
- [ ] SSE connection established when instance created
- [ ] Message updates arrive in real-time
- [ ] Session updates handled correctly
- [ ] Child sessions auto-create tabs
- [ ] Connection status visible in UI
- [ ] Reconnection logic works with exponential backoff
- [ ] Multiple instances have independent connections
- [ ] EventSource closed when instance removed
- [ ] No console errors during normal operation
- [ ] Events route to correct instance/session
## Performance Considerations
**Note: Per MVP principles, don't over-optimize**
- Simple event handling - no batching needed
- Direct state updates trigger reactivity
- Reconnection uses exponential backoff
- Only optimize if lag occurs in testing
## Future Enhancements (Post-MVP)
- Event batching for high-frequency updates
- Delta updates instead of full message parts
- Offline queue for events missed during disconnect
- Page Visibility API integration
- Event compression for large payloads
## References
- [Technical Implementation - SSE Event Handling](../docs/technical-implementation.md#sse-event-handling)
- [Architecture - Communication Layer](../docs/architecture.md#communication-layer)
- [MDN - EventSource API](https://developer.mozilla.org/en-US/docs/Web/API/EventSource)
## Estimated Time
3-4 hours
## Notes
- Keep reconnection logic simple for MVP
- Log all SSE events to console for debugging
- Test with long-running streaming responses
- Verify memory usage doesn't grow over time
- Consider adding SSE event debugging panel (optional)

View File

@@ -0,0 +1,520 @@
# Task 009: Prompt Input Basic - Text Input with Send Functionality
## Status: TODO
## Objective
Implement a basic prompt input component that allows users to type messages and send them to the OpenCode server. This enables testing of the SSE integration and completes the core chat interface loop.
## Prerequisites
- Task 007 (Message display) complete
- Task 008 (SSE integration) complete
- Active session available
- SDK client configured
## Context
The prompt input is the primary way users interact with OpenCode. For the MVP, we need:
- Simple text input (multi-line textarea)
- Send button
- Basic keyboard shortcuts (Enter to send, Shift+Enter for new line)
- Loading state while assistant is responding
- Basic validation (empty message prevention)
Advanced features (slash commands, file attachments, @ mentions) will come in Task 021-024.
## Implementation Steps
### Step 1: Create Prompt Input Component
Create `src/components/prompt-input.tsx`:
```typescript
import { createSignal, Show } from "solid-js"
interface PromptInputProps {
instanceId: string
sessionId: string
onSend: (prompt: string) => Promise<void>
disabled?: boolean
}
export default function PromptInput(props: PromptInputProps) {
const [prompt, setPrompt] = createSignal("")
const [sending, setSending] = createSignal(false)
let textareaRef: HTMLTextAreaElement | undefined
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
async function handleSend() {
const text = prompt().trim()
if (!text || sending() || props.disabled) return
setSending(true)
try {
await props.onSend(text)
setPrompt("")
// Auto-resize textarea back to initial size
if (textareaRef) {
textareaRef.style.height = "auto"
}
} catch (error) {
console.error("Failed to send message:", error)
alert("Failed to send message: " + (error instanceof Error ? error.message : String(error)))
} finally {
setSending(false)
textareaRef?.focus()
}
}
function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement
setPrompt(target.value)
// Auto-resize textarea
target.style.height = "auto"
target.style.height = Math.min(target.scrollHeight, 200) + "px"
}
const canSend = () => prompt().trim().length > 0 && !sending() && !props.disabled
return (
<div class="prompt-input-container">
<div class="prompt-input-wrapper">
<textarea
ref={textareaRef}
class="prompt-input"
placeholder="Type your message or /command..."
value={prompt()}
onInput={handleInput}
onKeyDown={handleKeyDown}
disabled={sending() || props.disabled}
rows={1}
/>
<button
class="send-button"
onClick={handleSend}
disabled={!canSend()}
aria-label="Send message"
>
<Show when={sending()} fallback={<span class="send-icon"></span>}>
<span class="spinner-small" />
</Show>
</button>
</div>
<div class="prompt-input-hints">
<span class="hint">
<kbd>Enter</kbd> to send, <kbd>Shift+Enter</kbd> for new line
</span>
</div>
</div>
)
}
```
### Step 2: Add Send Message Function to Sessions Store
Update `src/stores/sessions.ts` to add message sending:
```typescript
async function sendMessage(
instanceId: string,
sessionId: string,
prompt: string,
attachments: string[] = [],
): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId)
if (!session) {
throw new Error("Session not found")
}
// Add user message optimistically
const userMessage: Message = {
id: `temp-${Date.now()}`,
sessionId,
type: "user",
parts: [{ type: "text", text: prompt }],
timestamp: Date.now(),
status: "sending",
}
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = new Map(prev.get(instanceId))
const updatedSession = instanceSessions.get(sessionId)
if (updatedSession) {
const newMessages = [...updatedSession.messages, userMessage]
instanceSessions.set(sessionId, { ...updatedSession, messages: newMessages })
}
next.set(instanceId, instanceSessions)
return next
})
try {
// Send to server using session.prompt (not session.message)
await instance.client.session.prompt({
path: { id: sessionId },
body: {
messageID: userMessage.id,
parts: [
{
type: "text",
text: prompt,
},
],
},
})
// Update user message status
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = new Map(prev.get(instanceId))
const updatedSession = instanceSessions.get(sessionId)
if (updatedSession) {
const messages = updatedSession.messages.map((m) =>
m.id === userMessage.id ? { ...m, status: "sent" as const } : m,
)
instanceSessions.set(sessionId, { ...updatedSession, messages })
}
next.set(instanceId, instanceSessions)
return next
})
} catch (error) {
// Update user message with error
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = new Map(prev.get(instanceId))
const updatedSession = instanceSessions.get(sessionId)
if (updatedSession) {
const messages = updatedSession.messages.map((m) =>
m.id === userMessage.id ? { ...m, status: "error" as const } : m,
)
instanceSessions.set(sessionId, { ...updatedSession, messages })
}
next.set(instanceId, instanceSessions)
return next
})
throw error
}
}
// Export it
export { sendMessage }
```
### Step 3: Integrate Prompt Input into App
Update `src/App.tsx` to add the prompt input:
```typescript
import PromptInput from "./components/prompt-input"
import { sendMessage } from "./stores/sessions"
// In the SessionMessages component or create a new wrapper component
const SessionView: Component<{
sessionId: string
activeSessions: Map<string, Session>
instanceId: string
}> = (props) => {
const session = () => props.activeSessions.get(props.sessionId)
createEffect(() => {
const currentSession = session()
if (currentSession) {
loadMessages(props.instanceId, currentSession.id).catch(console.error)
}
})
async function handleSendMessage(prompt: string) {
await sendMessage(props.instanceId, props.sessionId, prompt)
}
return (
<Show
when={session()}
fallback={
<div class="flex items-center justify-center h-full">
<div class="text-center text-gray-500">Session not found</div>
</div>
}
>
{(s) => (
<div class="session-view">
<MessageStream
instanceId={props.instanceId}
sessionId={s().id}
messages={s().messages || []}
messagesInfo={s().messagesInfo}
/>
<PromptInput
instanceId={props.instanceId}
sessionId={s().id}
onSend={handleSendMessage}
/>
</div>
)}
</Show>
)
}
// Replace SessionMessages usage with SessionView
```
### Step 4: Add Styling
Add to `src/index.css`:
```css
.prompt-input-container {
display: flex;
flex-direction: column;
border-top: 1px solid var(--border-color);
background-color: var(--background);
}
.prompt-input-wrapper {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 12px 16px;
}
.prompt-input {
flex: 1;
min-height: 40px;
max-height: 200px;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
font-family: inherit;
font-size: 14px;
line-height: 1.5;
resize: none;
background-color: var(--background);
color: inherit;
outline: none;
transition: border-color 150ms ease;
}
.prompt-input:focus {
border-color: var(--accent-color);
}
.prompt-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.prompt-input::placeholder {
color: var(--text-muted);
}
.send-button {
width: 40px;
height: 40px;
border-radius: 6px;
background-color: var(--accent-color);
color: white;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition:
opacity 150ms ease,
transform 150ms ease;
flex-shrink: 0;
}
.send-button:hover:not(:disabled) {
opacity: 0.9;
transform: scale(1.05);
}
.send-button:active:not(:disabled) {
transform: scale(0.95);
}
.send-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.send-icon {
font-size: 16px;
}
.spinner-small {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.prompt-input-hints {
padding: 0 16px 8px 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.hint {
font-size: 12px;
color: var(--text-muted);
}
.hint kbd {
display: inline-block;
padding: 2px 6px;
font-size: 11px;
font-family: monospace;
background-color: var(--secondary-bg);
border: 1px solid var(--border-color);
border-radius: 3px;
margin: 0 2px;
}
.session-view {
display: flex;
flex-direction: column;
height: 100%;
}
```
### Step 5: Update Message Display for User Messages
Make sure user messages display correctly in `src/components/message-item.tsx`:
```typescript
// User messages should show with user styling
// Message status should be visible (sending, sent, error)
<Show when={props.message.status === "error"}>
<div class="message-error">Failed to send message</div>
</Show>
<Show when={props.message.status === "sending"}>
<div class="message-sending">
<span class="generating-spinner"></span> Sending...
</div>
</Show>
```
### Step 6: Handle Real-time Response
The SSE integration from Task 008 should automatically:
1. Receive message_updated events
2. Create assistant message in the session
3. Stream message parts as they arrive
4. Update the UI in real-time
No additional code needed - this should "just work" if SSE is connected.
## Testing Checklist
### Basic Functionality
- [ ] Prompt input renders at bottom of session view
- [ ] Can type text in the textarea
- [ ] Textarea auto-expands as you type (up to max height)
- [ ] Send button is disabled when input is empty
- [ ] Send button is enabled when text is present
### Sending Messages
- [ ] Click send button - message appears in stream
- [ ] Press Enter - message sends
- [ ] Press Shift+Enter - adds new line (doesn't send)
- [ ] Input clears after sending
- [ ] Focus returns to input after sending
### User Message Display
- [ ] User message appears immediately (optimistic update)
- [ ] User message shows "Sending..." state briefly
- [ ] User message updates to "sent" after API confirms
- [ ] Error state shows if send fails
### Assistant Response
- [ ] After sending, SSE receives message updates
- [ ] Assistant message appears in stream
- [ ] Message parts stream in real-time
- [ ] Tool calls appear as they execute
- [ ] Connection status indicator shows "Connected"
### Edge Cases
- [ ] Can't send while previous message is processing
- [ ] Empty/whitespace-only messages don't send
- [ ] Very long messages work correctly
- [ ] Multiple rapid sends are queued properly
- [ ] Network error shows helpful message
## Acceptance Criteria
- [ ] Can type and send text messages
- [ ] Enter key sends message
- [ ] Shift+Enter creates new line
- [ ] Send button works correctly
- [ ] User messages appear immediately
- [ ] Assistant responses stream in real-time via SSE
- [ ] Input auto-expands up to max height
- [ ] Loading states are clear
- [ ] Error handling works
- [ ] No console errors during normal operation
## Performance Considerations
**Per MVP principles - keep it simple:**
- Direct API calls - no batching
- Optimistic updates for user messages
- SSE handles streaming automatically
- No debouncing or throttling needed
## Future Enhancements (Post-MVP)
- Slash command autocomplete (Task 021)
- File attachment support (Task 022)
- Drag & drop files (Task 023)
- Attachment chips (Task 024)
- Message history navigation (Task 025)
- Multi-line paste handling
- Rich text formatting
- Message drafts persistence
## References
- [User Interface - Prompt Input](../docs/user-interface.md#5-prompt-input)
- [Technical Implementation - Message Rendering](../docs/technical-implementation.md#message-rendering)
- [Task 008 - SSE Integration](./008-sse-integration.md)
## Estimated Time
2-3 hours
## Notes
- Focus on core functionality - no fancy features yet
- Test thoroughly with SSE to ensure real-time streaming works
- This completes the basic chat loop - users can now interact with OpenCode
- Keep error messages user-friendly and actionable
- Ensure keyboard shortcuts work as expected

View File

@@ -0,0 +1,603 @@
# Task 010: Tool Call Rendering - Display Tool Executions Inline
## Status: TODO
## Objective
Implement interactive tool call rendering that displays tool executions inline within assistant messages. Users should be able to expand/collapse tool calls to see input, output, and execution status.
## Prerequisites
- Task 007 (Message display) complete
- Task 008 (SSE integration) complete
- Task 009 (Prompt input) complete
- Messages streaming from API
- Tool call data available in message parts
## Context
When OpenCode executes tools (bash commands, file edits, etc.), these should be visible to the user in the message stream. Tool calls need:
- Collapsed state showing summary (tool name + brief description)
- Expanded state showing full input/output
- Status indicators (pending, running, success, error)
- Click to toggle expand/collapse
- Syntax highlighting for code in input/output
This provides transparency into what OpenCode is doing and helps users understand the assistant's actions.
## Implementation Steps
### Step 1: Define Tool Call Types
Create or update `src/types/message.ts`:
```typescript
export interface ToolCallPart {
type: "tool_call"
id: string
tool: string
input: any
output?: any
status: "pending" | "running" | "success" | "error"
error?: string
}
export interface MessagePart {
type: "text" | "tool_call"
text?: string
id?: string
tool?: string
input?: any
output?: any
status?: "pending" | "running" | "success" | "error"
error?: string
}
```
### Step 2: Create Tool Call Component
Create `src/components/tool-call.tsx`:
```typescript
import { createSignal, Show, Switch, Match } from "solid-js"
import type { ToolCallPart } from "../types/message"
interface ToolCallProps {
part: ToolCallPart
}
export default function ToolCall(props: ToolCallProps) {
const [expanded, setExpanded] = createSignal(false)
function toggleExpanded() {
setExpanded(!expanded())
}
function getToolIcon(tool: string): string {
switch (tool) {
case "bash":
return "⚡"
case "edit":
return "✏️"
case "read":
return "📖"
case "write":
return "📝"
case "glob":
return "🔍"
case "grep":
return "🔎"
default:
return "🔧"
}
}
function getStatusIcon(status: string): string {
switch (status) {
case "pending":
return "⏳"
case "running":
return "⟳"
case "success":
return "✓"
case "error":
return "✗"
default:
return ""
}
}
function getToolSummary(part: ToolCallPart): string {
const { tool, input } = part
switch (tool) {
case "bash":
return input?.command || "Execute command"
case "edit":
return `Edit ${input?.filePath || "file"}`
case "read":
return `Read ${input?.filePath || "file"}`
case "write":
return `Write ${input?.filePath || "file"}`
case "glob":
return `Find ${input?.pattern || "files"}`
case "grep":
return `Search for "${input?.pattern || "pattern"}"`
default:
return tool
}
}
function formatJson(obj: any): string {
if (typeof obj === "string") return obj
return JSON.stringify(obj, null, 2)
}
return (
<div
class="tool-call"
classList={{
"tool-call-expanded": expanded(),
"tool-call-error": props.part.status === "error",
"tool-call-success": props.part.status === "success",
"tool-call-running": props.part.status === "running",
}}
onClick={toggleExpanded}
>
<div class="tool-call-header">
<span class="tool-call-expand-icon">{expanded() ? "▼" : "▶"}</span>
<span class="tool-call-icon">{getToolIcon(props.part.tool)}</span>
<span class="tool-call-tool">{props.part.tool}:</span>
<span class="tool-call-summary">{getToolSummary(props.part)}</span>
<span class="tool-call-status">{getStatusIcon(props.part.status)}</span>
</div>
<Show when={expanded()}>
<div class="tool-call-body" onClick={(e) => e.stopPropagation()}>
<Show when={props.part.input}>
<div class="tool-call-section">
<div class="tool-call-section-title">Input:</div>
<pre class="tool-call-content">
<code>{formatJson(props.part.input)}</code>
</pre>
</div>
</Show>
<Show when={props.part.output !== undefined}>
<div class="tool-call-section">
<div class="tool-call-section-title">Output:</div>
<pre class="tool-call-content">
<code>{formatJson(props.part.output)}</code>
</pre>
</div>
</Show>
<Show when={props.part.error}>
<div class="tool-call-section tool-call-error-section">
<div class="tool-call-section-title">Error:</div>
<pre class="tool-call-content tool-call-error-content">
<code>{props.part.error}</code>
</pre>
</div>
</Show>
<Show when={props.part.status === "running"}>
<div class="tool-call-running-indicator">
<span class="spinner-small" />
<span>Executing...</span>
</div>
</Show>
</div>
</Show>
</div>
)
}
```
### Step 3: Update Message Item to Render Tool Calls
Update `src/components/message-item.tsx`:
```typescript
import { For, Show, Switch, Match } from "solid-js"
import type { Message, MessagePart } from "../types/message"
import ToolCall from "./tool-call"
interface MessageItemProps {
message: Message
}
export default function MessageItem(props: MessageItemProps) {
const isUser = () => props.message.type === "user"
return (
<div
class="message-item"
classList={{
"message-user": isUser(),
"message-assistant": !isUser(),
}}
>
<div class="message-header">
<span class="message-author">{isUser() ? "You" : "Assistant"}</span>
<span class="message-timestamp">
{new Date(props.message.timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
<div class="message-content">
<For each={props.message.parts}>
{(part) => (
<Switch>
<Match when={part.type === "text"}>
<div class="message-text">{part.text}</div>
</Match>
<Match when={part.type === "tool_call"}>
<ToolCall part={part as any} />
</Match>
</Switch>
)}
</For>
</div>
<Show when={props.message.status === "error"}>
<div class="message-error">Failed to send message</div>
</Show>
<Show when={props.message.status === "sending"}>
<div class="message-sending">
<span class="generating-spinner"></span> Sending...
</div>
</Show>
</div>
)
}
```
### Step 4: Add Tool Call Styling
Add to `src/index.css`:
```css
/* Tool Call Styles */
.tool-call {
margin: 8px 0;
border: 1px solid var(--border-color);
border-radius: 6px;
background-color: var(--secondary-bg);
overflow: hidden;
cursor: pointer;
transition:
border-color 150ms ease,
background-color 150ms ease;
}
.tool-call:hover {
border-color: var(--accent-color);
}
.tool-call-expanded {
cursor: default;
}
.tool-call-success {
border-left: 3px solid #10b981;
}
.tool-call-error {
border-left: 3px solid #ef4444;
}
.tool-call-running {
border-left: 3px solid var(--accent-color);
}
.tool-call-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
font-size: 13px;
}
.tool-call-expand-icon {
font-size: 10px;
color: var(--text-muted);
transition: transform 150ms ease;
}
.tool-call-expanded .tool-call-expand-icon {
transform: rotate(0deg);
}
.tool-call-icon {
font-size: 14px;
}
.tool-call-tool {
font-weight: 600;
color: var(--text);
}
.tool-call-summary {
flex: 1;
color: var(--text-muted);
font-family: monospace;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tool-call-status {
font-size: 14px;
margin-left: auto;
}
.tool-call-body {
border-top: 1px solid var(--border-color);
padding: 12px;
background-color: var(--background);
}
.tool-call-section {
margin-bottom: 12px;
}
.tool-call-section:last-child {
margin-bottom: 0;
}
.tool-call-section-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 6px;
letter-spacing: 0.5px;
}
.tool-call-content {
background-color: var(--secondary-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 8px 12px;
font-family: monospace;
font-size: 12px;
line-height: 1.5;
overflow-x: auto;
margin: 0;
}
.tool-call-content code {
font-family: inherit;
background: none;
padding: 0;
}
.tool-call-error-section {
background-color: rgba(239, 68, 68, 0.05);
border-radius: 4px;
padding: 8px;
}
.tool-call-error-content {
background-color: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
color: #dc2626;
}
.tool-call-running-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background-color: var(--secondary-bg);
border-radius: 4px;
font-size: 13px;
color: var(--text-muted);
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Dark mode adjustments */
@media (prefers-color-scheme: dark) {
.tool-call {
background-color: rgba(255, 255, 255, 0.03);
}
.tool-call-body {
background-color: rgba(0, 0, 0, 0.2);
}
.tool-call-content {
background-color: rgba(0, 0, 0, 0.3);
}
}
```
### Step 5: Update SSE Handler to Parse Tool Calls
Update `src/lib/sse-manager.ts` to correctly parse tool call parts from SSE events:
```typescript
function handleMessageUpdate(event: MessageUpdateEvent, instanceId: string) {
// When a message part arrives via SSE, check if it's a tool call
const part = event.part
if (part.type === "tool_call") {
// Parse tool call data
const toolCallPart: ToolCallPart = {
type: "tool_call",
id: part.id || `tool-${Date.now()}`,
tool: part.tool || "unknown",
input: part.input,
output: part.output,
status: part.status || "pending",
error: part.error,
}
// Add or update in messages
updateMessagePart(instanceId, event.sessionId, event.messageId, toolCallPart)
}
}
```
### Step 6: Handle Tool Call Updates
Ensure that tool calls can update their status as they execute:
```typescript
// In sessions store
function updateMessagePart(instanceId: string, sessionId: string, messageId: string, part: MessagePart) {
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = new Map(prev.get(instanceId))
const session = instanceSessions.get(sessionId)
if (session) {
const messages = session.messages.map((msg) => {
if (msg.id === messageId) {
// Find existing part by ID and update, or append
const partIndex = msg.parts.findIndex((p) => p.type === "tool_call" && p.id === part.id)
if (partIndex !== -1) {
const updatedParts = [...msg.parts]
updatedParts[partIndex] = part
return { ...msg, parts: updatedParts }
} else {
return { ...msg, parts: [...msg.parts, part] }
}
}
return msg
})
instanceSessions.set(sessionId, { ...session, messages })
}
next.set(instanceId, instanceSessions)
return next
})
}
```
## Testing Checklist
### Visual Rendering
- [ ] Tool calls render in collapsed state by default
- [ ] Tool icon displays correctly for each tool type
- [ ] Tool summary shows meaningful description
- [ ] Status icon displays correctly (pending, running, success, error)
- [ ] Styling is consistent with design
### Expand/Collapse
- [ ] Click tool call header - expands to show details
- [ ] Click again - collapses back to summary
- [ ] Expand icon rotates correctly
- [ ] Clicking inside expanded body doesn't collapse
- [ ] Multiple tool calls can be expanded independently
### Content Display
- [ ] Input section shows tool input data
- [ ] Output section shows tool output data
- [ ] JSON is formatted with proper indentation
- [ ] Code/text is displayed in monospace font
- [ ] Long output is scrollable horizontally
### Status Indicators
- [ ] Pending status shows waiting icon (⏳)
- [ ] Running status shows spinner and "Executing..."
- [ ] Success status shows checkmark (✓)
- [ ] Error status shows X (✗) and error message
- [ ] Border color changes based on status
### Real-time Updates
- [ ] Tool calls appear as SSE events arrive
- [ ] Status updates from pending → running → success
- [ ] Output appears when tool completes
- [ ] Error state shows if tool fails
- [ ] UI updates smoothly without flashing
### Different Tool Types
- [ ] Bash commands display correctly
- [ ] File edits show file path and changes
- [ ] File reads show file path
- [ ] Glob/grep show patterns
- [ ] Unknown tools have fallback icon
### Error Handling
- [ ] Tool errors display error message
- [ ] Error section has red styling
- [ ] Error state is clearly visible
- [ ] Can expand to see full error details
## Acceptance Criteria
- [ ] Tool calls render inline in assistant messages
- [ ] Default collapsed state shows summary
- [ ] Click to expand shows full input/output
- [ ] Status indicators work correctly
- [ ] Real-time updates via SSE work
- [ ] Multiple tool calls in one message work
- [ ] Error states are clear and helpful
- [ ] Styling matches design specifications
- [ ] No performance issues with many tool calls
- [ ] No console errors during normal operation
## Performance Considerations
**Per MVP principles - keep it simple:**
- Render all tool calls - no virtualization
- No lazy loading of tool content
- Simple JSON.stringify for formatting
- Direct DOM updates via SolidJS reactivity
- Add optimizations only if problems arise
## Future Enhancements (Post-MVP)
- Syntax highlighting for code in input/output (using Shiki)
- Diff view for file edits
- Copy button for tool output
- Link to file in file operations
- Collapsible sections within tool calls
- Tool execution time display
- Retry failed tools
- Export tool output
## References
- [User Interface - Tool Call Rendering](../docs/user-interface.md#3-messages-area)
- [Technical Implementation - Tool Call Rendering](../docs/technical-implementation.md#message-rendering)
- [Build Roadmap - Phase 2](../docs/build-roadmap.md#phase-2-core-chat-interface-week-2)
## Estimated Time
3-4 hours
## Notes
- Focus on clear visual hierarchy - collapsed view should be scannable
- Status indicators help users understand what's happening
- Errors should be prominent but not alarming
- Tool calls are a key differentiator - make them shine
- Test with real OpenCode responses to ensure data format matches
- Consider adding debug logging to verify SSE data structure

View File

@@ -0,0 +1,527 @@
# Task 011: Agent and Model Selectors
## Goal
Implement dropdown selectors for switching agents and models in the active session. These controls appear in the control bar above the prompt input and allow users to change the agent or model for the current conversation.
## Prerequisites
- Task 010 (Tool Call Rendering) completed
- Session state management implemented
- SDK client integration functional
- UI components library (Kobalte) configured
## Acceptance Criteria
- [x] Agent selector dropdown displays current agent
- [x] Agent dropdown lists all available agents
- [x] Selecting agent updates session configuration
- [x] Model selector dropdown displays current model
- [x] Model dropdown lists all available models (flat list with provider name shown)
- [x] Selecting model updates session configuration
- [x] Changes persist across app restarts (stored in session state)
- [x] Loading states during fetch/update (automatic via createEffect)
- [x] Error handling for failed updates (logged to console)
- [x] Keyboard navigation works (provided by Kobalte Select)
- [x] Visual feedback on selection change
## Implementation Notes
**Completed:** All acceptance criteria met with the following implementation details:
1. **Agent Selector** (`src/components/agent-selector.tsx`):
- Uses Kobalte Select component for accessibility
- Fetches agents via `fetchAgents()` on mount
- Displays agent name and description
- Light mode styling matching the rest of the app
- Compact size (text-xs, smaller padding) for bottom placement
- Updates session state locally (agent/model are sent with each prompt, not via separate update API)
2. **Model Selector** (`src/components/model-selector.tsx`):
- Uses Kobalte Select component for accessibility
- Fetches providers and models via `fetchProviders()` on mount
- Flattens model list from all providers for easier selection
- Shows provider name alongside model name
- **Search functionality** - inline search input at top of dropdown
- Filters models by name, provider name, or model ID
- Shows "No models found" message when no matches
- Clears search query when model is selected
- Light mode styling matching the rest of the app
- Compact size (text-xs, smaller padding) for bottom placement
- Updates session state locally
3. **Integration** (`src/components/prompt-input.tsx`):
- Integrated selectors directly into prompt input hints area
- Positioned bottom right, on same line as "Enter to send" hint
- Removed separate controls-bar component for cleaner integration
- Passes agent/model props and change handlers from parent
4. **Session Store Updates** (`src/stores/sessions.ts`):
- Added `updateSessionAgent()` - updates session agent locally
- Added `updateSessionModel()` - updates session model locally
- Note: The SDK doesn't support updating agent/model via separate API calls
- Agent and model are sent with each prompt via the `sendMessage()` function
5. **Integration** (`src/App.tsx`):
- Passes agent, model, and change handlers to PromptInput
- SessionView component updated with new props
**Design Decisions:**
- Simplified model selector to use flat list instead of grouped (Kobalte 0.13.11 Select doesn't support groups)
- Agent and model changes are stored locally and sent with each prompt request
- No separate API call to update session configuration (matches SDK limitations)
- Used SolidJS's `createEffect` for automatic data fetching on component mount
- Integrated controls into prompt input area rather than separate bar for better space usage
- Positioned bottom right on hints line for easy access without obscuring content
- Light mode only styling (removed dark mode classes) to match existing app design
- Compact sizing (text-xs, reduced padding) to fit naturally in the hints area
- Search input with icon in sticky header at top of model dropdown
- Real-time filtering across model name, provider name, and model ID
- Search preserves dropdown open state and clears on selection
## Steps
### 1. Define Types
Create `src/types/config.ts`:
```typescript
interface Agent {
id: string
name: string
description: string
}
interface Model {
providerId: string
modelId: string
name: string
contextWindow?: number
capabilities?: string[]
}
interface ModelProvider {
id: string
name: string
models: Model[]
}
```
### 2. Fetch Available Options
Extend SDK hooks in `src/hooks/use-session.ts`:
```typescript
function useAgents(instanceId: string) {
const [agents, setAgents] = createSignal<Agent[]>([])
const [loading, setLoading] = createSignal(true)
const [error, setError] = createSignal<Error | null>(null)
createEffect(() => {
const client = getClient(instanceId)
if (!client) return
setLoading(true)
client.config
.agents()
.then(setAgents)
.catch(setError)
.finally(() => setLoading(false))
})
return { agents, loading, error }
}
function useModels(instanceId: string) {
const [providers, setProviders] = createSignal<ModelProvider[]>([])
const [loading, setLoading] = createSignal(true)
const [error, setError] = createSignal<Error | null>(null)
createEffect(() => {
const client = getClient(instanceId)
if (!client) return
setLoading(true)
client.config
.models()
.then((data) => {
// Group models by provider
const grouped = groupModelsByProvider(data)
setProviders(grouped)
})
.catch(setError)
.finally(() => setLoading(false))
})
return { providers, loading, error }
}
```
### 3. Create Agent Selector Component
Create `src/components/agent-selector.tsx`:
```typescript
import { Select } from '@kobalte/core'
import { createMemo } from 'solid-js'
import { useAgents } from '../hooks/use-session'
interface AgentSelectorProps {
instanceId: string
sessionId: string
currentAgent: string
onAgentChange: (agent: string) => void
}
export function AgentSelector(props: AgentSelectorProps) {
const { agents, loading, error } = useAgents(props.instanceId)
const currentAgentInfo = createMemo(() =>
agents().find(a => a.id === props.currentAgent)
)
return (
<Select.Root
value={props.currentAgent}
onChange={props.onAgentChange}
options={agents()}
optionValue="id"
optionTextValue="name"
placeholder="Select agent..."
itemComponent={props => (
<Select.Item item={props.item} class="px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer">
<Select.ItemLabel class="font-medium">{props.item.rawValue.name}</Select.ItemLabel>
<Select.ItemDescription class="text-sm text-gray-600 dark:text-gray-400">
{props.item.rawValue.description}
</Select.ItemDescription>
</Select.Item>
)}
>
<Select.Trigger class="inline-flex items-center justify-between px-4 py-2 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800">
<Select.Value<Agent>>
{state => (
<span class="text-sm">
Agent: {state.selectedOption()?.name ?? 'Select...'}
</span>
)}
</Select.Value>
<Select.Icon class="ml-2">
<ChevronDownIcon class="w-4 h-4" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content class="bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-md shadow-lg max-h-80 overflow-auto">
<Select.Listbox />
</Select.Content>
</Select.Portal>
</Select.Root>
)
}
```
### 4. Create Model Selector Component
Create `src/components/model-selector.tsx`:
```typescript
import { Select } from '@kobalte/core'
import { For, createMemo } from 'solid-js'
import { useModels } from '../hooks/use-session'
interface ModelSelectorProps {
instanceId: string
sessionId: string
currentModel: { providerId: string; modelId: string }
onModelChange: (model: { providerId: string; modelId: string }) => void
}
export function ModelSelector(props: ModelSelectorProps) {
const { providers, loading, error } = useModels(props.instanceId)
const allModels = createMemo(() =>
providers().flatMap(p => p.models.map(m => ({ ...m, provider: p.name })))
)
const currentModelInfo = createMemo(() =>
allModels().find(
m => m.providerId === props.currentModel.providerId &&
m.modelId === props.currentModel.modelId
)
)
return (
<Select.Root
value={`${props.currentModel.providerId}/${props.currentModel.modelId}`}
onChange={value => {
const [providerId, modelId] = value.split('/')
props.onModelChange({ providerId, modelId })
}}
options={allModels()}
optionValue={m => `${m.providerId}/${m.modelId}`}
optionTextValue="name"
placeholder="Select model..."
itemComponent={props => (
<Select.Item
item={props.item}
class="px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
>
<Select.ItemLabel class="font-medium">
{props.item.rawValue.name}
</Select.ItemLabel>
<Select.ItemDescription class="text-xs text-gray-600 dark:text-gray-400">
{props.item.rawValue.provider}
{props.item.rawValue.contextWindow &&
`${(props.item.rawValue.contextWindow / 1000).toFixed(0)}k context`
}
</Select.ItemDescription>
</Select.Item>
)}
>
<Select.Trigger class="inline-flex items-center justify-between px-4 py-2 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800">
<Select.Value<Model>>
{state => (
<span class="text-sm">
Model: {state.selectedOption()?.name ?? 'Select...'}
</span>
)}
</Select.Value>
<Select.Icon class="ml-2">
<ChevronDownIcon class="w-4 h-4" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content class="bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-md shadow-lg max-h-80 overflow-auto">
<For each={providers()}>
{provider => (
<>
<Select.Group>
<Select.GroupLabel class="px-3 py-1 text-xs font-semibold text-gray-500 dark:text-gray-500 uppercase">
{provider.name}
</Select.GroupLabel>
<For each={provider.models}>
{model => (
<Select.Item
value={`${model.providerId}/${model.modelId}`}
class="px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
>
<Select.ItemLabel>{model.name}</Select.ItemLabel>
</Select.Item>
)}
</For>
</Select.Group>
</>
)}
</For>
</Select.Content>
</Select.Portal>
</Select.Root>
)
}
```
### 5. Create Controls Bar Component
Create `src/components/controls-bar.tsx`:
```typescript
import { AgentSelector } from './agent-selector'
import { ModelSelector } from './model-selector'
interface ControlsBarProps {
instanceId: string
sessionId: string
currentAgent: string
currentModel: { providerId: string; modelId: string }
onAgentChange: (agent: string) => Promise<void>
onModelChange: (model: { providerId: string; modelId: string }) => Promise<void>
}
export function ControlsBar(props: ControlsBarProps) {
const handleAgentChange = async (agent: string) => {
try {
await props.onAgentChange(agent)
} catch (error) {
console.error('Failed to change agent:', error)
// Show error toast
}
}
const handleModelChange = async (model: { providerId: string; modelId: string }) => {
try {
await props.onModelChange(model)
} catch (error) {
console.error('Failed to change model:', error)
// Show error toast
}
}
return (
<div class="flex items-center gap-4 px-4 py-2 border-t border-gray-200 dark:border-gray-800">
<AgentSelector
instanceId={props.instanceId}
sessionId={props.sessionId}
currentAgent={props.currentAgent}
onAgentChange={handleAgentChange}
/>
<ModelSelector
instanceId={props.instanceId}
sessionId={props.sessionId}
currentModel={props.currentModel}
onModelChange={handleModelChange}
/>
</div>
)
}
```
### 6. Add Update Methods to Session Hook
Extend `src/hooks/use-session.ts`:
```typescript
async function updateSessionAgent(instanceId: string, sessionId: string, agent: string) {
const client = getClient(instanceId)
if (!client) throw new Error("Client not found")
await client.session.update(sessionId, { agent })
// Update local state
const session = getSession(instanceId, sessionId)
if (session) {
session.agent = agent
}
}
async function updateSessionModel(
instanceId: string,
sessionId: string,
model: { providerId: string; modelId: string },
) {
const client = getClient(instanceId)
if (!client) throw new Error("Client not found")
await client.session.update(sessionId, { model })
// Update local state
const session = getSession(instanceId, sessionId)
if (session) {
session.model = model
}
}
```
### 7. Integrate into Main Layout
Update the session view component to include controls bar:
```typescript
function SessionView(props: { instanceId: string; sessionId: string }) {
const session = () => getSession(props.instanceId, props.sessionId)
return (
<div class="flex flex-col h-full">
{/* Messages area */}
<div class="flex-1 overflow-auto">
<MessageStream
instanceId={props.instanceId}
sessionId={props.sessionId}
/>
</div>
{/* Controls bar */}
<ControlsBar
instanceId={props.instanceId}
sessionId={props.sessionId}
currentAgent={session()?.agent}
currentModel={session()?.model}
onAgentChange={agent => updateSessionAgent(props.instanceId, props.sessionId, agent)}
onModelChange={model => updateSessionModel(props.instanceId, props.sessionId, model)}
/>
{/* Prompt input */}
<PromptInput
instanceId={props.instanceId}
sessionId={props.sessionId}
/>
</div>
)
}
```
### 8. Add Loading and Error States
Enhance selectors with loading states:
```typescript
// In AgentSelector
<Show when={loading()}>
<div class="px-4 py-2 text-sm text-gray-500">Loading agents...</div>
</Show>
<Show when={error()}>
<div class="px-4 py-2 text-sm text-red-500">
Failed to load agents: {error()?.message}
</div>
</Show>
```
### 9. Style Dropdowns
Add Tailwind classes for:
- Dropdown trigger button
- Dropdown content panel
- Option items
- Hover states
- Selected state
- Keyboard focus states
- Dark mode variants
### 10. Add Keyboard Navigation
Ensure Kobalte Select handles:
- Arrow up/down: Navigate options
- Enter: Select option
- Escape: Close dropdown
- Tab: Move to next control
## Verification Steps
1. Launch app with an active session
2. Verify current agent displays in selector
3. Click agent selector
4. Verify dropdown opens with agent list
5. Select different agent
6. Verify session updates (check network request)
7. Verify selector shows new agent
8. Repeat for model selector
9. Test keyboard navigation
10. Test with long agent/model names
11. Test error state (disconnect network)
12. Test loading state (slow network)
13. Verify changes persist on session switch
14. Verify changes persist on app restart
## Dependencies for Next Tasks
- Task 012 (Markdown Rendering) can proceed independently
- Task 013 (Logs Tab) can proceed independently
- This completes session configuration UI
## Estimated Time
3-4 hours
## Notes
- Use Kobalte Select component for accessibility
- Group models by provider for better UX
- Show relevant model metadata (context window, capabilities)
- Consider caching agents/models list per instance
- Handle case where current agent/model is no longer available
- Future: Add search/filter for large model lists
- Future: Show model pricing information

View File

@@ -0,0 +1,417 @@
# Task 012: Markdown Rendering
**Status:** Todo
**Estimated Time:** 3-4 hours
**Phase:** 3 - Essential Features
**Dependencies:** 007 (Message Display)
## Overview
Implement proper markdown rendering for assistant messages with syntax-highlighted code blocks. Replace basic text display with rich markdown formatting using Marked and Shiki.
## Context
Currently messages display as plain text. We need to parse and render markdown content from assistant messages, including:
- Headings, bold, italic, links
- Code blocks with syntax highlighting
- Inline code
- Lists (ordered and unordered)
- Blockquotes
- Tables (if needed)
## Requirements
### Functional Requirements
1. **Markdown Parser Integration**
- Use `marked` library for markdown parsing
- Configure for safe HTML rendering
- Support GitHub-flavored markdown
2. **Syntax Highlighting**
- Use `shiki` for code block highlighting
- Support light and dark themes
- Support common languages: TypeScript, JavaScript, Python, Bash, JSON, HTML, CSS, etc.
3. **Code Block Features**
- Language label displayed
- Copy button on hover
- Line numbers (optional for MVP)
4. **Inline Code**
- Distinct background color
- Monospace font
- Subtle padding
5. **Links**
- Open in external browser
- Show external link icon
- Prevent opening in same window
### Technical Requirements
1. **Dependencies**
- Install `marked` and `@types/marked`
- Install `shiki`
- Install `marked-highlight` for integration
2. **Theme Support**
- Light mode: `github-light` theme
- Dark mode: `github-dark` theme
- Respect system theme preference
3. **Security**
- Sanitize HTML output
- No script execution
- Safe link handling
4. **Performance**
- Lazy load Shiki highlighter
- Cache highlighter instance
- Don't re-parse unchanged messages
## Implementation Steps
### Step 1: Install Dependencies
```bash
cd packages/opencode-client
npm install marked shiki
npm install -D @types/marked
```
### Step 2: Create Markdown Utility
Create `src/lib/markdown.ts`:
```typescript
import { marked } from "marked"
import { getHighlighter, type Highlighter } from "shiki"
let highlighter: Highlighter | null = null
async function getOrCreateHighlighter() {
if (!highlighter) {
highlighter = await getHighlighter({
themes: ["github-light", "github-dark"],
langs: ["typescript", "javascript", "python", "bash", "json", "html", "css", "markdown", "yaml", "sql"],
})
}
return highlighter
}
export async function initMarkdown(isDark: boolean) {
const hl = await getOrCreateHighlighter()
marked.use({
async: false,
breaks: true,
gfm: true,
})
const renderer = new marked.Renderer()
renderer.code = (code: string, language: string | undefined) => {
if (!language) {
return `<pre><code>${escapeHtml(code)}</code></pre>`
}
try {
const html = hl.codeToHtml(code, {
lang: language,
theme: isDark ? "github-dark" : "github-light",
})
return html
} catch (e) {
return `<pre><code class="language-${language}">${escapeHtml(code)}</code></pre>`
}
}
renderer.link = (href: string, title: string | null, text: string) => {
const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""
return `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>`
}
marked.use({ renderer })
}
export function renderMarkdown(content: string): string {
return marked.parse(content) as string
}
function escapeHtml(text: string): string {
const map: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
}
return text.replace(/[&<>"']/g, (m) => map[m])
}
```
### Step 3: Create Markdown Component
Create `src/components/markdown.tsx`:
```typescript
import { createEffect, createSignal, onMount } from 'solid-js'
import { initMarkdown, renderMarkdown } from '../lib/markdown'
interface MarkdownProps {
content: string
isDark?: boolean
}
export function Markdown(props: MarkdownProps) {
const [html, setHtml] = createSignal('')
const [ready, setReady] = createSignal(false)
onMount(async () => {
await initMarkdown(props.isDark ?? false)
setReady(true)
})
createEffect(() => {
if (ready()) {
const rendered = renderMarkdown(props.content)
setHtml(rendered)
}
})
createEffect(async () => {
if (props.isDark !== undefined) {
await initMarkdown(props.isDark)
const rendered = renderMarkdown(props.content)
setHtml(rendered)
}
})
return (
<div
class="prose prose-sm dark:prose-invert max-w-none"
innerHTML={html()}
/>
)
}
```
### Step 4: Add Copy Button to Code Blocks
Create `src/components/code-block.tsx`:
```typescript
import { createSignal, Show } from 'solid-js'
interface CodeBlockProps {
code: string
language?: string
}
export function CodeBlockWrapper(props: CodeBlockProps) {
const [copied, setCopied] = createSignal(false)
const copyCode = async () => {
await navigator.clipboard.writeText(props.code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div class="relative group">
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={copyCode}
class="px-2 py-1 text-xs bg-gray-700 text-white rounded hover:bg-gray-600"
>
<Show when={copied()} fallback="Copy">
Copied!
</Show>
</button>
</div>
<Show when={props.language}>
<div class="text-xs text-gray-500 dark:text-gray-400 px-4 pt-2">
{props.language}
</div>
</Show>
<div innerHTML={props.code} />
</div>
)
}
```
### Step 5: Update Message Component
Update `src/components/message-item.tsx` to use Markdown component:
```typescript
import { Markdown } from './markdown'
// In the assistant message rendering:
<For each={textParts()}>
{(part) => (
<Markdown
content={part.content}
isDark={/* get from theme context */}
/>
)}
</For>
```
### Step 6: Add Markdown Styles
Add to `src/index.css`:
```css
/* Markdown prose styles */
.prose {
@apply text-gray-900 dark:text-gray-100;
}
.prose code {
@apply bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-sm;
}
.prose pre {
@apply bg-gray-50 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto;
}
.prose pre code {
@apply bg-transparent p-0;
}
.prose a {
@apply text-blue-600 dark:text-blue-400 hover:underline;
}
.prose blockquote {
@apply border-l-4 border-gray-300 dark:border-gray-700 pl-4 italic;
}
.prose ul {
@apply list-disc list-inside;
}
.prose ol {
@apply list-decimal list-inside;
}
.prose h1 {
@apply text-2xl font-bold mb-4;
}
.prose h2 {
@apply text-xl font-bold mb-3;
}
.prose h3 {
@apply text-lg font-bold mb-2;
}
.prose table {
@apply border-collapse w-full;
}
.prose th {
@apply border border-gray-300 dark:border-gray-700 px-4 py-2 bg-gray-100 dark:bg-gray-800;
}
.prose td {
@apply border border-gray-300 dark:border-gray-700 px-4 py-2;
}
```
### Step 7: Handle Theme Changes
Create or update theme context to track light/dark mode:
```typescript
import { createContext, createSignal, useContext } from 'solid-js'
const ThemeContext = createContext<{
isDark: () => boolean
toggleTheme: () => void
}>()
export function ThemeProvider(props: { children: any }) {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
const [isDark, setIsDark] = createSignal(prefersDark)
const toggleTheme = () => {
setIsDark(!isDark())
document.documentElement.classList.toggle('dark')
}
return (
<ThemeContext.Provider value={{ isDark, toggleTheme }}>
{props.children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) {
throw new Error('useTheme must be used within ThemeProvider')
}
return context
}
```
### Step 8: Test Markdown Rendering
Test with various markdown inputs:
1. **Headings**: `# Heading 1\n## Heading 2`
2. **Code blocks**: ` ```typescript\nconst x = 1\n``` `
3. **Inline code**: `` `npm install` ``
4. **Lists**: `- Item 1\n- Item 2`
5. **Links**: `[OpenCode](https://opencode.ai)`
6. **Bold/Italic**: `**bold** and *italic*`
7. **Blockquotes**: `> Quote`
## Acceptance Criteria
- [ ] Markdown content renders with proper formatting
- [ ] Code blocks have syntax highlighting
- [ ] Light and dark themes work correctly
- [ ] Copy button appears on code block hover
- [ ] Copy button successfully copies code to clipboard
- [ ] Language label shows for code blocks
- [ ] Inline code has distinct styling
- [ ] Links open in external browser
- [ ] No XSS vulnerabilities (sanitized output)
- [ ] Theme changes update code highlighting
- [ ] Headings, lists, blockquotes render correctly
- [ ] Performance is acceptable (no lag when rendering)
## Testing Checklist
- [ ] Test all markdown syntax types
- [ ] Test code blocks with various languages
- [ ] Test switching between light and dark mode
- [ ] Test copy functionality
- [ ] Test external link opening
- [ ] Test very long code blocks (scrolling)
- [ ] Test malformed markdown
- [ ] Test HTML in markdown (should be escaped)
## Notes
- Shiki loads language grammars asynchronously, so first render may be slower
- Consider caching rendered markdown if re-rendering same content
- For MVP, don't implement line numbers or advanced code block features
- Keep the language list limited to common ones to reduce bundle size
## Future Enhancements (Post-MVP)
- Line numbers in code blocks
- Code block diff highlighting
- Collapsible long code blocks
- Search within code blocks
- More language support
- Custom syntax themes
- LaTeX/Math rendering
- Mermaid diagram support

479
tasks/done/013-logs-tab.md Normal file
View File

@@ -0,0 +1,479 @@
# Task 013: Logs Tab
**Status:** Todo
**Estimated Time:** 2-3 hours
**Phase:** 3 - Essential Features
**Dependencies:** 006 (Instance & Session Tabs)
## Overview
Implement a dedicated "Logs" tab for each instance that displays real-time server logs (stdout/stderr). This provides visibility into what the OpenCode server is doing and helps with debugging.
## Context
Currently, server logs are captured but not displayed anywhere. Users need to see:
- Server startup messages
- Port information
- Error messages
- Debug output
- Any other stdout/stderr from the OpenCode server
The Logs tab should be a special tab that appears alongside session tabs and cannot be closed.
## Requirements
### Functional Requirements
1. **Logs Tab Appearance**
- Appears in session tabs area (Level 2 tabs)
- Label: "Logs"
- Icon: Terminal icon (⚡ or similar)
- Non-closable (no × button)
- Always present for each instance
- Typically positioned at the end of session tabs
2. **Log Display**
- Shows all stdout/stderr from server process
- Real-time updates as logs come in
- Scrollable content
- Auto-scroll to bottom when new logs arrive
- Manual scroll up disables auto-scroll
- Monospace font for log content
- Timestamps for each log entry
3. **Log Entry Format**
- Timestamp (HH:MM:SS)
- Log level indicator (if available)
- Message content
- Color coding by level:
- Info: Default color
- Error: Red
- Warning: Yellow
- Debug: Gray/muted
4. **Log Controls**
- Clear logs button
- Scroll to bottom button (when scrolled up)
- Optional: Filter by log level (post-MVP)
- Optional: Search in logs (post-MVP)
### Technical Requirements
1. **State Management**
- Store logs in instance state
- Structure: `{ timestamp: number, level: string, message: string }[]`
- Limit log entries to prevent memory issues (e.g., max 1000 entries)
- Old entries removed when limit reached (FIFO)
2. **IPC Communication**
- Main process captures process stdout/stderr
- Send logs to renderer via IPC events
- Event type: `instance:log`
- Payload: `{ instanceId: string, entry: LogEntry }`
3. **Rendering**
- Virtualize log list only if performance issues (not for MVP)
- Simple list rendering is fine for MVP
- Each log entry is a separate div
- Apply styling based on log level
4. **Performance**
- Don't render logs when tab is not active
- Lazy render log entries (only visible ones if using virtual scrolling - not needed for MVP)
## Implementation Steps
### Step 1: Update Instance State
Update `src/stores/instances.ts` to include logs:
```typescript
interface LogEntry {
timestamp: number
level: "info" | "error" | "warn" | "debug"
message: string
}
interface Instance {
id: string
folder: string
port: number
pid: number
status: InstanceStatus
client: OpenCodeClient
eventSource: EventSource | null
sessions: Map<string, Session>
activeSessionId: string | null
logs: LogEntry[] // Add this
}
// Add log management functions
function addLog(instanceId: string, entry: LogEntry) {
const instance = instances.get(instanceId)
if (!instance) return
instance.logs.push(entry)
// Limit to 1000 entries
if (instance.logs.length > 1000) {
instance.logs.shift()
}
}
function clearLogs(instanceId: string) {
const instance = instances.get(instanceId)
if (!instance) return
instance.logs = []
}
```
### Step 2: Update Main Process Log Capture
Update `electron/main/process-manager.ts` to send logs via IPC:
```typescript
import { BrowserWindow } from "electron"
function spawn(folder: string, mainWindow: BrowserWindow): Promise<ProcessInfo> {
const proc = spawn("opencode", ["serve", "--port", "0"], {
cwd: folder,
stdio: ["ignore", "pipe", "pipe"],
})
const instanceId = generateId()
// Capture stdout
proc.stdout?.on("data", (data) => {
const message = data.toString()
// Send to renderer
mainWindow.webContents.send("instance:log", {
instanceId,
entry: {
timestamp: Date.now(),
level: "info",
message: message.trim(),
},
})
// Parse port if present
const port = parsePort(message)
if (port) {
// ... existing port handling
}
})
// Capture stderr
proc.stderr?.on("data", (data) => {
const message = data.toString()
mainWindow.webContents.send("instance:log", {
instanceId,
entry: {
timestamp: Date.now(),
level: "error",
message: message.trim(),
},
})
})
// ... rest of spawn logic
}
```
### Step 3: Update Preload Script
Add IPC handler in `electron/preload/index.ts`:
```typescript
contextBridge.exposeInMainWorld("electronAPI", {
// ... existing methods
onInstanceLog: (callback: (data: { instanceId: string; entry: LogEntry }) => void) => {
ipcRenderer.on("instance:log", (_, data) => callback(data))
},
})
```
### Step 4: Create Logs Component
Create `src/components/logs-view.tsx`:
```typescript
import { For, createSignal, createEffect, onMount } from 'solid-js'
import { useInstances } from '../stores/instances'
interface LogsViewProps {
instanceId: string
}
export function LogsView(props: LogsViewProps) {
let scrollRef: HTMLDivElement | undefined
const [autoScroll, setAutoScroll] = createSignal(true)
const instances = useInstances()
const instance = () => instances().get(props.instanceId)
const logs = () => instance()?.logs ?? []
// Auto-scroll to bottom when new logs arrive
createEffect(() => {
if (autoScroll() && scrollRef) {
scrollRef.scrollTop = scrollRef.scrollHeight
}
})
// Handle manual scroll
const handleScroll = () => {
if (!scrollRef) return
const isAtBottom =
scrollRef.scrollHeight - scrollRef.scrollTop <= scrollRef.clientHeight + 50
setAutoScroll(isAtBottom)
}
const scrollToBottom = () => {
if (scrollRef) {
scrollRef.scrollTop = scrollRef.scrollHeight
setAutoScroll(true)
}
}
const clearLogs = () => {
// Call store method to clear logs
instances.clearLogs(props.instanceId)
}
const formatTime = (timestamp: number) => {
const date = new Date(timestamp)
return date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
const getLevelColor = (level: string) => {
switch (level) {
case 'error': return 'text-red-600 dark:text-red-400'
case 'warn': return 'text-yellow-600 dark:text-yellow-400'
case 'debug': return 'text-gray-500 dark:text-gray-500'
default: return 'text-gray-900 dark:text-gray-100'
}
}
return (
<div class="flex flex-col h-full">
{/* Header with controls */}
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
Server Logs
</h3>
<div class="flex gap-2">
<button
onClick={clearLogs}
class="px-3 py-1 text-xs bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
>
Clear
</button>
</div>
</div>
{/* Logs container */}
<div
ref={scrollRef}
onScroll={handleScroll}
class="flex-1 overflow-y-auto p-4 bg-gray-50 dark:bg-gray-900 font-mono text-xs"
>
{logs().length === 0 ? (
<div class="text-gray-500 dark:text-gray-500 text-center py-8">
Waiting for server output...
</div>
) : (
<For each={logs()}>
{(entry) => (
<div class="flex gap-2 py-0.5 hover:bg-gray-100 dark:hover:bg-gray-800">
<span class="text-gray-500 dark:text-gray-500 select-none">
{formatTime(entry.timestamp)}
</span>
<span class={getLevelColor(entry.level)}>
{entry.message}
</span>
</div>
)}
</For>
)}
</div>
{/* Scroll to bottom button */}
{!autoScroll() && (
<button
onClick={scrollToBottom}
class="absolute bottom-4 right-4 px-3 py-2 bg-blue-600 text-white rounded-full shadow-lg hover:bg-blue-700"
>
Scroll to bottom
</button>
)}
</div>
)
}
```
### Step 5: Update Session Tabs Component
Update `src/components/session-tabs.tsx` to include Logs tab:
```typescript
import { LogsView } from './logs-view'
export function SessionTabs(props: { instanceId: string }) {
const sessions = () => getSessionsForInstance(props.instanceId)
const activeSession = () => getActiveSession(props.instanceId)
const [activeTab, setActiveTab] = createSignal<string | 'logs'>(/* ... */)
return (
<div class="flex flex-col h-full">
{/* Tab headers */}
<div class="flex items-center border-b border-gray-200 dark:border-gray-700">
{/* Session tabs */}
<For each={sessions()}>
{(session) => (
<button
onClick={() => setActiveTab(session.id)}
class={/* ... */}
>
{session.title || 'Untitled'}
</button>
)}
</For>
{/* Logs tab */}
<button
onClick={() => setActiveTab('logs')}
class={`px-4 py-2 text-sm ${
activeTab() === 'logs'
? 'border-b-2 border-blue-600 text-blue-600'
: 'text-gray-600 dark:text-gray-400'
}`}
>
Logs
</button>
{/* New session button */}
<button class="px-3 py-2 text-gray-500 hover:text-gray-700">
+
</button>
</div>
{/* Tab content */}
<div class="flex-1 overflow-hidden">
{activeTab() === 'logs' ? (
<LogsView instanceId={props.instanceId} />
) : (
<SessionView sessionId={activeTab()} />
)}
</div>
</div>
)
}
```
### Step 6: Setup IPC Listener
In `src/App.tsx` or wherever instances are initialized:
```typescript
import { onMount } from "solid-js"
onMount(() => {
// Listen for log events from main process
window.electronAPI.onInstanceLog((data) => {
const { instanceId, entry } = data
instances.addLog(instanceId, entry)
})
})
```
### Step 7: Add Initial Server Logs
When instance starts, add a startup log:
```typescript
function createInstance(folder: string) {
const instanceId = generateId()
// Add initial log
instances.addLog(instanceId, {
timestamp: Date.now(),
level: "info",
message: `Starting OpenCode server for ${folder}...`,
})
// ... spawn server
}
```
### Step 8: Test Logs Display
1. Start an instance
2. Switch to Logs tab
3. Verify startup messages appear
4. Verify real-time updates
5. Test auto-scroll behavior
6. Test clear button
7. Test manual scroll disables auto-scroll
8. Test scroll to bottom button
## Acceptance Criteria
- [ ] Logs tab appears for each instance
- [ ] Logs tab has terminal icon
- [ ] Logs tab cannot be closed
- [ ] Server stdout displays in real-time
- [ ] Server stderr displays in real-time
- [ ] Logs have timestamps
- [ ] Error logs are red
- [ ] Warning logs are yellow
- [ ] Auto-scroll works when at bottom
- [ ] Manual scroll disables auto-scroll
- [ ] Scroll to bottom button appears when scrolled up
- [ ] Clear button removes all logs
- [ ] Logs are limited to 1000 entries
- [ ] Monospace font used for log content
- [ ] Empty state shows when no logs
## Testing Checklist
- [ ] Test with normal server startup
- [ ] Test with server errors (e.g., port in use)
- [ ] Test with rapid log output (stress test)
- [ ] Test switching between session and logs tab
- [ ] Test clearing logs
- [ ] Test auto-scroll with new logs
- [ ] Test manual scroll behavior
- [ ] Test logs persist when switching instances
- [ ] Test logs cleared when instance closes
- [ ] Test very long log messages (wrapping)
## Notes
- For MVP, don't implement log filtering or search
- Keep log entry limit reasonable (1000 entries)
- Don't virtualize unless performance issues
- Consider adding log levels based on OpenCode server output format
- May need to parse ANSI color codes if server uses them
## Future Enhancements (Post-MVP)
- Filter logs by level (info, error, warn, debug)
- Search within logs
- Export logs to file
- Copy log entry on click
- Follow mode toggle (auto-scroll on/off)
- Parse and highlight errors/stack traces
- ANSI color code support
- Log level indicators with icons
- Timestamps toggle
- Word wrap toggle

View File

@@ -0,0 +1,849 @@
# Task 015: Keyboard Shortcuts
## Goal
Implement comprehensive keyboard shortcuts for efficient keyboard-first navigation, inspired by the TUI's keyboard system but adapted for desktop multi-instance/multi-session workflow.
## Prerequisites
- ✅ 001-013 completed
- ✅ All core UI components built
- ✅ Message stream, prompt input, tabs working
## Decisions Made
1. **Tab Navigation**: Use `Cmd/Ctrl+[/]` for instances, `Cmd/Ctrl+Shift+[/]` for sessions
2. **Clear Input**: Use `Cmd/Ctrl+K` (common in Slack, Discord, VS Code)
3. **Escape Behavior**: Context-dependent (blur when idle, interrupt when busy)
4. **Message History**: Per-instance, stored in IndexedDB (embedded local database)
5. **Agent Cycling**: Include Tab/Shift+Tab for agent cycling, add model selector focus shortcut
6. **Leader Key**: Skip it - use standard Cmd/Ctrl patterns
7. **Platform**: Cmd on macOS, Ctrl elsewhere (standard cross-platform pattern)
8. **View Controls**: Not needed for MVP
9. **Help Dialog**: Not needed - inline hints instead
## Key Principles
### Smart Inline Hints
Instead of a help dialog, show shortcuts contextually:
- Display hints next to actions they affect
- Keep hints subtle (small text, muted color)
- Use platform-specific symbols (⌘ on Mac, Ctrl elsewhere)
- Examples already in app: "Enter to send • Shift+Enter for new line"
### Modular Architecture
Build shortcuts in a centralized, configurable system:
- Single source of truth for all shortcuts
- Easy to extend for future customization
- Clear separation between shortcut definition and handler logic
- Registry pattern for discoverability
## Shortcuts to Implement
### Navigation (Tabs)
**Already Implemented:**
- [x] `Cmd/Ctrl+1-9` - Switch to instance tab by index
- [x] `Cmd/Ctrl+N` - New instance (select folder)
- [x] `Cmd/Ctrl+T` - New session in active instance
- [x] `Cmd/Ctrl+W` - Close active **parent** session (only)
**To Implement:**
- [ ] `Cmd/Ctrl+[` - Previous instance tab
- [ ] `Cmd/Ctrl+]` - Next instance tab
- [ ] `Cmd/Ctrl+Shift+[` - Previous session tab
- [ ] `Cmd/Ctrl+Shift+]` - Next session tab
- [ ] `Cmd/Ctrl+Shift+L` - Switch to Logs tab
### Input Management
**Already Implemented:**
- [x] `Enter` - Send message
- [x] `Shift+Enter` - New line
**To Implement:**
- [ ] `Cmd/Ctrl+K` - Clear input
- [ ] `Cmd/Ctrl+L` - Focus prompt input
- [ ] `Up Arrow` - Previous message in history (when at start of input)
- [ ] `Down Arrow` - Next message in history (when in history mode)
- [ ] `Escape` - Context-dependent:
- When idle: Blur input / close modals
- When busy: Interrupt session (requires confirmation)
### Agent/Model Selection
**To Implement:**
- [ ] `Tab` - Cycle to next agent (when input empty or not focused)
- [ ] `Shift+Tab` - Cycle to previous agent
- [ ] `Cmd/Ctrl+M` - Focus model selector dropdown
### Message Navigation
**To Implement:**
- [ ] `PgUp` - Scroll messages up
- [ ] `PgDown` - Scroll messages down
- [ ] `Home` - Jump to first message
- [ ] `End` - Jump to last message
## Architecture Design
### 1. Centralized Keyboard Registry
```typescript
// src/lib/keyboard-registry.ts
export interface KeyboardShortcut {
id: string
key: string
modifiers: {
ctrl?: boolean
meta?: boolean
shift?: boolean
alt?: boolean
}
handler: () => void
description: string
context?: "global" | "input" | "messages" // Where it works
condition?: () => boolean // Runtime condition check
}
class KeyboardRegistry {
private shortcuts = new Map<string, KeyboardShortcut>()
register(shortcut: KeyboardShortcut) {
this.shortcuts.set(shortcut.id, shortcut)
}
unregister(id: string) {
this.shortcuts.delete(id)
}
findMatch(event: KeyboardEvent): KeyboardShortcut | null {
for (const shortcut of this.shortcuts.values()) {
if (this.matches(event, shortcut)) {
// Check context
if (shortcut.context === "input" && !this.isInputFocused()) continue
if (shortcut.context === "messages" && this.isInputFocused()) continue
// Check runtime condition
if (shortcut.condition && !shortcut.condition()) continue
return shortcut
}
}
return null
}
private matches(event: KeyboardEvent, shortcut: KeyboardShortcut): boolean {
const keyMatch = event.key.toLowerCase() === shortcut.key.toLowerCase()
const ctrlMatch = event.ctrlKey === !!shortcut.modifiers.ctrl
const metaMatch = event.metaKey === !!shortcut.modifiers.meta
const shiftMatch = event.shiftKey === !!shortcut.modifiers.shift
const altMatch = event.altKey === !!shortcut.modifiers.alt
return keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch
}
private isInputFocused(): boolean {
const active = document.activeElement
return active?.tagName === "TEXTAREA" || active?.tagName === "INPUT" || active?.hasAttribute("contenteditable")
}
getByContext(context: string): KeyboardShortcut[] {
return Array.from(this.shortcuts.values()).filter((s) => !s.context || s.context === context)
}
}
export const keyboardRegistry = new KeyboardRegistry()
```
### 2. Cross-Platform Key Helper
```typescript
// src/lib/keyboard-utils.ts
export const isMac = () => navigator.platform.includes("Mac")
export const modKey = (event?: KeyboardEvent) => {
if (!event) return isMac() ? "metaKey" : "ctrlKey"
return isMac() ? event.metaKey : event.ctrlKey
}
export const modKeyPressed = (event: KeyboardEvent) => {
return isMac() ? event.metaKey : event.ctrlKey
}
export const formatShortcut = (shortcut: KeyboardShortcut): string => {
const parts: string[] = []
if (shortcut.modifiers.ctrl || shortcut.modifiers.meta) {
parts.push(isMac() ? "⌘" : "Ctrl")
}
if (shortcut.modifiers.shift) {
parts.push(isMac() ? "⇧" : "Shift")
}
if (shortcut.modifiers.alt) {
parts.push(isMac() ? "⌥" : "Alt")
}
parts.push(shortcut.key.toUpperCase())
return parts.join(isMac() ? "" : "+")
}
```
### 3. IndexedDB Storage Layer
```typescript
// src/lib/db.ts
const DB_NAME = "opencode-client"
const DB_VERSION = 1
const HISTORY_STORE = "message-history"
let db: IDBDatabase | null = null
async function getDB(): Promise<IDBDatabase> {
if (db) return db
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onerror = () => reject(request.error)
request.onsuccess = () => {
db = request.result
resolve(db)
}
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result
// Create object stores
if (!db.objectStoreNames.contains(HISTORY_STORE)) {
db.createObjectStore(HISTORY_STORE)
}
}
})
}
export async function saveHistory(instanceId: string, history: string[]): Promise<void> {
const database = await getDB()
return new Promise((resolve, reject) => {
const tx = database.transaction(HISTORY_STORE, "readwrite")
const store = tx.objectStore(HISTORY_STORE)
const request = store.put(history, instanceId)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve()
})
}
export async function loadHistory(instanceId: string): Promise<string[]> {
try {
const database = await getDB()
return new Promise((resolve, reject) => {
const tx = database.transaction(HISTORY_STORE, "readonly")
const store = tx.objectStore(HISTORY_STORE)
const request = store.get(instanceId)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result || [])
})
} catch (error) {
console.warn("Failed to load history from IndexedDB:", error)
return []
}
}
export async function deleteHistory(instanceId: string): Promise<void> {
const database = await getDB()
return new Promise((resolve, reject) => {
const tx = database.transaction(HISTORY_STORE, "readwrite")
const store = tx.objectStore(HISTORY_STORE)
const request = store.delete(instanceId)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve()
})
}
```
### 4. Message History Management
Per-instance storage using IndexedDB (persists across app restarts):
```typescript
// src/stores/message-history.ts
import { saveHistory, loadHistory, deleteHistory } from "../lib/db"
const MAX_HISTORY = 100
// In-memory cache
const instanceHistories = new Map<string, string[]>()
const historyLoaded = new Set<string>()
export async function addToHistory(instanceId: string, text: string): Promise<void> {
// Ensure history is loaded
await ensureHistoryLoaded(instanceId)
const history = instanceHistories.get(instanceId) || []
// Add to front (newest first)
history.unshift(text)
// Limit to MAX_HISTORY
if (history.length > MAX_HISTORY) {
history.length = MAX_HISTORY
}
// Update cache and persist
instanceHistories.set(instanceId, history)
// Persist to IndexedDB (async, don't wait)
saveHistory(instanceId, history).catch((err) => {
console.warn("Failed to persist message history:", err)
})
}
export async function getHistory(instanceId: string): Promise<string[]> {
await ensureHistoryLoaded(instanceId)
return instanceHistories.get(instanceId) || []
}
export async function clearHistory(instanceId: string): Promise<void> {
// Manually clear history (not called on instance stop)
instanceHistories.delete(instanceId)
historyLoaded.delete(instanceId)
await deleteHistory(instanceId)
}
async function ensureHistoryLoaded(instanceId: string): Promise<void> {
if (historyLoaded.has(instanceId)) {
return
}
try {
const history = await loadHistory(instanceId)
instanceHistories.set(instanceId, history)
historyLoaded.add(instanceId)
} catch (error) {
console.warn("Failed to load history:", error)
instanceHistories.set(instanceId, [])
historyLoaded.add(instanceId)
}
}
```
### 4. Inline Hint Component
```typescript
// src/components/keyboard-hint.tsx
import { Component } from 'solid-js'
import { formatShortcut, type KeyboardShortcut } from '../lib/keyboard-utils'
const KeyboardHint: Component<{
shortcuts: KeyboardShortcut[]
separator?: string
}> = (props) => {
return (
<span class="text-xs text-gray-500 dark:text-gray-400">
{props.shortcuts.map((shortcut, i) => (
<>
{i > 0 && <span class="mx-1">{props.separator || '•'}</span>}
<kbd class="font-mono">{formatShortcut(shortcut)}</kbd>
<span class="ml-1">{shortcut.description}</span>
</>
))}
</span>
)
}
export default KeyboardHint
```
## Implementation Steps
### Step 1: Create Keyboard Infrastructure
1. Create `src/lib/keyboard-registry.ts` - Central registry
2. Create `src/lib/keyboard-utils.ts` - Platform helpers
3. Create `src/lib/db.ts` - IndexedDB storage layer
4. Create `src/stores/message-history.ts` - History management
5. Create `src/components/keyboard-hint.tsx` - Inline hints component
### Step 2: Register Navigation Shortcuts
```typescript
// src/lib/shortcuts/navigation.ts
import { keyboardRegistry } from "../keyboard-registry"
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
import { getSessions, activeSessionId, setActiveSession } from "../../stores/sessions"
export function registerNavigationShortcuts() {
// Instance navigation
keyboardRegistry.register({
id: "instance-prev",
key: "[",
modifiers: { ctrl: true, meta: true },
handler: () => {
const ids = Array.from(instances().keys())
const current = ids.indexOf(activeInstanceId() || "")
const prev = current === 0 ? ids.length - 1 : current - 1
if (ids[prev]) setActiveInstanceId(ids[prev])
},
description: "previous instance",
context: "global",
})
keyboardRegistry.register({
id: "instance-next",
key: "]",
modifiers: { ctrl: true, meta: true },
handler: () => {
const ids = Array.from(instances().keys())
const current = ids.indexOf(activeInstanceId() || "")
const next = (current + 1) % ids.length
if (ids[next]) setActiveInstanceId(ids[next])
},
description: "next instance",
context: "global",
})
// Session navigation
keyboardRegistry.register({
id: "session-prev",
key: "[",
modifiers: { ctrl: true, meta: true, shift: true },
handler: () => {
const instanceId = activeInstanceId()
if (!instanceId) return
const sessions = getSessions(instanceId)
const ids = sessions.map((s) => s.id).concat(["logs"])
const current = ids.indexOf(activeSessionId().get(instanceId) || "")
const prev = current === 0 ? ids.length - 1 : current - 1
if (ids[prev]) setActiveSession(instanceId, ids[prev])
},
description: "previous session",
context: "global",
})
keyboardRegistry.register({
id: "session-next",
key: "]",
modifiers: { ctrl: true, meta: true, shift: true },
handler: () => {
const instanceId = activeInstanceId()
if (!instanceId) return
const sessions = getSessions(instanceId)
const ids = sessions.map((s) => s.id).concat(["logs"])
const current = ids.indexOf(activeSessionId().get(instanceId) || "")
const next = (current + 1) % ids.length
if (ids[next]) setActiveSession(instanceId, ids[next])
},
description: "next session",
context: "global",
})
// Logs tab
keyboardRegistry.register({
id: "switch-to-logs",
key: "l",
modifiers: { ctrl: true, meta: true, shift: true },
handler: () => {
const instanceId = activeInstanceId()
if (instanceId) setActiveSession(instanceId, "logs")
},
description: "logs tab",
context: "global",
})
}
```
### Step 3: Register Input Shortcuts
```typescript
// src/lib/shortcuts/input.ts
export function registerInputShortcuts(clearInput: () => void, focusInput: () => void) {
keyboardRegistry.register({
id: "clear-input",
key: "k",
modifiers: { ctrl: true, meta: true },
handler: clearInput,
description: "clear input",
context: "global",
})
keyboardRegistry.register({
id: "focus-input",
key: "l",
modifiers: { ctrl: true, meta: true },
handler: focusInput,
description: "focus input",
context: "global",
})
}
```
### Step 4: Update PromptInput with History Navigation
```typescript
// src/components/prompt-input.tsx
import { createSignal, onMount } from 'solid-js'
import { addToHistory, getHistory } from '../stores/message-history'
const PromptInput: Component<Props> = (props) => {
const [input, setInput] = createSignal('')
const [historyIndex, setHistoryIndex] = createSignal(-1)
const [history, setHistory] = createSignal<string[]>([])
let textareaRef: HTMLTextAreaElement | undefined
// Load history on mount
onMount(async () => {
const loaded = await getHistory(props.instanceId)
setHistory(loaded)
})
async function handleKeyDown(e: KeyboardEvent) {
const textarea = textareaRef
if (!textarea) return
const atStart = textarea.selectionStart === 0
const currentHistory = history()
// Up arrow - navigate to older message
if (e.key === 'ArrowUp' && atStart && currentHistory.length > 0) {
e.preventDefault()
const newIndex = Math.min(historyIndex() + 1, currentHistory.length - 1)
setHistoryIndex(newIndex)
setInput(currentHistory[newIndex])
}
// Down arrow - navigate to newer message
if (e.key === 'ArrowDown' && historyIndex() >= 0) {
e.preventDefault()
const newIndex = historyIndex() - 1
if (newIndex >= 0) {
setHistoryIndex(newIndex)
setInput(currentHistory[newIndex])
} else {
setHistoryIndex(-1)
setInput('')
}
}
}
async function handleSend() {
const text = input().trim()
if (!text) return
// Add to history (async, per instance)
await addToHistory(props.instanceId, text)
// Reload history for next navigation
const updated = await getHistory(props.instanceId)
setHistory(updated)
setHistoryIndex(-1)
await props.onSend(text)
setInput('')
}
return (
<div class="prompt-input">
<textarea
ref={textareaRef}
value={input()}
onInput={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your message..."
/>
<KeyboardHint shortcuts={[
{ description: 'to send', key: 'Enter', modifiers: {} },
{ description: 'for new line', key: 'Enter', modifiers: { shift: true } }
]} />
</div>
)
}
```
### Step 5: Agent Cycling
```typescript
// src/lib/shortcuts/agent.ts
export function registerAgentShortcuts(
cycleAgent: () => void,
cycleAgentReverse: () => void,
focusModelSelector: () => void,
) {
keyboardRegistry.register({
id: "agent-next",
key: "Tab",
modifiers: {},
handler: cycleAgent,
description: "next agent",
context: "global",
condition: () => !isInputFocused(), // Only when not typing
})
keyboardRegistry.register({
id: "agent-prev",
key: "Tab",
modifiers: { shift: true },
handler: cycleAgentReverse,
description: "previous agent",
context: "global",
condition: () => !isInputFocused(),
})
keyboardRegistry.register({
id: "focus-model",
key: "m",
modifiers: { ctrl: true, meta: true },
handler: focusModelSelector,
description: "focus model",
context: "global",
})
}
```
### Step 6: Escape Key Context Handling
```typescript
// src/lib/shortcuts/escape.ts
export function registerEscapeShortcut(
isSessionBusy: () => boolean,
interruptSession: () => void,
blurInput: () => void,
closeModal: () => void,
) {
keyboardRegistry.register({
id: "escape",
key: "Escape",
modifiers: {},
handler: () => {
// Priority 1: Close modal if open
if (hasOpenModal()) {
closeModal()
return
}
// Priority 2: Interrupt if session is busy
if (isSessionBusy()) {
interruptSession()
return
}
// Priority 3: Blur input
blurInput()
},
description: "cancel/close",
context: "global",
})
}
```
### Step 7: Setup Global Listener in App
```typescript
// src/App.tsx
import { registerNavigationShortcuts } from "./lib/shortcuts/navigation"
import { registerInputShortcuts } from "./lib/shortcuts/input"
import { registerAgentShortcuts } from "./lib/shortcuts/agent"
import { registerEscapeShortcut } from "./lib/shortcuts/escape"
import { keyboardRegistry } from "./lib/keyboard-registry"
onMount(() => {
// Register all shortcuts
registerNavigationShortcuts()
registerInputShortcuts(
() => setInput(""),
() => document.querySelector("textarea")?.focus(),
)
registerAgentShortcuts(handleCycleAgent, handleCycleAgentReverse, () =>
document.querySelector("[data-model-selector]")?.focus(),
)
registerEscapeShortcut(
() => activeInstance()?.status === "streaming",
handleInterrupt,
() => document.activeElement?.blur(),
hideModal,
)
// Global keydown handler
const handleKeyDown = (e: KeyboardEvent) => {
const shortcut = keyboardRegistry.findMatch(e)
if (shortcut) {
e.preventDefault()
shortcut.handler()
}
}
window.addEventListener("keydown", handleKeyDown)
onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown)
})
})
```
### Step 8: Add Inline Hints Throughout UI
**In PromptInput:**
```tsx
<KeyboardHint
shortcuts={[getShortcut("enter-to-send"), getShortcut("shift-enter-newline"), getShortcut("cmd-k-clear")]}
/>
```
**In Instance Tabs:**
```tsx
<KeyboardHint shortcuts={[getShortcut("cmd-1-9"), getShortcut("cmd-brackets")]} />
```
**In Agent Selector:**
```tsx
<KeyboardHint shortcuts={[getShortcut("tab-cycle")]} />
```
## Where to Show Hints
1. **Prompt Input Area** (bottom)
- Enter/Shift+Enter (already shown)
- Add: Cmd+K to clear, ↑↓ for history
2. **Instance Tabs** (subtle tooltip or header)
- Cmd+1-9, Cmd+[/]
3. **Session Tabs** (same as instance)
- Cmd+Shift+[/]
4. **Agent/Model Selectors** (placeholder or label)
- Tab/Shift+Tab, Cmd+M
5. **Empty State** (when no messages)
- Common shortcuts overview
## Testing Checklist
### Navigation
- [ ] Cmd/Ctrl+[ / ] cycles instance tabs
- [ ] Cmd/Ctrl+Shift+[ / ] cycles session tabs
- [ ] Cmd/Ctrl+1-9 jumps to instance
- [ ] Cmd/Ctrl+T creates new session
- [ ] Cmd/Ctrl+W closes parent session only
- [ ] Cmd/Ctrl+Shift+L switches to logs
### Input
- [ ] Cmd/Ctrl+K clears input
- [ ] Cmd/Ctrl+L focuses input
- [ ] Up arrow loads previous message (when at start)
- [ ] Down arrow navigates forward in history
- [ ] History is per-instance
- [ ] History persists in IndexedDB across app restarts
- [ ] History limited to 100 entries (not 50)
- [ ] History loads on component mount
- [ ] History NOT cleared when instance stops
### Agent/Model
- [ ] Tab cycles agents (when not in input)
- [ ] Shift+Tab cycles agents backward
- [ ] Cmd/Ctrl+M focuses model selector
### Context Behavior
- [ ] Escape closes modals first
- [ ] Escape interrupts when busy
- [ ] Escape blurs input when idle
- [ ] Shortcuts don't fire in wrong context
### Cross-Platform
- [ ] Works with Cmd on macOS
- [ ] Works with Ctrl on Windows
- [ ] Works with Ctrl on Linux
- [ ] Hints show correct keys per platform
### Inline Hints
- [ ] Hints visible but not intrusive
- [ ] Correct platform symbols shown
- [ ] Hints appear in relevant locations
- [ ] No excessive screen space used
## Dependencies
**Requires:**
- Tasks 001-013 completed
**Blocks:**
- None (final MVP task)
## Estimated Time
4-5 hours
## Success Criteria
✅ Task complete when:
- All shortcuts implemented and working
- Message history per-instance, persisted in IndexedDB
- History stores 100 most recent prompts
- History persists across app restarts and instance stops
- Agent cycling with Tab/Shift+Tab
- Context-aware Escape behavior
- Inline hints shown throughout UI
- Cross-platform (Cmd/Ctrl) working
- Modular registry system for future customization
- Can navigate entire app efficiently with keyboard
## Notes on History Storage
**Why per-instance (folder path)?**
- User opens same project folder multiple times → same history
- More intuitive: history tied to project, not ephemeral instance
- Survives instance restarts without losing context
**Why 100 entries?**
- More generous than TUI's 50
- ~20KB per instance (100 × ~200 chars)
- Plenty for typical usage patterns
- Can increase later if needed
**Cleanup Strategy:**
- No automatic cleanup (history persists indefinitely)
- Could add manual "Clear History" option in future
- IndexedDB handles storage efficiently

View File

@@ -0,0 +1,178 @@
---
title: Command Palette ✅
description: Implement VSCode-style command palette with Cmd+Shift+P
status: COMPLETED
completed: 2024-10-23
---
# Implement Command Palette ✅
Built a VSCode-style command palette that opens as a centered modal dialog with 19 commands organized into 5 categories.
---
## ✅ Implementation Summary
### Commands Implemented (19 total)
#### **Instance (4 commands)**
1.**New Instance** (Cmd+N) - Open folder picker to create new instance
2.**Close Instance** (Cmd+W) - Stop current instance's server
3.**Next Instance** (Cmd+]) - Cycle to next instance tab
4.**Previous Instance** (Cmd+[) - Cycle to previous instance tab
#### **Session (7 commands)**
5.**New Session** (Cmd+Shift+N) - Create a new parent session
6.**Close Session** (Cmd+Shift+W) - Close current parent session
7.**Switch to Logs** (Cmd+Shift+L) - Jump to logs view
8.**Next Session** (Cmd+Shift+]) - Cycle to next session tab
9.**Previous Session** (Cmd+Shift+[) - Cycle to previous session tab
10.**Compact Session** - Summarize and compact current session (/compact API)
11.**Undo Last Message** - Revert the last message (/undo API)
#### **Agent & Model (5 commands)**
12.**Next Agent** (Tab) - Cycle to next agent
13.**Previous Agent** (Shift+Tab) - Cycle to previous agent
14.**Open Model Selector** (Cmd+Shift+M) - Choose a different model
15.**Open Agent Selector** - Choose a different agent
16.**Initialize AGENTS.md** - Create or update AGENTS.md file (/init API)
#### **Input & Focus (1 command)**
17.**Clear Input** (Cmd+K) - Clear the prompt textarea
#### **System (2 commands)**
18.**Toggle Thinking Blocks** - Show/hide AI thinking process (placeholder)
19.**Show Help** - Display keyboard shortcuts and help (placeholder)
---
## ✅ Features Implemented
### Visual Design
- ✅ Modal dialog centered on screen with backdrop overlay
- ✅ ~600px wide with auto height and max height
- ✅ Search/filter input at top
- ✅ Scrollable list of commands below
- ✅ Each command shows: name, description, keyboard shortcut (if any)
- ✅ Category headers for command grouping
- ✅ Dark/light mode support
### Behavior
- ✅ Opens on `Cmd+Shift+P`
- ✅ Closes on `Escape` or clicking outside
- ✅ Search input is auto-focused when opened
- ✅ Filter commands as user types (substring search by label, description, keywords, category)
- ✅ Arrow keys navigate through filtered list
- ✅ Enter executes selected command
- ✅ Mouse click on command also executes it
- ✅ Mouse hover updates selection
- ✅ Closes automatically after command execution
### Command Registry
- ✅ Centralized command registry in `lib/commands.ts`
- ✅ Commands organized by category
- ✅ Keywords for better search
- ✅ Keyboard shortcuts displayed
- ✅ All commands connected to existing actions
### Integration
- ✅ Integrated with keyboard registry
- ✅ Connected to instance/session management
- ✅ Connected to SDK client for API calls
- ✅ Connected to UI selectors (agent, model)
- ✅ State management via `stores/command-palette.ts`
---
## 📁 Files Modified
- `src/App.tsx` - Registered all 19 commands with categories
- `src/components/command-palette.tsx` - Added category grouping and display
- `src/lib/commands.ts` - Already existed with command registry
- `src/stores/command-palette.ts` - Already existed with state management
---
## ✅ Acceptance Criteria
- ✅ Palette opens with `Cmd+Shift+P`
- ✅ Search input is auto-focused
- ✅ 19 commands are listed in 5 categories
- ✅ Typing filters commands (case-insensitive substring match)
- ✅ Arrow keys navigate through list
- ✅ Enter executes selected command
- ✅ Click executes command
- ✅ Escape or click outside closes palette
- ✅ Palette closes after command execution
- ✅ Keyboard shortcuts display correctly
- ✅ Commands execute their intended actions:
-`/init` calls API
-`/compact` calls API
-`/undo` calls API
- ✅ New Session/Instance work
- ✅ Model/Agent selectors open
- ✅ Navigation shortcuts work
- ✅ Works in both light and dark mode
- ✅ Smooth open/close animations
---
## 🎯 Key Implementation Details
### Category Ordering
Commands are grouped and displayed in this order:
1. Instance - Managing workspace folders
2. Session - Managing conversation sessions
3. Agent & Model - AI configuration
4. Input & Focus - Input controls
5. System - System-level settings
### Search Functionality
Search filters by:
- Command label
- Command description
- Keywords
- Category name
### Keyboard Shortcuts
All shortcuts are registered in the keyboard registry and displayed in the palette using the `Kbd` component.
---
## 🚀 Future Enhancements
These can be added post-MVP:
- Fuzzy search algorithm (not just substring)
- Command history (recently used commands first)
- Custom user-defined commands
- Command arguments/parameters
- Command aliases
- Search by keyboard shortcut
- Quick switch between sessions/instances via command palette
- Command icons/emoji
- Command grouping within categories
---
## Notes
- Command palette provides VSCode-like discoverability
- All commands leverage existing keyboard shortcuts and actions
- Categories make it easy to find related commands
- Foundation is in place for adding more commands in the future
- Agent and Model selector commands work by programmatically clicking their triggers

View File

@@ -0,0 +1,40 @@
---
title: File Attachments
description: Add @mentions, drag & drop, and chips for files.
---
Implement File Attachments
---
### Implement @ Mentions
When a user types `@` in the input field, display a file picker with search functionality.
Allow users to select files to attach to their prompt.
---
### Visual Attachment Chips
Display attached files as interactive chips above the input area.
Chips should include a filename and a removable "x" button.
---
### Drag and Drop Files
Enable dragging files from the operating system directly onto the input area.
Automatically create an attachment chip for dropped files.
---
### Acceptance Criteria
- Typing `@` brings up a file selection autocomplete.
- Files can be selected and appear as chips.
- Users can drag and drop files onto the input, creating chips.
- Attached files are included in the prompt submission.
- Attachment chips can be removed by clicking "x".

View File

@@ -0,0 +1,29 @@
---
title: Long Paste Handling
description: Summarize large pasted text into attachments.
---
Implement Long Paste Handling
---
### Detect Long Pastes
Monitor clipboard paste events for text content. Identify if the pasted text exceeds a defined length (e.g., >150 characters or >3 lines).
---
### Create Summarized Attachments
If a paste is identified as "long", prevent direct insertion into the input field. Instead, create a new text attachment containing the full content.
Display a summarized chip for the attachment, such as `[pasted #1 10+ lines]`.
---
### Acceptance Criteria
- Pasting short text directly inserts it into the input.
- Pasting long text creates a summarized attachment chip.
- The full content of the long paste is retained within the attachment for submission.
- Multiple long pastes create distinct numbered chips.

View File

@@ -0,0 +1,31 @@
---
title: Agent Attachments
description: Allow @agent mentions for multi-agent conversations.
---
Implement Agent Attachments
---
### @ Agent Autocomplete
When a user types `@` followed by an agent name, display an autocomplete list of available agents.
Filter agent suggestions as the user types.
---
### Attach Agents
Enable users to select an agent from the autocomplete list to attach to their prompt.
Display attached agents as interactive chips.
---
### Acceptance Criteria
- Typing `@` followed by a partial agent name displays matching agent suggestions.
- Selecting an agent creates an attachment chip.
- Attached agents are included in the prompt submission.
- Agent chips can be removed.

View File

@@ -0,0 +1,31 @@
---
title: Image Clipboard
description: Support pasting images from the clipboard.
---
Implement Image Clipboard Support
---
### Detect Image Paste
Detect when image data is present in the system clipboard during a paste event.
Prioritize image data over text data if both are present.
---
### Create Image Attachment
Automatically create an image attachment from the pasted image data. Convert the image to a base64 encoded format for internal handling and submission.
Display the image attachment as a chip in the input area.
---
### Acceptance Criteria
- Pasting an image from the clipboard creates an image attachment chip.
- The image data is base64 encoded and associated with the attachment.
- The attachment chip has a suitable display name (e.g., `[Image #1]`).
- Users can clear the image attachment.

View File

@@ -0,0 +1,35 @@
# Task 041 - Tailwind Theme Hooks
## Goal
Establish the base Tailwind configuration needed for theming work without changing current visuals.
## Prerequisites
- Installed project dependencies (`npm install`).
- Ability to run the renderer locally (`npm run dev`).
## Acceptance Criteria
- [ ] `tailwind.config.js` uses `darkMode: ["class", '[data-theme="dark"]']`.
- [ ] `theme.extend` contains empty objects for upcoming tokens: `colors`, `spacing`, `fontSize`, `borderRadius`, and `boxShadow`.
- [ ] No other configuration changes are introduced.
- [ ] App builds and renders exactly as before (no visual diffs expected).
## Steps
1. Update `tailwind.config.js` with the new `darkMode` array value.
2. Add empty extension objects for `colors`, `spacing`, `fontSize`, `borderRadius`, and `boxShadow` under `theme.extend`.
3. Double-check that all other keys remain untouched.
4. Save the file.
## Testing Checklist
- [ ] Run `npm run dev` and ensure the renderer starts successfully.
- [ ] Smoke test the UI in light and dark mode to confirm no visual regressions.
## Dependencies
- None.
## Estimated Time
0.25 hours
## Notes
- Create a branch (e.g., `feature/task-041-tailwind-theme-hooks`).
- Commit message suggestion: `chore: prep tailwind for theming`.
- Include before/after screenshots only if an unexpected visual change occurs.

View File

@@ -0,0 +1,42 @@
# Task 042 - Style Token Scaffolding
## Goal
Create the shared token stylesheet placeholder and wire it into the app without defining actual variables yet.
## Prerequisites
- Task 041 complete (Tailwind theme hooks ready).
- Local dev server can be run (`npm run dev`).
## Acceptance Criteria
- [ ] New file `src/styles/tokens.css` exists with section headings for light and dark palettes plus TODO comments for future tokens.
- [ ] `src/index.css` imports `src/styles/tokens.css` near the top of the file.
- [ ] No CSS variables are defined yet (only structure and comments).
- [ ] App compiles and renders as before.
## Steps
1. Create `src/styles/` if missing and add `tokens.css` with placeholders:
```css
:root {
/* TODO: surface, text, accent, status tokens */
}
[data-theme="dark"] {
/* TODO: dark-mode overrides */
}
```
2. Import `./styles/tokens.css` from `src/index.css` after the Tailwind directives.
3. Ensure no existing CSS variables are removed yet.
## Testing Checklist
- [ ] Run `npm run dev` and confirm the renderer starts without warnings.
- [ ] Visually spot-check a session view in light and dark mode for unchanged styling.
## Dependencies
- Blocks Task 043 (color variable migration).
## Estimated Time
0.25 hours
## Notes
- Branch name suggestion: `feature/task-042-style-token-scaffolding`.
- Keep the file ASCII-only and avoid trailing spaces.

View File

@@ -0,0 +1,36 @@
# Task 043 - Color Variable Migration
## Goal
Move all hard-coded color variables from `src/index.css` into `src/styles/tokens.css`, aligning with the documented light and dark palettes.
## Prerequisites
- Task 042 complete (token scaffolding in place).
- Access to color definitions from `docs/user-interface.md`.
## Acceptance Criteria
- [ ] Light mode color tokens (`--surface-*`, `--border-*`, `--text-*`, `--accent`, `--status-success|error|warning`) defined under `:root` in `src/styles/tokens.css`.
- [ ] Dark mode overrides defined under `[data-theme="dark"]` in the same file.
- [ ] `src/index.css` no longer declares color variables directly; it references the new tokens instead.
- [ ] Theme toggle continues to switch palettes correctly.
## Steps
1. Transfer existing color custom properties from `src/index.css` into `src/styles/tokens.css`, renaming them to semantic names that match the design doc.
2. Add any missing variables required by the design spec (e.g., `--surface-muted`, `--text-inverted`).
3. Update `src/index.css` to reference the new semantic variable names where necessary (e.g., `background-color: var(--surface-base)`).
4. Remove redundant color declarations from `src/index.css` after confirming replacements.
## Testing Checklist
- [ ] Run `npm run dev` and switch between light and dark themes.
- [ ] Verify primary screens (instance tabs, session view, prompt input) in both themes for correct colors.
- [ ] Confirm no CSS warnings/errors in the console.
## Dependencies
- Depends on Task 042.
- Blocks Task 044 (typography tokens) and Task 045 (component migration batch 1).
## Estimated Time
0.5 hours
## Notes
- Align hex values with `docs/user-interface.md`; note any intentional deviations in the PR description.
- Provide side-by-side screenshots (light/dark) in the PR for quicker review.

View File

@@ -0,0 +1,34 @@
# Task 044 - Typography Baseline
## Goal
Define the shared typography tokens and map them into Tailwind so text sizing stays consistent with the UI spec.
## Prerequisites
- Task 043 complete (color variables migrated).
## Acceptance Criteria
- [ ] `src/styles/tokens.css` includes typography variables (font families, weights, line heights, size scale).
- [ ] `tailwind.config.js` `theme.extend.fontFamily` and `theme.extend.fontSize` reference the new variables.
- [ ] `src/index.css` applies body font and default text color using the new variables.
- [ ] No existing components lose readability or spacing.
## Steps
1. Add typography variables to `src/styles/tokens.css`, e.g., `--font-family-sans`, `--font-size-body`, `--line-height-body`.
2. Extend Tailwind font families and sizes to match the variable names (`font-body`, `font-heading`, `text-body`, `text-label`).
3. Update `src/index.css` body rules to use `var(--font-family-sans)` and the appropriate default sizes.
4. Spot-check components for any stray font-size declarations that should use utilities instead.
## Testing Checklist
- [ ] Run `npm run dev` and verify the app renders without layout shifts.
- [ ] Inspect headings, labels, and body text to make sure sizes align with the design doc.
## Dependencies
- Depends on Task 043.
- Blocks Task 045 (component migration batch 1).
## Estimated Time
0.5 hours
## Notes
- Keep variable names semantic; record any design clarifications in the Notes section of the PR.
- Use browser dev tools to confirm computed font values match expectations (14px body, 16px headers, etc.).

View File

@@ -0,0 +1,35 @@
# Task 045 - Message Item Tailwind Refactor
## Goal
Refactor `MessageItem` to rely on Tailwind utilities and the new token variables instead of bespoke global CSS.
## Prerequisites
- Task 043 complete (color tokens available).
- Task 044 complete (typography baseline available).
## Acceptance Criteria
- [ ] `src/components/message-item.tsx` uses Tailwind utility classes (with CSS variable references where needed) for layout, colors, and typography.
- [ ] Legacy `.message-item*` styles are removed from `src/index.css`.
- [ ] Visual parity in light and dark modes is maintained for queued, sending, error, and generating states.
## Steps
1. Replace `class="message-item ..."` and nested class usage with Tailwind class lists that reference tokens (e.g., `bg-[var(--surface-elevated)]`, `text-[var(--text-secondary)]`).
2. Create any small reusable utility classes (e.g., `.chip`, `.card`) in a new `src/styles/components.css` if repeated patterns arise; keep them token-based.
3. Delete the now-unused `.message-item` block from `src/index.css`.
4. Verify conditional states (queued badge, sending indicator, error block) still render with correct colors/typography.
## Testing Checklist
- [ ] Run `npm run dev` and load a session with mixed message states.
- [ ] Toggle between light/dark themes to confirm token usage.
- [ ] Use dev tools to ensure no stale `.message-item` selectors remain in the DOM.
## Dependencies
- Depends on Tasks 043 and 044.
- Blocks future component refactor tasks (046+).
## Estimated Time
0.75 hours
## Notes
- Capture before/after screenshots (light + dark, streamed message) for review.
- Mention any new utility classes in the PR description so reviewers know where to look.

View File

@@ -0,0 +1,34 @@
# Task 046 - Prompt Input Tailwind Refactor
## Goal
Port the prompt input stack to Tailwind utilities and shared tokens so it no longer depends on custom selectors in `src/index.css`.
## Prerequisites
- Tasks 043-045 complete (color and typography tokens available, message item refactored).
## Acceptance Criteria
- [ ] `src/components/prompt-input.tsx` and nested elements use Tailwind + token classes for layout, borders, and typography.
- [ ] Legacy selectors in `src/index.css` matching `.prompt-input-container`, `.prompt-input-wrapper`, `.prompt-input`, `.send-button`, `.prompt-input-hints`, `.hint`, `.hint kbd`, and related variants are removed or replaced with token-based utilities.
- [ ] Input states (focus, disabled, multi-line expansion) and keyboard hint row look identical in light/dark modes.
- [ ] Esc debounce handling and attachment hooks remain functional.
## Steps
1. Audit existing markup in `prompt-input.tsx` and note the current class usage.
2. Replace className strings with Tailwind utility stacks that reference CSS variables (e.g., `bg-[var(--surface-base)]`, `text-[var(--text-muted)]`).
3. Introduce small reusable helpers (e.g., `.kbd` token utility) in `src/styles/components.css` if patterns recur elsewhere.
4. Delete superseded CSS blocks from `src/index.css` once equivalents exist.
5. Verify light/dark theme parity and interaction states manually.
## Testing Checklist
- [ ] Run `npm run build`.
- [ ] In dev mode, send a message with/without attachments, toggle disabled state, and confirm keyboard hints render correctly.
## Dependencies
- Blocks future component refactors for the input stack.
## Estimated Time
0.75 hours
## Notes
- Branch suggestion: `feature/task-046-prompt-input-refactor`.
- Capture light/dark screenshots for review if any subtle spacing changes occur.

View File

@@ -0,0 +1,35 @@
# Task 047 - Tabs Tailwind Refactor
## Goal
Refactor instance and session tab components to rely on Tailwind utilities and shared tokens, aligning with the design spec for spacing, typography, and state indicators.
## Prerequisites
- Task 046 complete (prompt input refactor) to keep merges manageable.
## Acceptance Criteria
- [ ] `src/components/instance-tabs.tsx` and `src/components/session-tabs.tsx` no longer reference legacy `.instance-tabs`, `.session-tabs`, `.session-tab` classes from `src/index.css`.
- [ ] Global CSS for tab bars (`.connection-status`, `.status-indicator`, `.status-dot`, `.session-view`) is replaced or minimized in favor of Tailwind utilities and token variables.
- [ ] Active, hover, and error states match the UI spec in both themes, including badges/icons.
- [ ] Tab bar layout remains responsive with overflow scrolling where applicable.
## Steps
1. Catalogue existing tab-related classes used in both components and in `src/index.css`.
2. Convert markup to Tailwind class lists, leveraging tokens for colors/borders (e.g., `bg-[var(--surface-secondary)]`).
3. Add any reusable tab utilities to `src/styles/components.css` if needed.
4. Remove obsolete CSS blocks from `src/index.css` once coverage is confirmed.
5. Smoke-test tab interactions: switching, closing (where allowed), error state display, and overflow behavior.
## Testing Checklist
- [ ] Run `npm run build`.
- [ ] In dev mode, load multiple instances/sessions to verify active styling and horizontal scrolling.
## Dependencies
- Depends on Task 046 completion.
- Blocks subsequent polish tasks for tab-level layout.
## Estimated Time
1.0 hour
## Notes
- Branch suggestion: `feature/task-047-tabs-tailwind-refactor`.
- Provide before/after screenshots (light/dark) of both tab bars in the PR for clarity.

View File

@@ -0,0 +1,35 @@
# Task 048 - Message Stream & Tool Call Refactor
## Goal
Finish migrating the message stream container, tool call blocks, and reasoning UI to Tailwind utilities and shared tokens.
## Prerequisites
- Tasks 045-047 complete (message item, prompt input, and tabs refactored).
## Acceptance Criteria
- [ ] `src/components/message-stream.tsx`, `src/components/message-part.tsx`, and tool call subcomponents no longer depend on legacy classes (`.message-stream`, `.tool-call-message`, `.tool-call`, `.tool-call-header`, `.tool-call-preview`, `.tool-call-details`, `.reasoning-*`, `.scroll-to-bottom`, etc.).
- [ ] Global CSS definitions for these selectors are removed from `src/index.css`, replaced by Tailwind utilities and token-aware helpers.
- [ ] Scroll behavior (auto-scroll, “scroll to bottom” button) and collapsing/expanding tool calls behave as before in light/dark modes.
- [ ] Markdown/code blocks continue to render properly within the new layout.
## Steps
1. Inventory remaining global selectors in `src/index.css` associated with the stream/tool-call UI.
2. Update component markup to use Tailwind classes, creating shared helpers in `src/styles/components.css` when patterns repeat.
3. Remove or rewrite the corresponding CSS blocks in `src/index.css` to avoid duplication.
4. Validate tool call states (pending/running/success/error), reasoning blocks, and markdown rendering visually.
## Testing Checklist
- [ ] Run `npm run build`.
- [ ] In dev mode, stream a message with tool calls and reasoning to ensure toggles and scroll helpers work.
## Dependencies
- Depends on prompt input and tab refactors to reduce merge conflicts.
- Unlocks subsequent layout cleanups for logs and empty states.
## Estimated Time
1.25 hours
## Notes
- Branch suggestion: `feature/task-048-message-stream-refactor`.
- Capture short screen recording or screenshots if tool call layout adjustments were required.
- Legacy `message-stream.tsx` has since been replaced by `message-stream-v2.tsx` using the normalized message store.

View File

@@ -0,0 +1,33 @@
# Task 049 - Unified & File Picker Tailwind Refactor
## Goal
Replace the hardcoded gray/blue class stacks in `UnifiedPicker` and `FilePicker` with token-based Tailwind utilities and shared dropdown helpers.
## Prerequisites
- Tasks 041-048 complete (tokens, message components, tabs, prompt input refactored).
## Acceptance Criteria
- [ ] `src/components/unified-picker.tsx` and `src/components/file-picker.tsx` reference token-backed utility classes for surfaces, borders, typography, and states.
- [ ] A shared dropdown utility block lives in `src/styles/components.css` (e.g., `.dropdown-surface`, `.dropdown-item`, `.dropdown-highlight`).
- [ ] Legacy class strings using `bg-white`, `bg-gray-*`, `dark:bg-gray-*`, etc., are removed from both components.
- [ ] Loading/empty states, highlights, and diff chips preserve their current behavior in light/dark themes.
## Steps
1. Inventory all className usages in the two picker components.
2. Add reusable dropdown utilities to `components.css`, powered by the existing tokens.
3. Update component markup to use the new helpers and Tailwind utilities with `var(--token)` references for color.
4. Smoke test: open the picker, filter results, confirm loading/empty states and diff counts.
## Testing Checklist
- [ ] Run `npm run build`.
- [ ] In dev mode, trigger the picker from prompt input (file mention) and ensure keyboard navigation/hover states look correct.
## Dependencies
- Blocks further cleanup of selector components and modals.
## Estimated Time
0.75 hours
## Notes
- Branch name suggestion: `feature/task-049-unified-picker-refactor`.
- Include before/after light & dark screenshots in the PR description if any visual tweaks occur.

View File

@@ -0,0 +1,34 @@
# Task 050 - Selector Popover Tailwind Refactor
## Goal
Bring `ModelSelector` and `OpencodeBinarySelector` popovers in line with the design tokens, eliminating manual light/dark class stacks.
## Prerequisites
- Task 049 complete (dropdown utility helpers ready).
## Acceptance Criteria
- [ ] `src/components/model-selector.tsx` and `src/components/opencode-binary-selector.tsx` use token-backed utilities for surfaces, borders, focus rings, and typography.
- [ ] Shared selector utilities live in `src/styles/components.css` (e.g., `.selector-trigger`, `.selector-option`, `.selector-section`).
- [ ] All `dark:bg-gray-*` / `text-gray-*` combinations are removed in favor of tokens or newly added utilities.
- [ ] Combobox states (highlighted, selected, disabled) and validation overlays preserve current UX.
## Steps
1. Map all class usages in both selectors, noting duplicated patterns (trigger button, list items, badges).
2. Create selector-specific helpers in `components.css` that rely on tokens.
3. Update component markup to use the helpers and Tailwind utility additions.
4. Verify validation/binary version chips and search input styling in both themes.
## Testing Checklist
- [ ] Run `npm run build`.
- [ ] In dev mode, open the selector popovers, search, and select options to confirm styling and focus rings.
## Dependencies
- Depends on Task 049 dropdown helpers.
- Blocks folder selection advanced settings refactor.
## Estimated Time
1.0 hour
## Notes
- Branch suggestion: `feature/task-050-selector-popover-refactor`.
- Document any intentional color tweaks in the PR if tokens reveal contrast issues.

View File

@@ -0,0 +1,34 @@
# Task 051 - Command Palette & Keyboard Hint Refactor
## Goal
Align the command palette modal and keyboard hint UI with the shared token system, removing bespoke gray/black overlay styling.
## Prerequisites
- Task 050 complete (selector helpers available for reuse).
## Acceptance Criteria
- [ ] `src/components/command-palette.tsx` uses token-backed utilities for overlay, surface, list items, and focus states.
- [ ] `src/components/keyboard-hint.tsx` and any inline `<kbd>` styling leverage reusable helpers (`.kbd` etc.) from `components.css`.
- [ ] Legacy utility combos in these components (`bg-gray-*`, `dark:bg-gray-*`, `text-gray-*`) are eliminated.
- [ ] Palette overlay opacity, search field, section headers, and highlighted items match existing behavior in both themes.
## Steps
1. Extract repeated modal/dropdown patterns into helpers (overlay, surface, list item) if not already present.
2. Update command palette markup to use the helpers and token-aware Tailwind classes.
3. Refactor `keyboard-hint.tsx` to rely on shared `.kbd` styling and tokens.
4. Verify keyboard navigation, highlighted items, and section headers visually.
## Testing Checklist
- [ ] Run `npm run build`.
- [ ] In dev mode, open the command palette, search, navigate with arrow keys, and confirm highlight/focus styling.
## Dependencies
- Depends on Task 050.
- Blocks folder selection advanced settings refactor (which reuses keyboard hints).
## Estimated Time
0.75 hours
## Notes
- Branch suggestion: `feature/task-051-command-palette-refactor`.
- Include GIF/screenshots if overlay opacity or highlight timing needed adjustment.

View File

@@ -0,0 +1,34 @@
# Task 052 - Folder Selection & Info Panels Refactor
## Goal
Migrate the folder selection view, info view, and logs view to token-driven utilities, removing bespoke gray styling blocks.
## Prerequisites
- Task 051 complete (modal/kbd helpers ready).
## Acceptance Criteria
- [ ] `src/components/folder-selection-view.tsx`, `src/components/info-view.tsx`, and `src/components/logs-view.tsx` use token-backed utilities or shared helpers from `components.css`.
- [ ] Panel surfaces, headers, section dividers, and scroll containers reference tokens rather than raw Tailwind color values.
- [ ] `.session-view` global rule in `src/index.css` is replaced with a utility/helper equivalent.
- [ ] Loading/empty states and action buttons keep their existing behavior and contrast in both themes.
## Steps
1. Catalog remaining raw color classes in the three components.
2. Add reusable panel helpers (e.g., `.panel`, `.panel-header`, `.panel-body`) to `components.css` if helpful.
3. Update component markup to use helpers and token-aware Tailwind classes.
4. Remove residual `bg-gray-*` / `text-gray-*` from these components and clean up `index.css`.
## Testing Checklist
- [ ] Run `npm run build`.
- [ ] Manual spot check: recent folders list, info view logs, logs view streaming; confirm hover states and CTAs.
## Dependencies
- Depends on Task 051.
- Blocks final markdown/global CSS cleanup.
## Estimated Time
1.25 hours
## Notes
- Branch suggestion: `feature/task-052-folder-info-panels-refactor`.
- Capture screenshots (light/dark) of folder selection and logs panels for review.

View File

@@ -0,0 +1,34 @@
# Task 053 - Markdown & Code Block Styling Refactor
## Goal
Extract the remaining markdown/code-block styling from `src/index.css` into token-aware utilities and ensure all prose rendering uses the shared system.
## Prerequisites
- Task 052 complete (panels cleaned up).
## Acceptance Criteria
- [ ] `src/index.css` no longer contains `.prose`, `.markdown-code-block`, `.code-block-header`, `.code-block-copy`, or `.code-block-inline` blocks; equivalent styling lives in a new `src/styles/markdown.css` (imported from `index.css`) and/or token helpers.
- [ ] New markdown helpers rely on tokens for colors, borders, and typography (no hard-coded hex values).
- [ ] Code block copy button, language label, and inline code maintain current interaction and contrast in both themes.
- [ ] `MessagePart` markdown rendering (`src/components/markdown.tsx`) automatically picks up the new styling without component changes.
## Steps
1. Move markdown-related CSS into a dedicated `styles/markdown.css` file, rewriting colors with tokens.
2. Replace any legacy values (e.g., `text-gray-700`) with token references.
3. Update `src/index.css` to import the new stylesheet after tokens/components layers.
4. Verify formatted markdown in the message stream (headings, lists, code blocks, copy button).
## Testing Checklist
- [ ] Run `npm run build`.
- [ ] Manually view messages with markdown (headings, inline code, block code, tables) in both themes.
## Dependencies
- Depends on Task 052.
- Blocks final cleanup task for attachment/keyboard chips.
## Estimated Time
0.75 hours
## Notes
- Branch suggestion: `feature/task-053-markdown-style-refactor`.
- If additional tokens are needed (e.g., `--surface-prose`), document them in the PR.

View File

@@ -0,0 +1,34 @@
# Task 054 - Attachment & Misc Chip Refactor
## Goal
Standardize attachment chips and any remaining inline badge styles to use the shared token helpers.
## Prerequisites
- Task 053 complete (markdown styling moved).
## Acceptance Criteria
- [ ] `src/components/attachment-chip.tsx` uses the `.attachment-chip` and `.attachment-remove` helpers (or equivalent token-backed utilities) instead of hardcoded Tailwind color stacks.
- [ ] Any other chip/badge helpers introduced in earlier tasks reference the same token palette (audit `folder-selection-view.tsx`, `unified-picker.tsx`, etc.).
- [ ] No component contains inline `bg-blue-*` / `dark:bg-blue-*` combinations after the refactor.
- [ ] Interaction states (hover, focus) remain consistent in both themes.
## Steps
1. Update `attachment-chip.tsx` to import and use the shared helper classes.
2. Search the codebase for remaining `bg-blue-`, `bg-gray-900`, `dark:bg-blue-` patterns; convert them to tokenized utilities or helpers.
3. Adjust `components.css` helpers if needed (e.g., expose variations for neutral vs accent chips).
4. Verify attachments display correctly in the prompt input and message list.
## Testing Checklist
- [ ] Run `npm run build`.
- [ ] Manually add/remove attachments via the prompt input, confirming chip styling survives theme toggle.
## Dependencies
- Depends on Task 053.
- Finalizes legacy styling removal.
## Estimated Time
0.5 hours
## Notes
- Branch suggestion: `feature/task-054-attachment-chip-refactor`.
- Document any new helper names in the task PR for traceability.

View File

@@ -0,0 +1,37 @@
---
title: Symbol Attachments
description: Attach code symbols with LSP integration.
---
Implement Symbol Attachments
---
### LSP Integration
Integrate with the Language Server Protocol (LSP) to get a list of symbols in the current project.
---
### @ Symbol Autocomplete
When a user types `@` followed by a symbol-like pattern, trigger an autocomplete with relevant code symbols.
Include symbols from various file types supported by LSP.
---
### Attach and Navigate Symbols
Allow users to select a symbol from the autocomplete list to attach it to the prompt.
Display attached symbols as interactive chips. Optionally, implement functionality to jump to the symbol definition in an editor.
---
### Acceptance Criteria
- Typing `@` followed by a partial symbol name displays matching symbol suggestions.
- Selecting a symbol creates an attachment chip.
- Attached symbols are correctly formatted for submission.
- (Optional) Clicking a symbol chip navigates to its definition.