Working messages display
This commit is contained in:
177
tasks/README.md
Normal file
177
tasks/README.md
Normal 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
|
||||
262
tasks/done/001-project-setup.md
Normal file
262
tasks/done/001-project-setup.md
Normal 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
|
||||
281
tasks/done/002-empty-state-ui.md
Normal file
281
tasks/done/002-empty-state-ui.md
Normal 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
|
||||
430
tasks/done/003-process-manager.md
Normal file
430
tasks/done/003-process-manager.md
Normal 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)
|
||||
504
tasks/done/004-sdk-integration.md
Normal file
504
tasks/done/004-sdk-integration.md
Normal 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
|
||||
333
tasks/done/005-session-picker-modal.md
Normal file
333
tasks/done/005-session-picker-modal.md
Normal 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)
|
||||
591
tasks/done/006-instance-session-tabs.md
Normal file
591
tasks/done/006-instance-session-tabs.md
Normal 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
|
||||
810
tasks/done/007-message-display.md
Normal file
810
tasks/done/007-message-display.md
Normal 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
|
||||
Reference in New Issue
Block a user