Working messages display

This commit is contained in:
Shantur Rathore
2025-10-22 22:10:51 +01:00
commit fa77b4e82e
53 changed files with 9336 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 the OpenCode Client.
## 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)
- [ ] 006 - Instance & Session Tabs
- [ ] 007 - Message Display
- [ ] 008 - SSE Integration
- [ ] 009 - Prompt Input (Basic)
- [ ] 010 - Tool Call Rendering
### Phase 3: Essential Features (Tasks 011-015)
- [ ] 011 - Agent/Model Selectors
- [ ] 012 - Markdown Rendering
- [ ] 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 OpenCode Client"
- 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: OpenCode Client
- 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 OpenCode Client" 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,281 @@
# 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)
- Heading: "Welcome to OpenCode Client"
- 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,810 @@
# 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.
## 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