Working messages display

This commit is contained in:
Shantur Rathore
2025-10-22 22:10:51 +01:00
commit fa77b4e82e
53 changed files with 9336 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
dist/
release/
.DS_Store
*.log
.vite/
.electron-vite/
out/

149
PROGRESS.md Normal file
View File

@@ -0,0 +1,149 @@
# OpenCode Client - Development Progress
## Completed Tasks
### Task 001: Project Setup ✅
- Set up Electron + SolidJS + Vite + TypeScript
- Configured TailwindCSS v3 (downgraded from v4 for electron-vite compatibility)
- Build pipeline with electron-vite
- Application window management
- Application menu with keyboard shortcuts
### Task 002: Empty State UI & Folder Selection ✅
- Empty state component with styled UI
- Native folder picker integration
- IPC handlers for folder selection
- UI state management with SolidJS signals
- Loading states with spinner
- Keyboard shortcuts (Cmd/Ctrl+N)
### Task 003: Process Manager ✅
- Process spawning: `opencode serve --port 0`
- Port detection from stdout (regex: `opencode server listening on http://...`)
- Process lifecycle management (spawn, kill, cleanup)
- IPC communication for instance management
- Instance state tracking (starting → ready → stopped/error)
- Auto-cleanup on app quit
- Error handling & timeout protection (10s)
- Graceful shutdown (SIGTERM → SIGKILL)
### Task 004: SDK Integration ✅
- Installed `@opencode-ai/sdk` package
- SDK manager for client lifecycle
- Session fetching from OpenCode server
- Agent fetching (`client.app.agents()`)
- Provider fetching (`client.config.providers()`)
- Session store with SolidJS signals
- Instance store updated with SDK client
- Loading states for async operations
- Error handling for network failures
### Task 005: Session Picker Modal ✅
- Modal dialog with Kobalte Dialog
- Lists ALL existing sessions (scrollable)
- Session metadata display (title, relative timestamp)
- Native HTML select dropdown for agents
- Auto-selects first agent by default
- Create new session with selected agent
- Cancel button stops instance and closes modal
- Resume session on click
- Empty state for no sessions
- Loading state for agents
- Keyboard navigation (Escape to cancel)
## Current State
**Working Features:**
- ✅ App launches with empty state
- ✅ Folder selection via native dialog
- ✅ OpenCode server spawning per folder
- ✅ Port extraction and process tracking
- ✅ SDK client connection to running servers
- ✅ Session list fetching and display
- ✅ Agent and provider data fetching
- ✅ Session picker modal on instance creation
- ✅ Resume existing sessions
- ✅ Create new sessions with agent selection
**File Structure:**
```
packages/opencode-client/
├── electron/
│ ├── main/
│ │ ├── main.ts (window + IPC setup)
│ │ ├── menu.ts (app menu)
│ │ ├── ipc.ts (instance IPC handlers)
│ │ └── process-manager.ts (server spawning)
│ └── preload/
│ └── index.ts (IPC bridge)
├── src/
│ ├── components/
│ │ ├── empty-state.tsx
│ │ └── session-picker.tsx
│ ├── lib/
│ │ └── sdk-manager.ts
│ ├── stores/
│ │ ├── ui.ts
│ │ ├── instances.ts
│ │ └── sessions.ts
│ ├── types/
│ │ ├── electron.d.ts
│ │ ├── instance.ts
│ │ └── session.ts
│ └── App.tsx
├── tasks/
│ ├── done/ (001-005)
│ └── todo/ (006+)
└── docs/
```
## Next Steps
### Task 006: Message Stream UI (NEXT)
- Message display component
- User/assistant message rendering
- Markdown support with syntax highlighting
- Tool use visualization
- Auto-scroll behavior
### Task 007: Prompt Input
- Text input with multi-line support
- Send button
- File attachment support
- Keyboard shortcuts (Enter to send, Shift+Enter for newline)
### Task 008: Instance Tabs
- Tab bar for multiple instances
- Switch between instances
- Close instance tabs
- "+" button for new instance
## Build & Test
```bash
cd packages/opencode-client
bun run build
bunx electron .
```
**Known Issue:**
- Dev mode (`bun dev`) fails due to Bun workspace hoisting + electron-vite
- Workaround: Use production builds for testing
## Dependencies
- Electron 38
- SolidJS 1.8
- TailwindCSS 3.x
- @opencode-ai/sdk
- @kobalte/core (Dialog)
- Vite 5
- TypeScript 5
## Stats
- **Tasks completed:** 5/5 (Phase 1)
- **Files created:** 18+
- **Lines of code:** ~1500+
- **Build time:** ~7s
- **Bundle size:** 152KB (renderer)

216
README.md Normal file
View File

@@ -0,0 +1,216 @@
# OpenCode Client
A cross-platform desktop application for interacting with OpenCode servers, built with Electron and SolidJS.
## Overview
OpenCode Client provides a multi-instance, multi-session interface for working with AI-powered coding assistants. It manages OpenCode server processes, handles real-time message streaming, and provides an intuitive UI for coding with AI.
**🎯 MVP Focus:** This project prioritizes functionality over performance. Performance optimization is intentionally deferred to post-MVP phases. See [docs/MVP-PRINCIPLES.md](docs/MVP-PRINCIPLES.md) for details.
## Features
### Core Capabilities
- **Multi-Instance Management**: Work on multiple projects simultaneously
- **Session Persistence**: Resume conversations across app restarts
- **Real-time Streaming**: Live message updates via Server-Sent Events
- **Tool Execution Visibility**: See bash commands, file edits, and other tool calls
- **Agent & Model Switching**: Easily switch between different AI agents and models
- **Markdown Rendering**: Beautiful code highlighting and formatting
### Advanced Features (Planned)
- Virtual scrolling for large conversations
- Full-text search across sessions
- Workspace management
- Custom themes
- Plugin system
## Architecture
See [docs/architecture.md](docs/architecture.md) for detailed architecture documentation.
### High-Level Overview
```
Electron App
├── Main Process (Node.js)
│ ├── Window management
│ ├── OpenCode server spawning
│ └── IPC communication
├── Renderer Process (SolidJS)
│ ├── UI components
│ ├── State management (stores)
│ └── SDK client communication
└── Multiple OpenCode Servers
└── One per instance/project folder
```
## Prerequisites
- Node.js 18+
- Bun package manager
- OpenCode CLI installed and in PATH
## Installation
```bash
# Install dependencies
bun install
# Run in development mode
bun run dev
# Build for production
bun run build
# Package for distribution
bun run package:mac # macOS
bun run package:win # Windows
bun run package:linux # Linux
```
## Development
### Project Structure
```
packages/opencode-client/
├── docs/ # Documentation
├── tasks/ # Task management
│ ├── todo/ # Pending tasks
│ └── done/ # Completed tasks
├── electron/ # Electron main process
│ ├── main/ # Main process code
│ ├── preload/ # Preload scripts
│ └── resources/ # App icons, etc.
└── src/ # Renderer (UI) code
├── components/ # UI components
├── stores/ # State management
├── lib/ # Utilities
├── hooks/ # SolidJS hooks
└── types/ # TypeScript types
```
### Tech Stack
- **Electron** - Desktop wrapper
- **SolidJS** - Reactive UI framework
- **TypeScript** - Type safety
- **Vite** - Build tool
- **TailwindCSS** - Styling
- **Kobalte** - Accessible UI primitives
- **OpenCode SDK** - API client
### Scripts
```bash
bun run dev # Start dev server with hot reload
bun run build # Build for production
bun run typecheck # Run TypeScript type checking
bun run preview # Preview production build
```
## Usage
### Starting an Instance
1. Launch the app
2. Click "Select Folder" or press Cmd/Ctrl+N
3. Choose a project folder
4. Wait for OpenCode server to start
5. Select an existing session or create new one
### Working with Sessions
- **Switch sessions**: Click session tab at bottom
- **Create session**: Click "+" button or Cmd/Ctrl+T
- **Change agent**: Use agent dropdown
- **Change model**: Use model dropdown
### Sending Messages
- Type in the input box at bottom
- Press Enter to send (Shift+Enter for new line)
- Use `/` for commands
- Use `@` to mention files
## Documentation
- [Architecture](docs/architecture.md) - System design and structure
- [User Interface](docs/user-interface.md) - UI specifications
- [Technical Implementation](docs/technical-implementation.md) - Implementation details
- [Build Roadmap](docs/build-roadmap.md) - Development plan and phases
- [Tasks](tasks/README.md) - Task breakdown and tracking
## Build Phases
The project is built in phases:
1. **Phase 1**: Foundation (Tasks 001-005)
2. **Phase 2**: Core Chat (Tasks 006-010)
3. **Phase 3**: Essential Features (Tasks 011-015)
4. **Phase 4**: Multi-Instance (Tasks 016-020)
5. **Phase 5**: Advanced Input (Tasks 021-025)
6. **Phase 6**: Polish & UX (Tasks 026-030)
7. **Phase 7**: System Integration (Tasks 031-035)
8. **Phase 8**: Advanced Features (Tasks 036-040)
See [docs/build-roadmap.md](docs/build-roadmap.md) for detailed phase information.
## Contributing
### Getting Started
1. Read the documentation in `docs/`
2. Check `tasks/todo/` for available tasks
3. Pick a task and create a feature branch
4. Follow the task steps
5. Submit PR when complete
### Code Style
- Use TypeScript for all code
- Follow existing patterns and conventions
- Write clear, descriptive commit messages
- Add comments for complex logic
- Keep components small and focused
### Testing
- Test manually at minimum window size (800x600)
- Test on multiple platforms (macOS, Windows, Linux)
- Verify keyboard navigation works
- Check accessibility with screen readers
## Troubleshooting
### Server Won't Start
- Verify `opencode` is in PATH: `which opencode`
- Check folder permissions
- Review server logs in Logs tab
- Try restarting the instance
### Connection Issues
- Check if server is running: `ps aux | grep opencode`
- Verify port is correct in instance metadata
- Check for firewall blocking localhost
- Try killing and restarting server
### Performance Issues
- Check number of messages in session
- Monitor memory usage in Activity Monitor
- Consider enabling virtual scrolling (Phase 8)
- Close unused instances
## License
[License TBD]
## Credits
Built with ❤️ for the OpenCode project.

178
docs/INDEX.md Normal file
View File

@@ -0,0 +1,178 @@
# Documentation Index
Quick reference to all documentation files.
## Main Documents
### [README.md](../README.md)
Project overview, installation, and getting started guide.
### [SUMMARY.md](SUMMARY.md)
Executive summary of the entire project - **start here!**
### [MVP-PRINCIPLES.md](MVP-PRINCIPLES.md)
**MVP development philosophy** - Focus on functionality, NOT performance ⚡
---
## Specification Documents
### [architecture.md](architecture.md)
**Complete system architecture**
- Component layers and responsibilities
- State management structure
- Data flow diagrams
- Technology stack
- Security and performance considerations
**Read this to understand:** How the app is structured
### [user-interface.md](user-interface.md)
**Complete UI/UX specifications**
- Every screen and component layout
- Visual design specifications
- Interaction patterns
- Accessibility requirements
- Color schemes and typography
**Read this to understand:** What the app looks like and how users interact
### [technical-implementation.md](technical-implementation.md)
**Implementation details**
- File structure
- TypeScript interfaces
- Process management logic
- SDK integration patterns
- IPC communication
- Error handling strategies
**Read this to understand:** How to actually build it
### [build-roadmap.md](build-roadmap.md)
**Development plan**
- 8 phases of development
- Task dependencies
- Timeline estimates
- Success criteria
- Risk mitigation
**Read this to understand:** The development journey from start to finish
---
## Task Documents
### [tasks/README.md](../tasks/README.md)
**Task management guide**
- Task workflow
- Naming conventions
- How to work on tasks
- Progress tracking
### Task Files (in tasks/todo/)
- **001-project-setup.md** - Electron + SolidJS boilerplate
- **002-empty-state-ui.md** - Initial UI with folder selection
- **003-process-manager.md** - OpenCode server spawning
- **004-sdk-integration.md** - API client integration
- **005-session-picker-modal.md** - Session selection UI
More tasks will be added as we progress through phases.
---
## Reading Order
### For First-Time Readers:
1. [SUMMARY.md](SUMMARY.md) - Get the big picture
2. [architecture.md](architecture.md) - Understand the structure
3. [user-interface.md](user-interface.md) - See what you're building
4. [build-roadmap.md](build-roadmap.md) - Understand the plan
5. [tasks/README.md](../tasks/README.md) - Learn the workflow
### For Implementers:
1. [tasks/README.md](../tasks/README.md) - Understand task workflow
2. [technical-implementation.md](technical-implementation.md) - Implementation patterns
3. [tasks/todo/001-\*.md](../tasks/todo/) - Start with first task
4. Refer to architecture.md and user-interface.md as needed
### For Designers:
1. [user-interface.md](user-interface.md) - Complete UI specs
2. [architecture.md](architecture.md) - Component structure
3. [SUMMARY.md](SUMMARY.md) - Feature overview
### For Project Managers:
1. [SUMMARY.md](SUMMARY.md) - Executive overview
2. [build-roadmap.md](build-roadmap.md) - Timeline and phases
3. [tasks/README.md](../tasks/README.md) - Task tracking
---
## Quick Reference
### Common Questions
**Q: Where do I start?**
A: Read [SUMMARY.md](SUMMARY.md), then start [Task 001](../tasks/todo/001-project-setup.md)
**Q: How long will this take?**
A: See [build-roadmap.md](build-roadmap.md) - MVP in 3-7 weeks depending on commitment
**Q: What does the UI look like?**
A: See [user-interface.md](user-interface.md) for complete specifications
**Q: How does it work internally?**
A: See [architecture.md](architecture.md) for system design
**Q: How do I build feature X?**
A: See [technical-implementation.md](technical-implementation.md) for patterns
**Q: What's the development plan?**
A: See [build-roadmap.md](build-roadmap.md) for phases
---
## Document Status
| Document | Status | Last Updated |
| --------------------------- | ----------- | ------------ |
| README.md | ✅ Complete | 2024-10-22 |
| SUMMARY.md | ✅ Complete | 2024-10-22 |
| architecture.md | ✅ Complete | 2024-10-22 |
| user-interface.md | ✅ Complete | 2024-10-22 |
| technical-implementation.md | ✅ Complete | 2024-10-22 |
| build-roadmap.md | ✅ Complete | 2024-10-22 |
| tasks/README.md | ✅ Complete | 2024-10-22 |
| Task 001-005 | ✅ Complete | 2024-10-22 |
---
## Contributing to Documentation
When updating documentation:
1. Update the relevant file
2. Update "Last Updated" in this index
3. Update SUMMARY.md if adding major changes
4. Keep consistent formatting and style
---
_This index will be updated as more documentation is added._

326
docs/MVP-PRINCIPLES.md Normal file
View File

@@ -0,0 +1,326 @@
# MVP Development Principles
## Core Philosophy
**Focus on functionality, NOT performance.**
The MVP (Minimum Viable Product) is about proving the concept and getting feedback. Performance optimization comes later, after we validate the product with real users.
---
## What We Care About in MVP
### ✅ DO Focus On:
1. **Functionality**
- Does it work?
- Can users complete their tasks?
- Are all core features present?
2. **Correctness**
- Does it produce correct results?
- Does error handling work?
- Is data persisted properly?
3. **User Experience**
- Is the UI intuitive?
- Are loading states clear?
- Are error messages helpful?
4. **Stability**
- Does it crash?
- Can users recover from errors?
- Does it lose data?
5. **Code Quality**
- Is code readable?
- Are types correct?
- Is it maintainable?
### ❌ DON'T Focus On:
1. **Performance Optimization**
- Virtual scrolling
- Message batching
- Lazy loading
- Memory optimization
- Render optimization
2. **Scalability**
- Handling 1000+ messages
- Multiple instances with 100+ sessions
- Large file attachments
- Massive search indexes
3. **Advanced Features**
- Plugins
- Advanced search
- Custom themes
- Workspace management
---
## Specific MVP Guidelines
### Messages & Rendering
**Simple approach:**
```typescript
// Just render everything - no virtual scrolling
<For each={messages()}>
{(message) => <MessageItem message={message} />}
</For>
```
**Don't worry about:**
- Sessions with 500+ messages
- Re-render performance
- Memory usage
- Scroll performance
**When to optimize:**
- Post-MVP (Phase 8)
- Only if users report issues
- Based on real-world usage data
### State Management
**Simple approach:**
- Use SolidJS signals directly
- No batching
- No debouncing
- No caching layers
**Don't worry about:**
- Update frequency
- Number of reactive dependencies
- State structure optimization
### Process Management
**Simple approach:**
- Spawn servers as needed
- Kill on close
- Basic error handling
**Don't worry about:**
- Resource limits (max processes)
- CPU/memory monitoring
- Restart optimization
- Process pooling
### API Communication
**Simple approach:**
- Direct SDK calls
- Basic error handling
- Simple retry (if at all)
**Don't worry about:**
- Request batching
- Response caching
- Optimistic updates
- Request deduplication
---
## Decision Framework
When implementing any feature, ask:
### Is this optimization needed for MVP?
**NO if:**
- It only helps with large datasets
- It only helps with many instances
- It's about speed, not correctness
- Users won't notice the difference
- It adds significant complexity
**YES if:**
- Users can't complete basic tasks without it
- App is completely unusable without it
- It prevents data loss
- It's a security requirement
### Examples
**Virtual Scrolling:** ❌ NO for MVP
- MVP users won't have 1000+ message sessions
- Simple list rendering works fine for <100 messages
- Add in Phase 8 if needed
**Error Handling:** YES for MVP
- Users need clear feedback when things fail
- Prevents frustration and data loss
- Core to usability
**Message Batching:** NO for MVP
- SolidJS handles updates efficiently
- Only matters at very high frequency
- Add later if users report lag
**Session Persistence:** YES for MVP
- Users expect sessions to persist
- Losing work is unacceptable
- Core functionality
---
## Testing Approach
### MVP Testing Focus
**Test for:**
- Correctness (does it work?)
- Error handling (does it fail gracefully?)
- Data integrity (is data saved?)
- User flows (can users complete tasks?)
**Don't test for:**
- Performance benchmarks
- Load testing
- Stress testing
- Scalability limits
### Acceptable Performance
For MVP, these are **acceptable:**
- 100 messages render in 1 second
- UI slightly laggy during heavy streaming
- Memory usage grows with message count
- Multiple instances slow down app
These become **unacceptable** only if:
- Users complain
- App becomes unusable
- Basic tasks can't be completed
---
## When to Optimize
### Post-MVP Triggers
Add optimization when:
1. **User Feedback**
- Multiple users report slowness
- Users abandon due to performance
- Performance prevents usage
2. **Measurable Issues**
- App freezes for >2 seconds
- Memory usage causes crashes
- UI becomes unresponsive
3. **Phase 8 Reached**
- MVP complete and validated
- User base established
- Performance becomes focus
### How to Optimize
When the time comes:
1. **Measure First**
- Profile actual bottlenecks
- Use real user data
- Identify specific problems
2. **Target Fixes**
- Fix the specific bottleneck
- Don't over-engineer
- Measure improvement
3. **Iterate**
- Optimize one thing at a time
- Verify with users
- Stop when "fast enough"
---
## Communication with Users
### During Alpha/Beta
**Be honest about performance:**
- "This is an MVP - expect some slowness with large sessions"
- "We're focused on functionality first"
- "Performance optimization is planned for v1.x"
**Set expectations:**
- Works best with <200 messages per session
- Multiple instances may slow things down
- We'll optimize based on your feedback
### Collecting Feedback
**Ask about:**
- What features are missing?
- What's confusing?
- What doesn't work?
- Is it too slow to use?
**Don't ask about:**
- How many milliseconds for X?
- Memory usage specifics
- Benchmark comparisons
---
## Summary
### The MVP Mantra
> **Make it work, then make it better, then make it fast.**
For OpenCode Client MVP:
- **Phase 1-7:** Make it work, make it better
- **Phase 8+:** Make it fast
### Remember
- Premature optimization is the root of all evil
- Real users provide better optimization guidance than assumptions
- Functionality > Performance for MVP
- You can't optimize what users don't use
---
## Quick Reference
**When in doubt, ask:**
1. Is this feature essential for users to do their job? → Build it
2. Is this optimization essential for the feature to work? → Build it
3. Is this just making it faster/more efficient? → Defer to Phase 8
**MVP = Minimum _Viable_ Product**
- Viable = works and is useful
- Viable ≠ optimized and fast

344
docs/SUMMARY.md Normal file
View File

@@ -0,0 +1,344 @@
# OpenCode Client - Project Summary
## What We've Created
A comprehensive specification and task breakdown for building the OpenCode Client desktop application.
## Directory Structure
```
packages/opencode-client/
├── docs/ # Comprehensive documentation
│ ├── architecture.md # System architecture & design
│ ├── user-interface.md # UI/UX specifications
│ ├── technical-implementation.md # Technical details & patterns
│ ├── build-roadmap.md # Phased development plan
│ └── SUMMARY.md # This file
├── tasks/
│ ├── README.md # Task management guide
│ ├── todo/ # Tasks to implement
│ │ ├── 001-project-setup.md
│ │ ├── 002-empty-state-ui.md
│ │ ├── 003-process-manager.md
│ │ ├── 004-sdk-integration.md
│ │ └── 005-session-picker-modal.md
│ └── done/ # Completed tasks (empty)
└── README.md # Project overview
```
## Documentation Overview
### 1. Architecture (architecture.md)
**What it covers:**
- High-level system design
- Component layers (Main process, Renderer, Communication)
- State management approach
- Tab hierarchy (Instance tabs → Session tabs)
- Data flow for key operations
- Technology stack decisions
- Security considerations
**Key sections:**
- Component architecture diagram
- Instance/Session state structures
- Communication patterns (HTTP, SSE)
- Error handling strategies
- Performance considerations
### 2. User Interface (user-interface.md)
**What it covers:**
- Complete UI layout specifications
- Visual design for every component
- Interaction patterns
- Keyboard shortcuts
- Accessibility requirements
- Empty states and error states
- Modal designs
**Key sections:**
- Detailed layout wireframes (ASCII art)
- Component-by-component specifications
- Message rendering formats
- Control bar designs
- Modal/overlay specifications
- Color schemes and typography
### 3. Technical Implementation (technical-implementation.md)
**What it covers:**
- Technology stack details
- Project file structure
- State management patterns
- Process management implementation
- SDK integration approach
- SSE event handling
- IPC communication
- Error handling strategies
- Performance optimizations
**Key sections:**
- Complete project structure
- TypeScript interfaces
- Process spawning logic
- SDK client management
- Message rendering implementation
- Build and packaging config
### 4. Build Roadmap (build-roadmap.md)
**What it covers:**
- 8 development phases
- Task dependencies
- Timeline estimates
- Success criteria per phase
- Risk mitigation
- Release strategy
**Phases:**
1. **Foundation** (Week 1) - Project setup, process management
2. **Core Chat** (Week 2) - Message display, SSE streaming
3. **Essential Features** (Week 3) - Markdown, agents, errors
4. **Multi-Instance** (Week 4) - Multiple projects support
5. **Advanced Input** (Week 5) - Commands, file attachments
6. **Polish** (Week 6) - UX refinements, settings
7. **System Integration** (Week 7) - Native features
8. **Advanced** (Week 8+) - Performance, plugins
## Task Breakdown
### Current Tasks (Phase 1)
**001 - Project Setup** (2-3 hours)
- Set up Electron + SolidJS + Vite
- Configure TypeScript, TailwindCSS
- Create basic project structure
- Verify build pipeline works
**002 - Empty State UI** (2-3 hours)
- Create empty state component
- Implement folder selection dialog
- Add keyboard shortcuts
- Style and test responsiveness
**003 - Process Manager** (4-5 hours)
- Spawn OpenCode server processes
- Parse stdout for port extraction
- Kill processes on command
- Handle errors and timeouts
- Auto-cleanup on app quit
**004 - SDK Integration** (3-4 hours)
- Create SDK client per instance
- Fetch sessions, agents, models
- Implement session CRUD operations
- Add error handling and retries
**005 - Session Picker Modal** (3-4 hours)
- Build modal with session list
- Agent selector for new sessions
- Keyboard navigation
- Loading and error states
**Total Phase 1 time: ~15-20 hours (2-3 weeks part-time)**
## Key Design Decisions
### 1. Two-Level Tabs
- **Level 1**: Instance tabs (one per project folder)
- **Level 2**: Session tabs (multiple per instance)
- Allows working on multiple projects with multiple conversations each
### 2. Process Management in Main Process
- Electron main process spawns servers
- Parses stdout to get port
- IPC sends port to renderer
- Ensures clean shutdown on app quit
### 3. One SDK Client Per Instance
- Each instance has its own HTTP client
- Connects to different port (different server)
- Isolated state prevents cross-contamination
### 4. SolidJS for Reactivity
- Fine-grained reactivity for SSE updates
- No re-render cascades
- Better performance for real-time updates
- Smaller bundle size than React
### 5. No Virtual Scrolling or Performance Optimization in MVP
- Start with simple list rendering
- Don't optimize for large sessions initially
- Focus on functionality, not performance
- Add optimizations in post-MVP phases if needed
- Reduces initial complexity and speeds up development
### 6. Messages and Tool Calls Inline
- All activity shows in main message stream
- Tool calls expandable/collapsible
- File changes visible inline
- Single timeline view
## Implementation Guidelines
### For Each Task:
1. Read task file completely
2. Review related documentation
3. Follow steps in order
4. Check off acceptance criteria
5. Test thoroughly
6. Move to done/ when complete
### Code Standards:
- TypeScript for everything
- No `any` types
- Descriptive variable names
- Comments for complex logic
- Error handling on all async operations
- Loading states for all network calls
### Testing Approach:
- Manual testing at each step
- Test on minimum window size (800x600)
- Test error cases
- Test edge cases (long text, special chars)
- Keyboard navigation verification
## Next Steps
### To Start Building:
1. **Read all documentation**
- Understand architecture
- Review UI specifications
- Study technical approach
2. **Start with Task 001**
- Set up project structure
- Install dependencies
- Verify build works
3. **Follow sequential order**
- Each task builds on previous
- Don't skip ahead
- Dependencies matter
4. **Track progress**
- Update task checkboxes
- Move completed tasks to done/
- Update roadmap as you go
### When You Hit Issues:
1. Review task prerequisites
2. Check documentation for clarification
3. Look at related specs
4. Ask questions on unclear requirements
5. Document blockers and solutions
## Success Metrics
### MVP (After Task 015)
- Can select folder → spawn server → chat
- Messages stream in real-time
- Can switch agents and models
- Tool executions visible
- Basic error handling works
- **Performance is NOT a concern** - focus on functionality
### Beta (After Task 030)
- Multi-instance support
- Advanced input (files, commands)
- Polished UX
- Settings and preferences
- Native menus
### v1.0 (After Task 035)
- System tray integration
- Auto-updates
- Crash reporting
- Production-ready stability
## Useful References
### Within This Project:
- `README.md` - Project overview and getting started
- `docs/architecture.md` - System design
- `docs/user-interface.md` - UI specifications
- `docs/technical-implementation.md` - Implementation details
- `tasks/README.md` - Task workflow guide
### External:
- OpenCode server API: https://opencode.ai/docs/server/
- Electron docs: https://electronjs.org/docs
- SolidJS docs: https://solidjs.com
- Kobalte UI: https://kobalte.dev
## Questions to Resolve
Before starting implementation, clarify:
1. Exact OpenCode CLI syntax for spawning server
2. Expected stdout format for port extraction
3. SDK package location and version
4. Any platform-specific gotchas
5. Icon and branding assets location
## Estimated Timeline
**Conservative estimate (part-time, ~15 hours/week):**
- Phase 1 (MVP Foundation): 2-3 weeks
- Phase 2 (Core Chat): 2 weeks
- Phase 3 (Essential): 2 weeks
- **MVP Complete: 6-7 weeks**
**Aggressive estimate (full-time, ~40 hours/week):**
- Phase 1: 1 week
- Phase 2: 1 week
- Phase 3: 1 week
- **MVP Complete: 3 weeks**
Add 2-4 weeks for testing, bug fixes, and polish before alpha release.
## This is a Living Document
As you build:
- Update estimates based on actual time
- Add new tasks as needed
- Refine specifications
- Document learnings
- Track blockers and solutions
Good luck! 🚀

305
docs/architecture.md Normal file
View File

@@ -0,0 +1,305 @@
# OpenCode Client Architecture
## Overview
OpenCode Client is a cross-platform desktop application built with Electron that provides a multi-instance, multi-session interface for interacting with OpenCode servers. Each instance manages its own OpenCode server process and can handle multiple concurrent sessions.
## High-Level Architecture
```
┌─────────────────────────────────────────────────────────┐
│ Electron Main Process │
│ - Window management │
│ - Process spawning (opencode serve) │
│ - IPC bridge to renderer │
│ - File system operations │
└────────────────┬────────────────────────────────────────┘
│ IPC
┌────────────────┴────────────────────────────────────────┐
│ Electron Renderer Process │
│ ┌──────────────────────────────────────────────────┐ │
│ │ SolidJS Application │ │
│ │ ┌────────────────────────────────────────────┐ │ │
│ │ │ Instance Manager │ │ │
│ │ │ - Spawns/kills OpenCode servers │ │ │
│ │ │ - Manages SDK clients per instance │ │ │
│ │ │ - Handles port allocation │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ │ ┌────────────────────────────────────────────┐ │ │
│ │ │ State Management (SolidJS Stores) │ │ │
│ │ │ - instances[] │ │ │
│ │ │ - sessions[] per instance │ │ │
│ │ │ - messages[] per session │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ │ ┌────────────────────────────────────────────┐ │ │
│ │ │ UI Components │ │ │
│ │ │ - InstanceTabs │ │ │
│ │ │ - SessionTabs │ │ │
│ │ │ - MessageStream │ │ │
│ │ │ - PromptInput │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│ HTTP/SSE
┌────────────────┴────────────────────────────────────────┐
│ Multiple OpenCode Server Processes │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Instance 1 │ │ Instance 2 │ │ Instance 3 │ │
│ │ Port: 4096 │ │ Port: 4097 │ │ Port: 4098 │ │
│ │ ~/project-a │ │ ~/project-a │ │ ~/api │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────┘
```
## Component Layers
### 1. Main Process Layer (Electron)
**Responsibilities:**
- Create and manage application window
- Spawn OpenCode server processes as child processes
- Parse server stdout to extract port information
- Handle process lifecycle (start, stop, restart)
- Provide IPC handlers for renderer requests
- Manage native OS integrations (file dialogs, menus)
**Key Modules:**
- `main.ts` - Application entry point
- `process-manager.ts` - OpenCode server process spawning
- `ipc-handlers.ts` - IPC communication handlers
- `menu.ts` - Native application menu
### 2. Renderer Process Layer (SolidJS)
**Responsibilities:**
- Render UI components
- Manage application state
- Handle user interactions
- Communicate with OpenCode servers via HTTP/SSE
- Real-time message streaming
**Key Modules:**
- `App.tsx` - Root component
- `stores/` - State management
- `components/` - UI components
- `contexts/` - SolidJS context providers
- `lib/` - Utilities and helpers
### 3. Communication Layer
**HTTP API Communication:**
- SDK client per instance
- RESTful API calls for session/config/file operations
- Error handling and retries
**SSE (Server-Sent Events):**
- One EventSource per instance
- Real-time message updates
- Event type routing
- Reconnection logic
## Data Flow
### Instance Creation Flow
1. User selects folder via Electron file dialog
2. Main process receives folder path via IPC
3. Main process spawns `opencode serve --port 0`
4. Main process parses stdout for port number
5. Main process sends port + PID back to renderer
6. Renderer creates SDK client for that port
7. Renderer fetches initial session list
8. Renderer displays session picker
### Message Streaming Flow
1. User submits prompt in active session
2. Renderer POSTs to `/session/:id/message`
3. SSE connection receives `MessageUpdated` events
4. Events are routed to correct instance → session
5. Message state updates trigger UI re-render
6. Messages display with auto-scroll
### Child Session Creation Flow
1. OpenCode server creates child session
2. SSE emits `SessionUpdated` event with `parentId`
3. Renderer adds session to instance's session list
4. New session tab appears automatically
5. Optional: Auto-switch to new tab
## State Management
### Instance State
```
instances: Map<instanceId, {
id: string
folder: string
port: number
pid: number
status: 'starting' | 'ready' | 'error' | 'stopped'
client: OpenCodeClient
eventSource: EventSource
sessions: Map<sessionId, Session>
activeSessionId: string | null
logs: string[]
}>
```
### Session State
```
Session: {
id: string
title: string
parentId: string | null
messages: Message[]
agent: string
model: { providerId: string, modelId: string }
status: 'idle' | 'streaming' | 'error'
}
```
### Message State
```
Message: {
id: string
sessionId: string
type: 'user' | 'assistant'
parts: Part[]
timestamp: number
status: 'sending' | 'sent' | 'streaming' | 'complete' | 'error'
}
```
## Tab Hierarchy
### Level 1: Instance Tabs
Each tab represents one OpenCode server instance:
- Label: Folder name (with counter if duplicate)
- Icon: Folder icon
- Close button: Stops server and closes tab
- "+" button: Opens folder picker for new instance
### Level 2: Session Tabs
Each instance has multiple session tabs:
- Main session tab (always present)
- Child session tabs (auto-created)
- Logs tab (shows server output)
- "+" button: Creates new session
### Tab Behavior
**Instance Tab Switching:**
- Preserves session tabs
- Switches active SDK client
- Updates SSE event routing
**Session Tab Switching:**
- Loads messages for that session
- Updates agent/model controls
- Preserves scroll position
## Technology Stack
### Core
- **Electron** - Desktop wrapper
- **SolidJS** - Reactive UI framework
- **TypeScript** - Type safety
- **Vite** - Build tool
### UI
- **TailwindCSS** - Styling
- **Kobalte** - Accessible UI primitives
- **Shiki** - Code syntax highlighting
- **Marked** - Markdown parsing
### Communication
- **OpenCode SDK** - API client
- **EventSource** - SSE streaming
- **Node Child Process** - Process spawning
## Error Handling
### Process Errors
- Server fails to start → Show error in instance tab
- Server crashes → Attempt auto-restart once
- Port already in use → Find next available port
### Network Errors
- API call fails → Show inline error, allow retry
- SSE disconnects → Auto-reconnect with backoff
- Timeout → Show timeout error, allow manual retry
### User Errors
- Invalid folder selection → Show error dialog
- Permission denied → Show actionable error message
- Out of memory → Graceful degradation message
## Performance Considerations
**Note: Performance optimization is NOT a focus for MVP. These are future considerations.**
### Message Rendering (Post-MVP)
- Start with simple list rendering - no virtual scrolling
- No message limits initially
- Only optimize if users report issues
- Virtual scrolling can be added in Phase 8 if needed
### State Updates
- SolidJS fine-grained reactivity handles most cases
- No special optimizations needed for MVP
- Batching/debouncing can be added later if needed
### Memory Management (Post-MVP)
- No memory management in MVP
- Let browser/OS handle it
- Add limits only if problems arise in testing
## Security Considerations
- No remote code execution
- Server spawned with user permissions
- No eval() or dangerous innerHTML
- Sanitize markdown rendering
- Validate all IPC messages
- HTTPS only for external requests
## Extensibility Points
### Plugin System (Future)
- Custom slash commands
- Custom message renderers
- Theme extensions
- Keybinding customization
### Configuration (Future)
- Per-instance settings
- Global preferences
- Workspace-specific configs
- Import/export settings

389
docs/build-roadmap.md Normal file
View File

@@ -0,0 +1,389 @@
# OpenCode Client Build Roadmap
## Overview
This document outlines the phased approach to building the OpenCode Client desktop application. Each phase builds incrementally on the previous, with clear deliverables and milestones.
## MVP Scope (Phases 1-3)
The minimum viable product includes:
- Single instance management
- Session selection and creation
- Message display (streaming)
- Basic prompt input (text only)
- Agent/model selection
- Process lifecycle management
**Target: 3-4 weeks for MVP**
---
## Phase 1: Foundation (Week 1)
**Goal:** Running Electron app that can spawn OpenCode servers
### Tasks
1.**001-project-setup** - Electron + SolidJS + Vite boilerplate
2.**002-empty-state-ui** - Empty state UI with folder selection
3.**003-process-manager** - Spawn and manage OpenCode server processes
4.**004-sdk-integration** - Connect to server via SDK
5.**005-session-picker-modal** - Select/create session modal
### Deliverables
- App launches successfully
- Can select folder
- Server spawns automatically
- Session picker appears
- Can create/select session
### Success Criteria
- User can launch app → select folder → see session picker
- Server process runs in background
- Sessions fetch from API successfully
---
## Phase 2: Core Chat Interface (Week 2)
**Goal:** Display messages and send basic prompts
### Tasks
6. **006-instance-session-tabs** - Two-level tab navigation
7. **007-message-display** - Render user and assistant messages
8. **008-sse-integration** - Real-time message streaming
9. **009-prompt-input-basic** - Text input with send functionality
10. **010-tool-call-rendering** - Display tool executions inline
### Deliverables
- Tab navigation works
- Messages display correctly
- Real-time updates via SSE
- Can send text messages
- Tool calls show status
### Success Criteria
- User can type message → see response stream in real-time
- Tool executions visible and expandable
- Multiple sessions can be open simultaneously
---
## Phase 3: Essential Features (Week 3)
**Goal:** Feature parity with basic TUI functionality
### Tasks
11. **011-agent-model-selectors** - Dropdown for agent/model switching
12. **012-markdown-rendering** - Proper markdown with code highlighting
13. **013-logs-tab** - View server logs
14. **014-error-handling** - Comprehensive error states and recovery
15. **015-keyboard-shortcuts** - Essential keyboard navigation
### Deliverables
- Can switch agents and models
- Markdown renders beautifully
- Code blocks have syntax highlighting
- Server logs accessible
- Errors handled gracefully
- Cmd/Ctrl+N, K, L shortcuts work
### Success Criteria
- User experience matches TUI quality
- All error cases handled
- Keyboard-first navigation option available
---
## Phase 4: Multi-Instance Support (Week 4)
**Goal:** Work on multiple projects simultaneously
### Tasks
16. **016-instance-tabs** - Instance-level tab management
17. **017-instance-state-persistence** - Remember instances across restarts
18. **018-child-session-handling** - Auto-create tabs for child sessions
19. **019-instance-lifecycle** - Stop, restart, reconnect instances
20. **020-multiple-sdk-clients** - One SDK client per instance
### Deliverables
- Multiple instance tabs
- Persists across app restarts
- Child sessions appear as new tabs
- Can stop individual instances
- All instances work independently
### Success Criteria
- User can work on 3+ projects simultaneously
- App remembers state on restart
- No interference between instances
---
## Phase 5: Advanced Input (Week 5)
**Goal:** Full input capabilities matching TUI
### Tasks
21. **021-slash-commands** - Command palette with autocomplete
22. **022-file-attachments** - @ mention file picker
23. **023-drag-drop-files** - Drag files onto input
24. **024-attachment-chips** - Display and manage attachments
25. **025-input-history** - Up/down arrow message history
### Deliverables
- `/command` autocomplete works
- `@file` picker searches files
- Drag & drop attaches files
- Attachment chips removable
- Previous messages accessible
### Success Criteria
- Input feature parity with TUI
- File context easy to add
- Command discovery intuitive
---
## Phase 6: Polish & UX (Week 6)
**Goal:** Production-ready user experience
### Tasks
26. **026-message-actions** - Copy, edit, regenerate messages
27. **027-search-in-session** - Find text in conversation
28. **028-session-management** - Rename, share, export sessions
29. **029-settings-ui** - Preferences and configuration
30. **030-native-menus** - Platform-native menu bar
### Deliverables
- Message context menus
- Search within conversation
- Session CRUD operations
- Settings dialog
- Native File/Edit/View menus
### Success Criteria
- Feels polished and professional
- All common actions accessible
- Settings discoverable
---
## Phase 7: System Integration (Week 7)
**Goal:** Native desktop app features
### Tasks
31. **031-system-tray** - Background running with tray icon
32. **032-notifications** - Desktop notifications for events
33. **033-auto-updater** - In-app update mechanism
34. **034-crash-reporting** - Error reporting and recovery
35. **035-performance-profiling** - Optimize rendering and memory
### Deliverables
- Runs in background
- Notifications for session activity
- Auto-updates on launch
- Crash logs captured
- Smooth performance with large sessions
### Success Criteria
- App feels native to platform
- Updates seamlessly
- Crashes don't lose data
---
## Phase 8: Advanced Features (Week 8+)
**Goal:** Beyond MVP, power user features
### Tasks
36. **036-virtual-scrolling** - Handle 1000+ message sessions
37. **037-message-search-advanced** - Full-text search across sessions
38. **038-workspace-management** - Save/load workspace configurations
39. **039-theme-customization** - Custom themes and UI tweaks
40. **040-plugin-system** - Extension API for custom tools
### Deliverables
- Virtual scrolling for performance
- Cross-session search
- Workspace persistence
- Theme editor
- Plugin loader
### Success Criteria
- Handles massive sessions (5000+ messages)
- Can search entire project history
- Fully customizable
---
## Parallel Tracks
Some tasks can be worked on independently:
### Design Track
- Visual design refinements
- Icon creation
- Brand assets
- Marketing materials
### Documentation Track
- User guide
- Keyboard shortcuts reference
- Troubleshooting docs
- Video tutorials
### Infrastructure Track
- CI/CD pipeline
- Automated testing
- Release automation
- Analytics integration
---
## Release Strategy
### Alpha (After Phase 3)
- Internal testing only
- Frequent bugs expected
- Rapid iteration
### Beta (After Phase 6)
- Public beta program
- Feature complete
- Bug fixes and polish
### v1.0 (After Phase 7)
- Public release
- Stable and reliable
- Production-ready
### v1.x (Phase 8+)
- Regular feature updates
- Community-driven priorities
- Plugin ecosystem
---
## Success Metrics
### MVP Success
- 10 internal users daily
- Can complete full coding session
- <5 critical bugs
### Beta Success
- 100+ external users
- NPS >50
- <10 bugs per week
### v1.0 Success
- 1000+ users
- <1% crash rate
- Feature requests > bug reports
---
## Risk Mitigation
### Technical Risks
- **Process management complexity**
- Mitigation: Extensive testing, graceful degradation
- **SSE connection stability**
- Mitigation: Robust reconnection logic, offline mode
- **Performance with large sessions**
- Mitigation: NOT a concern for MVP - defer to Phase 8
- Accept slower performance initially, optimize later based on user feedback
### Product Risks
- **Feature creep**
- Mitigation: Strict MVP scope, user feedback prioritization
- **Over-optimization too early**
- Mitigation: Focus on functionality first, optimize in Phase 8
- Avoid premature performance optimization
- **Platform inconsistencies**
- Mitigation: Test on all platforms regularly
---
## Dependencies
### External
- OpenCode CLI availability
- OpenCode SDK stability
- Electron framework updates
### Internal
- Design assets
- Documentation
- Testing resources
---
## Milestone Checklist
### Pre-Alpha
- [ ] All Phase 1 tasks complete
- [ ] Can create instance and session
- [ ] Internal demo successful
### Alpha
- [ ] All Phase 2-3 tasks complete
- [ ] MVP feature complete
- [ ] 5+ internal users testing
### Beta
- [ ] All Phase 4-6 tasks complete
- [ ] Multi-instance stable
- [ ] 50+ external testers
### v1.0
- [ ] All Phase 7 tasks complete
- [ ] Documentation complete
- [ ] <5 known bugs
- [ ] Ready for public release

View File

@@ -0,0 +1,634 @@
# Technical Implementation Details
## Technology Stack
### Core Technologies
- **Electron** v28+ - Desktop application wrapper
- **SolidJS** v1.8+ - Reactive UI framework
- **TypeScript** v5.3+ - Type-safe development
- **Vite** v5+ - Fast build tool and dev server
### UI & Styling
- **TailwindCSS** v4+ - Utility-first styling
- **Kobalte** - Accessible UI primitives for SolidJS
- **Shiki** - Syntax highlighting for code blocks
- **Marked** - Markdown parsing
- **Lucide** - Icon library
### Communication
- **OpenCode SDK** (@opencode-ai/sdk) - API client
- **EventSource API** - Server-sent events
- **Node Child Process** - Process management
### Development Tools
- **electron-vite** - Electron + Vite integration
- **electron-builder** - Application packaging
- **ESLint** - Code linting
- **Prettier** - Code formatting
## Project Structure
```
packages/opencode-client/
├── electron/
│ ├── main/
│ │ ├── main.ts # Electron main entry
│ │ ├── window.ts # Window management
│ │ ├── process-manager.ts # OpenCode server spawning
│ │ ├── ipc.ts # IPC handlers
│ │ └── menu.ts # Application menu
│ ├── preload/
│ │ └── index.ts # Preload script (IPC bridge)
│ └── resources/
│ └── icon.png # Application icon
├── src/
│ ├── components/
│ │ ├── instance-tabs.tsx # Level 1 tabs
│ │ ├── session-tabs.tsx # Level 2 tabs
│ │ ├── message-stream.tsx # Messages display
│ │ ├── message-item.tsx # Single message
│ │ ├── tool-call.tsx # Tool execution display
│ │ ├── prompt-input.tsx # Input with attachments
│ │ ├── agent-selector.tsx # Agent dropdown
│ │ ├── model-selector.tsx # Model dropdown
│ │ ├── session-picker.tsx # Startup modal
│ │ ├── logs-view.tsx # Server logs
│ │ └── empty-state.tsx # No instances view
│ ├── stores/
│ │ ├── instances.ts # Instance state
│ │ ├── sessions.ts # Session state per instance
│ │ └── ui.ts # UI state (active tabs, etc)
│ ├── lib/
│ │ ├── sdk-manager.ts # SDK client management
│ │ ├── sse-manager.ts # SSE connection handling
│ │ ├── port-finder.ts # Find available ports
│ │ └── markdown.ts # Markdown rendering utils
│ ├── hooks/
│ │ ├── use-instance.ts # Instance operations
│ │ ├── use-session.ts # Session operations
│ │ └── use-messages.ts # Message operations
│ ├── types/
│ │ ├── instance.ts # Instance types
│ │ ├── session.ts # Session types
│ │ └── message.ts # Message types
│ ├── App.tsx # Root component
│ ├── main.tsx # Renderer entry
│ └── index.css # Global styles
├── docs/ # Documentation
├── tasks/ # Task tracking
├── package.json
├── tsconfig.json
├── electron.vite.config.ts
├── tailwind.config.js
└── README.md
```
## State Management
### Instance Store
```typescript
interface InstanceState {
instances: Map<string, Instance>
activeInstanceId: string | null
// Actions
createInstance(folder: string): Promise<void>
removeInstance(id: string): Promise<void>
setActiveInstance(id: string): void
}
interface Instance {
id: string // UUID
folder: string // Absolute path
port: number // Server port
pid: number // Process ID
status: InstanceStatus
client: OpenCodeClient // SDK client
eventSource: EventSource | null // SSE connection
sessions: Map<string, Session>
activeSessionId: string | null
logs: LogEntry[]
}
type InstanceStatus =
| "starting" // Server spawning
| "ready" // Server connected
| "error" // Failed to start
| "stopped" // Server killed
interface LogEntry {
timestamp: number
level: "info" | "error" | "warn"
message: string
}
```
### Session Store
```typescript
interface SessionState {
// Per instance
getSessions(instanceId: string): Session[]
getActiveSession(instanceId: string): Session | null
// Actions
createSession(instanceId: string, agent: string): Promise<Session>
deleteSession(instanceId: string, sessionId: string): Promise<void>
setActiveSession(instanceId: string, sessionId: string): void
updateSession(instanceId: string, sessionId: string, updates: Partial<Session>): void
}
interface Session {
id: string
instanceId: string
title: string
parentId: string | null
agent: string
model: {
providerId: string
modelId: string
}
messages: Message[]
status: SessionStatus
createdAt: number
updatedAt: number
}
type SessionStatus =
| "idle" // No activity
| "streaming" // Assistant responding
| "error" // Error occurred
```
### UI Store
```typescript
interface UIState {
// Tab state
instanceTabOrder: string[]
sessionTabOrder: Map<string, string[]> // instanceId -> sessionIds
// Modal state
showSessionPicker: string | null // instanceId or null
showSettings: boolean
// Actions
reorderInstanceTabs(newOrder: string[]): void
reorderSessionTabs(instanceId: string, newOrder: string[]): void
openSessionPicker(instanceId: string): void
closeSessionPicker(): void
}
```
## Process Management
### Server Spawning
**Strategy:** Spawn with port 0 (random), parse stdout for actual port
```typescript
interface ProcessManager {
spawn(folder: string): Promise<ProcessInfo>
kill(pid: number): Promise<void>
restart(pid: number, folder: string): Promise<ProcessInfo>
}
interface ProcessInfo {
pid: number
port: number
stdout: Readable
stderr: Readable
}
// Implementation approach:
// 1. Check if opencode binary exists
// 2. Spawn: spawn('opencode', ['serve', '--port', '0'], { cwd: folder })
// 3. Listen to stdout
// 4. Parse line matching: "Server listening on port 4096"
// 5. Resolve promise with port
// 6. Timeout after 10 seconds
```
### Port Parsing
```typescript
// Expected output from opencode serve:
// > Starting OpenCode server...
// > Server listening on port 4096
// > API available at http://localhost:4096
function parsePort(output: string): number | null {
const match = output.match(/port (\d+)/)
return match ? parseInt(match[1], 10) : null
}
```
### Error Handling
**Server fails to start:**
- Parse stderr for error message
- Display in instance tab with retry button
- Common errors: Port in use, permission denied, binary not found
**Server crashes after start:**
- Detect via process 'exit' event
- Attempt auto-restart once
- If restart fails, show error state
- Preserve session data for manual restart
## Communication Layer
### SDK Client Management
```typescript
interface SDKManager {
createClient(port: number): OpenCodeClient
destroyClient(port: number): void
getClient(port: number): OpenCodeClient | null
}
// One client per instance
// Client lifecycle tied to instance lifecycle
```
### SSE Event Handling
```typescript
interface SSEManager {
connect(instanceId: string, port: number): void
disconnect(instanceId: string): void
// Event routing
onMessageUpdate(handler: (instanceId: string, event: MessageUpdateEvent) => void): void
onSessionUpdate(handler: (instanceId: string, event: SessionUpdateEvent) => void): void
onError(handler: (instanceId: string, error: Error) => void): void
}
// Event flow:
// 1. EventSource connects to /event endpoint
// 2. Events arrive as JSON
// 3. Route to correct instance store
// 4. Update reactive state
// 5. UI auto-updates via signals
```
### Reconnection Logic
```typescript
// SSE disconnects:
// - Network issue
// - Server restart
// - Tab sleep (browser optimization)
class SSEConnection {
private reconnectAttempts = 0
private maxReconnectAttempts = 5
private reconnectDelay = 1000 // Start with 1s
reconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.emitError(new Error("Max reconnection attempts reached"))
return
}
setTimeout(() => {
this.connect()
this.reconnectAttempts++
this.reconnectDelay *= 2 // Exponential backoff
}, this.reconnectDelay)
}
}
```
## Message Rendering
### Markdown Processing
```typescript
// Use Marked + Shiki for syntax highlighting
import { marked } from "marked"
import { markedHighlight } from "marked-highlight"
import { getHighlighter } from "shiki"
const highlighter = await getHighlighter({
themes: ["github-dark", "github-light"],
langs: ["typescript", "javascript", "python", "bash", "json"],
})
marked.use(
markedHighlight({
highlight(code, lang) {
return highlighter.codeToHtml(code, {
lang,
theme: isDark ? "github-dark" : "github-light",
})
},
}),
)
```
### Tool Call Rendering
```typescript
interface ToolCallComponent {
tool: string // "bash", "edit", "read"
input: any // Tool-specific input
output?: any // Tool-specific output
status: "pending" | "running" | "success" | "error"
expanded: boolean // Collapse state
}
// Render logic:
// - Default: Collapsed, show summary
// - Click: Toggle expanded state
// - Running: Show spinner
// - Complete: Show checkmark
// - Error: Show error icon + message
```
### Streaming Updates
```typescript
// Messages stream in via SSE
// Update strategy: Replace existing message parts
function handleMessagePartUpdate(event: MessagePartEvent) {
const session = getSession(event.sessionId)
const message = session.messages.find((m) => m.id === event.messageId)
if (!message) {
// New message
session.messages.push(createMessage(event))
} else {
// Update existing
const partIndex = message.parts.findIndex((p) => p.id === event.partId)
if (partIndex === -1) {
message.parts.push(event.part)
} else {
message.parts[partIndex] = event.part
}
}
// SolidJS reactivity triggers re-render
}
```
## Performance Considerations
**MVP Approach: Don't optimize prematurely**
### Message Rendering (MVP)
**Simple approach - no optimization:**
```typescript
// Render all messages - no virtual scrolling, no limits
<For each={messages()}>
{(message) => <MessageItem message={message} />}
</For>
// SolidJS will handle reactivity efficiently
// Only optimize if users report issues
```
### State Update Batching
**Not needed for MVP:**
- SolidJS reactivity is efficient enough
- SSE updates will just trigger normal re-renders
- Add batching only if performance issues arise
### Memory Management
**Not needed for MVP:**
- No message limits
- No pruning
- No lazy loading
- Let users create as many messages as they want
- Optimize later if problems occur
**When to add optimizations (post-MVP):**
- Users report slowness with large sessions
- Measurable performance degradation
- Memory usage becomes problematic
- See Phase 8 tasks for virtual scrolling and optimization
## IPC Communication
### Main Process → Renderer
```typescript
// Events sent from main to renderer
type MainToRenderer = {
"instance:started": { id: string; port: number; pid: number }
"instance:error": { id: string; error: string }
"instance:stopped": { id: string }
"instance:log": { id: string; entry: LogEntry }
}
```
### Renderer → Main Process
```typescript
// Commands sent from renderer to main
type RendererToMain = {
"folder:select": () => Promise<string | null>
"instance:create": (folder: string) => Promise<{ port: number; pid: number }>
"instance:stop": (pid: number) => Promise<void>
"app:quit": () => void
}
```
### Preload Script (Bridge)
```typescript
// Expose safe IPC methods to renderer
contextBridge.exposeInMainWorld("electronAPI", {
selectFolder: () => ipcRenderer.invoke("folder:select"),
createInstance: (folder: string) => ipcRenderer.invoke("instance:create", folder),
stopInstance: (pid: number) => ipcRenderer.invoke("instance:stop", pid),
onInstanceStarted: (callback) => ipcRenderer.on("instance:started", callback),
onInstanceError: (callback) => ipcRenderer.on("instance:error", callback),
})
```
## Error Handling Strategy
### Network Errors
```typescript
// HTTP request fails
try {
const response = await client.session.list()
} catch (error) {
if (error.code === "ECONNREFUSED") {
// Server not responding
showError("Cannot connect to server. Is it running?")
} else if (error.code === "ETIMEDOUT") {
// Request timeout
showError("Request timed out. Retry?", { retry: true })
} else {
// Unknown error
showError(error.message)
}
}
```
### SSE Errors
```typescript
eventSource.onerror = (error) => {
// Connection lost
if (eventSource.readyState === EventSource.CLOSED) {
// Attempt reconnect
reconnectSSE()
}
}
```
### User Input Errors
```typescript
// Validate before sending
function validatePrompt(text: string): string | null {
if (!text.trim()) {
return "Message cannot be empty"
}
if (text.length > 10000) {
return "Message too long (max 10000 characters)"
}
return null
}
```
## Security Measures
### IPC Security
- Use `contextIsolation: true`
- Whitelist allowed IPC channels
- Validate all data from renderer
- No `nodeIntegration` in renderer
### Process Security
- Spawn OpenCode with user permissions only
- No shell execution of user input
- Sanitize file paths
### Content Security
- Sanitize markdown before rendering
- Use DOMPurify for HTML sanitization
- No `dangerouslySetInnerHTML` without sanitization
- CSP headers in renderer
## Testing Strategy (Future)
### Unit Tests
- State management logic
- Utility functions
- Message parsing
### Integration Tests
- Process spawning
- SDK client operations
- SSE event handling
### E2E Tests
- Complete user flows
- Multi-instance scenarios
- Error recovery
## Build & Packaging
### Development
```bash
npm run dev # Start Electron + Vite dev server
npm run dev:main # Main process only
npm run dev:renderer # Renderer only
```
### Production
```bash
npm run build # Build all
npm run build:main # Build main process
npm run build:renderer # Build renderer
npm run package # Create distributable
```
### Distribution
- macOS: DMG + auto-update
- Windows: NSIS installer + auto-update
- Linux: AppImage + deb/rpm
## Configuration Files
### electron.vite.config.ts
```typescript
import { defineConfig } from "electron-vite"
import solid from "vite-plugin-solid"
export default defineConfig({
main: {
build: {
rollupOptions: {
external: ["electron"],
},
},
},
preload: {
build: {
rollupOptions: {
external: ["electron"],
},
},
},
renderer: {
plugins: [solid()],
resolve: {
alias: {
"@": "/src",
},
},
},
})
```
### tsconfig.json
```json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM"],
"jsx": "preserve",
"jsxImportSource": "solid-js",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"paths": {
"@/*": ["./src/*"]
}
}
}
```

493
docs/user-interface.md Normal file
View File

@@ -0,0 +1,493 @@
# User Interface Specification
## Overview
The OpenCode Client interface consists of a two-level tabbed layout with instance tabs at the top and session tabs below. Each session displays a message stream and prompt input.
## Layout Structure
```
┌──────────────────────────────────────────────────────────────┐
│ File Edit View Window Help ● ○ ◐ │ ← Native menu bar
├──────────────────────────────────────────────────────────────┤
│ [~/project-a] [~/project-a (2)] [~/api-service] [+] │ ← Instance tabs (Level 1)
├──────────────────────────────────────────────────────────────┤
│ [Main] [Fix login] [Write tests] [Logs] [+] │ ← Session tabs (Level 2)
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Messages Area │ │
│ │ │ │
│ │ User: How do I set up testing? │ │
│ │ │ │
│ │ Assistant: To set up testing, you'll need to... │ │
│ │ → bash: npm install vitest ✓ │ │
│ │ Output: added 50 packages │ │
│ │ │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
├──────────────────────────────────────────────────────────────┤
│ Agent: Build ▼ Model: Claude 3.5 Sonnet ▼ │ ← Controls
├──────────────────────────────────────────────────────────────┤
│ [@file.ts] [@api.ts] [×] │ ← Attachments
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Type your message or /command... │ │ ← Prompt input
│ │ │ │
│ └────────────────────────────────────────────────────────┘ │
│ [▶] │ ← Send button
└──────────────────────────────────────────────────────────────┘
```
## Components Specification
### 1. Instance Tabs (Level 1)
**Visual Design:**
- Horizontal tabs at top of window
- Each tab shows folder name
- Icon: Folder icon (🗂️)
- Close button (×) on hover
- Active tab: Highlighted with accent color
- Inactive tabs: Muted background
**Tab Label Format:**
- Single instance: `~/project-name`
- Multiple instances of same folder: `~/project-name (2)`, `~/project-name (3)`
- Max width: 200px with ellipsis for long paths
- Tooltip shows full path on hover
**Actions:**
- Click: Switch to that instance
- Close (×): Stop server and close instance (with confirmation)
- Drag: Reorder tabs (future)
**New Instance Button (+):**
- Always visible at right end
- Click: Opens folder picker dialog
- Keyboard: Cmd/Ctrl+N
**States:**
- Starting: Loading spinner + "Starting..."
- Ready: Normal appearance
- Error: Red indicator + error icon
- Stopped: Grayed out (should not be visible, tab closes)
### 2. Session Tabs (Level 2)
**Visual Design:**
- Horizontal tabs below instance tabs
- Smaller than instance tabs
- Each tab shows session title or "Untitled"
- Active tab: Underline or bold
- Parent-child relationship: No visual distinction (all siblings)
**Tab Types:**
**Session Tab:**
- Label: Session title (editable on double-click)
- Icon: Chat bubble (💬) or none
- Close button (×) on hover
- Max width: 150px with ellipsis
**Logs Tab:**
- Label: "Logs"
- Icon: Terminal (⚡)
- Always present per instance
- Non-closable
- Shows server stdout/stderr
**Actions:**
- Click: Switch to that session
- Double-click label: Rename session
- Close (×): Delete session (with confirmation if has messages)
- Right-click: Context menu (Share, Export, Delete)
**New Session Button (+):**
- Click: Creates new session with default agent
- Keyboard: Cmd/Ctrl+T
### 3. Messages Area
**Container:**
- Scrollable viewport
- Auto-scroll to bottom when new messages arrive
- Manual scroll up: Disable auto-scroll
- "Scroll to bottom" button appears when scrolled up
**Message Layout:**
**User Message:**
```
┌──────────────────────────────────────────┐
│ You 10:32 AM │
│ How do I set up testing? │
│ │
│ [@src/app.ts] [@package.json] │ ← Attachments if any
└──────────────────────────────────────────┘
```
**Assistant Message:**
````
┌──────────────────────────────────────────┐
│ Assistant • Build 10:32 AM │
│ To set up testing, you'll need to │
│ install Vitest and configure it. │
│ │
│ ▶ bash: npm install vitest ✓ │ ← Tool call (collapsed)
│ │
│ ▶ edit src/vitest.config.ts ✓ │
│ │
│ Here's the configuration I added: │
│ ```typescript │
│ export default { │
│ test: { globals: true } │
│ } │
│ ``` │
└──────────────────────────────────────────┘
````
**Tool Call (Collapsed):**
```
▶ bash: npm install vitest ✓
^ ^ ^
| | |
Icon Tool name + summary Status
```
**Tool Call (Expanded):**
```
▼ bash: npm install vitest ✓
Input:
{
"command": "npm install vitest"
}
Output:
added 50 packages, and audited 51 packages in 2s
found 0 vulnerabilities
```
**Status Icons:**
- ⏳ Pending (spinner)
- ✓ Success (green checkmark)
- ✗ Error (red X)
- ⚠ Warning (yellow triangle)
**File Change Display:**
```
▶ edit src/vitest.config.ts ✓
Modified: src/vitest.config.ts
+12 lines, -3 lines
```
Click to expand: Show diff inline
### 4. Controls Bar
**Agent Selector:**
- Dropdown button showing current agent
- Click: Opens dropdown with agent list
- Shows: Agent name + description
- Grouped by category (if applicable)
**Model Selector:**
- Dropdown button showing current model
- Click: Opens dropdown with model list
- Shows: Provider icon + Model name
- Grouped by provider
- Displays: Context window, capabilities icons
**Layout:**
```
┌────────────────────────────────────────────┐
│ Agent: Build ▼ Model: Claude 3.5 ▼ │
└────────────────────────────────────────────┘
```
### 5. Prompt Input
**Input Field:**
- Multi-line textarea
- Auto-expanding (max 10 lines)
- Placeholder: "Type your message or /command..."
- Supports keyboard shortcuts
**Features:**
**Slash Commands:**
- Type `/` → Autocomplete dropdown appears
- Shows: Command name + description
- Filter as you type
- Enter to execute
**File Mentions:**
- Type `@` → File picker appears
- Search files by name
- Shows: File icon + path
- Enter to attach
**Attachments:**
- Display as chips above input
- Format: [@filename] [×]
- Click × to remove
- Drag & drop files onto input area
**Send Button:**
- Icon: Arrow (▶) or paper plane
- Click: Submit message
- Keyboard: Enter (without Shift)
- Disabled when: Empty input or server busy
**Keyboard Shortcuts:**
- Enter: Send message
- Shift+Enter: New line
- Cmd/Ctrl+K: Clear input
- Cmd/Ctrl+V: Paste (handles files)
- Cmd/Ctrl+L: Focus input
- Up/Down: Navigate message history (when input empty)
## Overlays & Modals
### Session Picker (Startup)
Appears when instance starts:
```
┌────────────────────────────────────────┐
│ OpenCode • ~/project-a │
├────────────────────────────────────────┤
│ Resume a session: │
│ │
│ > Fix login bug 2h ago │
│ Add dark mode 5h ago │
│ Refactor API Yesterday │
│ │
│ ────────────── or ────────────── │
│ │
│ Start new session: │
│ Agent: [Build ▼] [Start] │
│ │
│ [Cancel] │
└────────────────────────────────────────┘
```
**Actions:**
- Click session: Resume that session
- Click "Start": Create new session with selected agent
- Click "Cancel": Close instance
- Keyboard: Arrow keys to navigate, Enter to select
### Confirmation Dialogs
**Close Instance:**
```
┌────────────────────────────────────────┐
│ Stop OpenCode instance? │
├────────────────────────────────────────┤
│ This will stop the server for: │
│ ~/project-a │
│ │
│ Active sessions will be lost. │
│ │
│ [Cancel] [Stop Instance] │
└────────────────────────────────────────┘
```
**Delete Session:**
```
┌────────────────────────────────────────┐
│ Delete session? │
├────────────────────────────────────────┤
│ This will permanently delete: │
│ "Fix login bug" │
│ │
│ This cannot be undone. │
│ │
│ [Cancel] [Delete] │
└────────────────────────────────────────┘
```
## Empty States
### No Instances
```
┌──────────────────────────────────────────┐
│ │
│ [Folder Icon] │
│ │
│ Welcome to OpenCode Client │
│ │
│ Select a folder to start coding with AI │
│ │
│ [Select Folder] │
│ │
│ Keyboard shortcut: Cmd/Ctrl+N │
│ │
└──────────────────────────────────────────┘
```
### No Messages (New Session)
```
┌──────────────────────────────────────────┐
│ │
│ Start a conversation │
│ │
│ Type a message below or try: │
│ • /init-project │
│ • Ask about your codebase │
│ • Attach files with @ │
│ │
└──────────────────────────────────────────┘
```
### Logs Tab (No Logs Yet)
```
┌──────────────────────────────────────────┐
│ Waiting for server output... │
└──────────────────────────────────────────┘
```
## Visual Styling
### Color Scheme
**Light Mode:**
- Background: #FFFFFF
- Secondary background: #F5F5F5
- Border: #E0E0E0
- Text: #1A1A1A
- Muted text: #666666
- Accent: #0066FF
**Dark Mode:**
- Background: #1A1A1A
- Secondary background: #2A2A2A
- Border: #3A3A3A
- Text: #E0E0E0
- Muted text: #999999
- Accent: #0080FF
### Typography
- **Main text**: 14px, system font
- **Headers**: 16px, medium weight
- **Labels**: 12px, regular weight
- **Code**: Monospace font (Consolas, Monaco, Courier)
- **Line height**: 1.5
### Spacing
- **Padding**: 8px, 12px, 16px, 24px (consistent scale)
- **Margins**: Same as padding
- **Tab height**: 40px
- **Input height**: 80px (auto-expanding)
- **Message spacing**: 16px between messages
### Icons
- Use consistent icon set (Lucide, Heroicons, or similar)
- Size: 16px for inline, 20px for buttons
- Stroke width: 2px
## Responsive Behavior
### Minimum Window Size
- Width: 800px
- Height: 600px
### Behavior When Small
- Instance tabs: Scroll horizontally
- Session tabs: Scroll horizontally
- Messages: Always visible, scroll vertically
- Input: Fixed at bottom
## Accessibility
- All interactive elements keyboard-navigable
- ARIA labels for screen readers
- Focus indicators visible
- Color contrast WCAG AA compliant
- Tab trap in modals
- Escape key closes overlays
## Animation & Transitions
- Tab switching: Instant (no animation)
- Message appearance: Fade in (100ms)
- Tool expand/collapse: Slide (200ms)
- Dropdown menus: Fade + slide (150ms)
- Loading states: Spinner or skeleton
## Context Menus
### Session Tab Right-Click
- Rename
- Duplicate
- Share
- Export
- Delete
- Close Other Tabs
### Message Right-Click
- Copy message
- Copy code block
- Edit & regenerate
- Delete message
- Quote in reply
## Status Indicators
### Instance Tab
- Green dot: Server running
- Yellow dot: Server starting
- Red dot: Server error
- No dot: Server stopped
### Session Tab
- Blue pulse: Assistant responding
- No indicator: Idle
### Connection Status
- Bottom right corner: "Connected" or "Reconnecting..."

53
electron.vite.config.ts Normal file
View File

@@ -0,0 +1,53 @@
import { defineConfig, externalizeDepsPlugin } from "electron-vite"
import solid from "vite-plugin-solid"
import { resolve } from "path"
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
build: {
outDir: "dist/main",
lib: {
entry: resolve(__dirname, "electron/main/main.ts"),
},
rollupOptions: {
external: ["electron"],
},
},
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
outDir: "dist/preload",
lib: {
entry: resolve(__dirname, "electron/preload/index.ts"),
formats: ["cjs"],
fileName: () => "index.js",
},
rollupOptions: {
external: ["electron"],
output: {
entryFileNames: "index.js",
},
},
},
},
renderer: {
root: "./src/renderer",
plugins: [solid()],
css: {
postcss: "./postcss.config.js",
},
resolve: {
alias: {
"@": resolve(__dirname, "./src"),
},
},
server: {
port: 3000,
},
build: {
outDir: "dist/renderer",
},
},
})

83
electron/main/ipc.ts Normal file
View File

@@ -0,0 +1,83 @@
import { ipcMain, BrowserWindow } from "electron"
import { processManager } from "./process-manager"
import { randomBytes } from "crypto"
interface Instance {
id: string
folder: string
port: number
pid: number
status: "starting" | "ready" | "error" | "stopped"
error?: string
}
const instances = new Map<string, Instance>()
function generateId(): string {
return randomBytes(16).toString("hex")
}
export function setupInstanceIPC(mainWindow: BrowserWindow) {
ipcMain.handle("instance:create", async (event, folder: string) => {
const id = generateId()
const instance: Instance = {
id,
folder,
port: 0,
pid: 0,
status: "starting",
}
instances.set(id, instance)
try {
const { pid, port } = await processManager.spawn(folder)
instance.port = port
instance.pid = pid
instance.status = "ready"
mainWindow.webContents.send("instance:started", { id, port, pid })
const meta = processManager.getAllProcesses().get(pid)
if (meta) {
meta.childProcess.on("exit", (code, signal) => {
instance.status = "stopped"
mainWindow.webContents.send("instance:stopped", { id })
})
}
return { port, pid }
} catch (error) {
instance.status = "error"
instance.error = error instanceof Error ? error.message : String(error)
mainWindow.webContents.send("instance:error", {
id,
error: instance.error,
})
throw error
}
})
ipcMain.handle("instance:stop", async (event, pid: number) => {
await processManager.kill(pid)
for (const [id, instance] of instances.entries()) {
if (instance.pid === pid) {
instance.status = "stopped"
break
}
}
})
ipcMain.handle("instance:status", async (event, pid: number) => {
return processManager.getStatus(pid)
})
ipcMain.handle("instance:list", async () => {
return Array.from(instances.values())
})
}

69
electron/main/main.ts Normal file
View File

@@ -0,0 +1,69 @@
import { app, BrowserWindow, dialog, ipcMain } from "electron"
import { join } from "path"
import { createApplicationMenu } from "./menu"
import { setupInstanceIPC } from "./ipc"
let mainWindow: BrowserWindow | null = null
function createWindow() {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 800,
minHeight: 600,
webPreferences: {
preload: join(__dirname, "../preload/index.js"),
contextIsolation: true,
nodeIntegration: false,
},
})
if (process.env.NODE_ENV === "development") {
mainWindow.loadURL("http://localhost:3000")
mainWindow.webContents.openDevTools()
} else {
mainWindow.loadFile(join(__dirname, "../renderer/index.html"))
}
createApplicationMenu(mainWindow)
setupInstanceIPC(mainWindow)
mainWindow.on("closed", () => {
mainWindow = null
})
}
function setupIPC() {
ipcMain.handle("dialog:selectFolder", async () => {
if (!mainWindow) return null
const result = await dialog.showOpenDialog(mainWindow, {
title: "Select Project Folder",
buttonLabel: "Select",
properties: ["openDirectory"],
})
if (result.canceled) {
return null
}
return result.filePaths[0] || null
})
}
app.whenReady().then(() => {
setupIPC()
createWindow()
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit()
}
})

84
electron/main/menu.ts Normal file
View File

@@ -0,0 +1,84 @@
import { Menu, BrowserWindow, MenuItemConstructorOptions } from "electron"
export function createApplicationMenu(mainWindow: BrowserWindow) {
const isMac = process.platform === "darwin"
const template: MenuItemConstructorOptions[] = [
...(isMac
? [
{
label: "OpenCode Client",
submenu: [
{ role: "about" as const },
{ type: "separator" as const },
{ role: "hide" as const },
{ role: "hideOthers" as const },
{ role: "unhide" as const },
{ type: "separator" as const },
{ role: "quit" as const },
],
},
]
: []),
{
label: "File",
submenu: [
{
label: "New Instance",
accelerator: "CmdOrCtrl+N",
click: () => {
mainWindow.webContents.send("menu:newInstance")
},
},
{ type: "separator" as const },
isMac ? { role: "close" as const } : { role: "quit" as const },
],
},
{
label: "Edit",
submenu: [
{ role: "undo" as const },
{ role: "redo" as const },
{ type: "separator" as const },
{ role: "cut" as const },
{ role: "copy" as const },
{ role: "paste" as const },
...(isMac
? [{ role: "pasteAndMatchStyle" as const }, { role: "delete" as const }, { role: "selectAll" as const }]
: [{ role: "delete" as const }, { type: "separator" as const }, { role: "selectAll" as const }]),
],
},
{
label: "View",
submenu: [
{ role: "reload" as const },
{ role: "forceReload" as const },
{ role: "toggleDevTools" as const },
{ type: "separator" as const },
{ role: "resetZoom" as const },
{ role: "zoomIn" as const },
{ role: "zoomOut" as const },
{ type: "separator" as const },
{ role: "togglefullscreen" as const },
],
},
{
label: "Window",
submenu: [
{ role: "minimize" as const },
{ role: "zoom" as const },
...(isMac
? [
{ type: "separator" as const },
{ role: "front" as const },
{ type: "separator" as const },
{ role: "window" as const },
]
: [{ role: "close" as const }]),
],
},
]
const menu = Menu.buildFromTemplate(template)
Menu.setApplicationMenu(menu)
}

View File

@@ -0,0 +1,190 @@
import { spawn, ChildProcess } from "child_process"
import { app } from "electron"
import { existsSync, statSync } from "fs"
import { execSync } from "child_process"
export interface ProcessInfo {
pid: number
port: number
}
interface ProcessMeta {
pid: number
port: number
folder: string
startTime: number
childProcess: ChildProcess
logs: string[]
}
class ProcessManager {
private processes = new Map<number, ProcessMeta>()
async spawn(folder: string): Promise<ProcessInfo> {
this.validateFolder(folder)
this.validateOpenCodeBinary()
return new Promise((resolve, reject) => {
const child = spawn("opencode", ["serve", "--port", "0"], {
cwd: folder,
stdio: ["ignore", "pipe", "pipe"],
env: process.env,
shell: false,
})
const timeout = setTimeout(() => {
child.kill("SIGKILL")
reject(new Error("Server startup timeout (10s exceeded)"))
}, 10000)
let stdoutBuffer = ""
let stderrBuffer = ""
let portFound = false
child.stdout?.on("data", (data: Buffer) => {
const text = data.toString()
stdoutBuffer += text
const lines = stdoutBuffer.split("\n")
stdoutBuffer = lines.pop() || ""
for (const line of lines) {
const portMatch = line.match(/opencode server listening on http:\/\/[^:]+:(\d+)/)
if (portMatch && !portFound) {
portFound = true
const port = parseInt(portMatch[1], 10)
clearTimeout(timeout)
const meta: ProcessMeta = {
pid: child.pid!,
port,
folder,
startTime: Date.now(),
childProcess: child,
logs: [line],
}
this.processes.set(child.pid!, meta)
resolve({ pid: child.pid!, port })
}
const logEntry = { timestamp: Date.now(), level: "info", message: line }
const meta = this.processes.get(child.pid!)
if (meta) {
meta.logs.push(line)
}
}
})
child.stderr?.on("data", (data: Buffer) => {
const text = data.toString()
stderrBuffer += text
const lines = stderrBuffer.split("\n")
stderrBuffer = lines.pop() || ""
for (const line of lines) {
const logEntry = { timestamp: Date.now(), level: "error", message: line }
const meta = this.processes.get(child.pid!)
if (meta) {
meta.logs.push(line)
}
}
})
child.on("error", (error) => {
clearTimeout(timeout)
if (error.message.includes("ENOENT")) {
reject(new Error("opencode binary not found in PATH"))
} else {
reject(error)
}
})
child.on("exit", (code, signal) => {
clearTimeout(timeout)
this.processes.delete(child.pid!)
if (!portFound) {
const errorMsg = stderrBuffer || `Process exited with code ${code}`
reject(new Error(errorMsg))
}
})
})
}
async kill(pid: number): Promise<void> {
const meta = this.processes.get(pid)
if (!meta) {
throw new Error(`Process ${pid} not found`)
}
return new Promise((resolve, reject) => {
const child = meta.childProcess
const killTimeout = setTimeout(() => {
child.kill("SIGKILL")
}, 2000)
child.on("exit", () => {
clearTimeout(killTimeout)
this.processes.delete(pid)
resolve()
})
child.kill("SIGTERM")
})
}
getStatus(pid: number): "running" | "stopped" | "unknown" {
if (!this.processes.has(pid)) {
return "unknown"
}
try {
process.kill(pid, 0)
return "running"
} catch {
return "stopped"
}
}
getAllProcesses(): Map<number, ProcessMeta> {
return new Map(this.processes)
}
async cleanup(): Promise<void> {
const killPromises = Array.from(this.processes.keys()).map((pid) => this.kill(pid).catch(() => {}))
await Promise.all(killPromises)
}
private validateFolder(folder: string): void {
if (!existsSync(folder)) {
throw new Error(`Folder does not exist: ${folder}`)
}
const stats = statSync(folder)
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${folder}`)
}
}
private validateOpenCodeBinary(): void {
const command = process.platform === "win32" ? "where opencode" : "which opencode"
try {
execSync(command, { stdio: "pipe" })
} catch {
throw new Error(
"opencode binary not found in PATH. Please install OpenCode CLI first: npm install -g @opencode/cli",
)
}
}
}
export const processManager = new ProcessManager()
app.on("before-quit", async (event) => {
event.preventDefault()
await processManager.cleanup()
app.exit(0)
})

43
electron/preload/index.ts Normal file
View File

@@ -0,0 +1,43 @@
import { contextBridge, ipcRenderer } from "electron"
export interface ElectronAPI {
selectFolder: () => Promise<string | null>
createInstance: (folder: string) => Promise<{ port: number; pid: number }>
stopInstance: (pid: number) => Promise<void>
onInstanceStarted: (callback: (data: { id: string; port: number; pid: number }) => void) => void
onInstanceError: (callback: (data: { id: string; error: string }) => void) => void
onInstanceStopped: (callback: (data: { id: string }) => void) => void
onInstanceLog: (
callback: (data: { id: string; entry: { timestamp: number; level: string; message: string } }) => void,
) => void
onNewInstance: (callback: () => void) => void
}
const electronAPI: ElectronAPI = {
selectFolder: () => ipcRenderer.invoke("dialog:selectFolder"),
createInstance: (folder: string) => ipcRenderer.invoke("instance:create", folder),
stopInstance: (pid: number) => ipcRenderer.invoke("instance:stop", pid),
onInstanceStarted: (callback) => {
ipcRenderer.on("instance:started", (_, data) => callback(data))
},
onInstanceError: (callback) => {
ipcRenderer.on("instance:error", (_, data) => callback(data))
},
onInstanceStopped: (callback) => {
ipcRenderer.on("instance:stopped", (_, data) => callback(data))
},
onInstanceLog: (callback) => {
ipcRenderer.on("instance:log", (_, data) => callback(data))
},
onNewInstance: (callback) => {
ipcRenderer.on("menu:newInstance", () => callback())
},
}
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
declare global {
interface Window {
electronAPI: ElectronAPI
}
}

49
package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "@opencode-ai/client",
"version": "0.1.0",
"description": "OpenCode desktop client - multi-instance, multi-session AI coding interface",
"type": "module",
"main": "dist/main/main.js",
"scripts": {
"dev": "electron-vite dev",
"dev:electron": "NODE_ENV=development electron .",
"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"
},
"dependencies": {
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "0.15.13",
"@solidjs/router": "^0.13.0",
"electron": "38.4.0",
"lucide-solid": "^0.300.0",
"marked": "^12.0.0",
"shiki": "^1.0.0",
"solid-js": "^1.8.0"
},
"devDependencies": {
"autoprefixer": "10.4.21",
"electron-builder": "^24.0.0",
"electron-vite": "^2.0.0",
"postcss": "8.5.6",
"tailwindcss": "3",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vite-plugin-solid": "^2.10.0"
},
"build": {
"appId": "ai.opencode.client",
"productName": "OpenCode Client",
"directories": {
"output": "release",
"buildResources": "electron/resources"
},
"files": [
"dist/**/*",
"package.json"
]
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

24
scripts/dev.sh Normal file
View File

@@ -0,0 +1,24 @@
#!/bin/bash
# Kill background processes on exit
trap 'kill $(jobs -p) 2>/dev/null' EXIT
# Build main and preload in watch mode
NODE_ENV=development vite build --watch --mode development --config electron.vite.config.ts --outDir dist/main &
MAIN_PID=$!
NODE_ENV=development vite build --watch --mode development --ssr electron/preload/index.ts --outDir dist/preload &
PRELOAD_PID=$!
# Start vite dev server for renderer
NODE_ENV=development vite --config electron.vite.config.ts --mode development &
RENDERER_PID=$!
# Wait for builds to complete
sleep 2
# Launch Electron
NODE_ENV=development electron .
# This will run when electron closes
wait

247
src/App.tsx Normal file
View File

@@ -0,0 +1,247 @@
import { Component, onMount, Show, createMemo, createEffect } from "solid-js"
import type { Session } from "./types/session"
import EmptyState from "./components/empty-state"
import SessionPicker from "./components/session-picker"
import InstanceTabs from "./components/instance-tabs"
import SessionTabs from "./components/session-tabs"
import MessageStream from "./components/message-stream"
import {
hasInstances,
isSelectingFolder,
setIsSelectingFolder,
setHasInstances,
sessionPickerInstance,
hideSessionPicker,
showSessionPicker,
} from "./stores/ui"
import {
createInstance,
instances,
updateInstance,
activeInstanceId,
setActiveInstanceId,
stopInstance,
getActiveInstance,
} from "./stores/instances"
import {
getSessions,
activeSessionId,
setActiveSession,
setActiveParentSession,
clearActiveParentSession,
createSession,
deleteSession,
getSessionFamily,
activeParentSessionId,
getParentSessions,
loadMessages,
} from "./stores/sessions"
import { setupTabKeyboardShortcuts } from "./lib/keyboard"
const SessionMessages: Component<{
sessionId: string
activeSessions: Map<string, Session>
instanceId: string
}> = (props) => {
const session = () => props.activeSessions.get(props.sessionId)
createEffect(() => {
const currentSession = session()
if (currentSession) {
loadMessages(props.instanceId, currentSession.id).catch(console.error)
}
})
return (
<Show
when={session()}
fallback={
<div class="flex items-center justify-center h-full">
<div class="text-center text-gray-500">Session not found</div>
</div>
}
>
{(s) => <MessageStream sessionId={s().id} messages={s().messages || []} messagesInfo={s().messagesInfo} />}
</Show>
)
}
const App: Component = () => {
const activeInstance = createMemo(() => getActiveInstance())
const activeSessions = createMemo(() => {
const instance = activeInstance()
if (!instance) return new Map()
const instanceId = instance.id
const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return new Map()
const sessionFamily = getSessionFamily(instanceId, parentId)
return new Map(sessionFamily.map((s) => [s.id, s]))
})
const activeSessionIdForInstance = createMemo(() => {
const instance = activeInstance()
if (!instance) return null
return activeSessionId().get(instance.id) || null
})
async function handleSelectFolder() {
setIsSelectingFolder(true)
try {
const folder = await window.electronAPI.selectFolder()
if (!folder) {
return
}
const instanceId = await createInstance(folder)
setHasInstances(true)
console.log("Created instance:", instanceId, "Port:", instances().get(instanceId)?.port)
} catch (error) {
console.error("Failed to create instance:", error)
} finally {
setIsSelectingFolder(false)
}
}
async function handleCloseInstance(instanceId: string) {
if (confirm("Stop OpenCode instance? This will stop the server.")) {
await stopInstance(instanceId)
if (instances().size === 0) {
setHasInstances(false)
}
}
}
async function handleNewSession(instanceId: string) {
try {
const session = await createSession(instanceId)
setActiveParentSession(instanceId, session.id)
} catch (error) {
console.error("Failed to create session:", error)
}
}
async function handleCloseSession(instanceId: string, sessionId: string) {
const sessions = getSessions(instanceId)
const session = sessions.find((s) => s.id === sessionId)
const isParent = session?.parentId === null
if (!isParent) {
return
}
clearActiveParentSession(instanceId)
showSessionPicker(instanceId)
}
onMount(() => {
setupTabKeyboardShortcuts(handleSelectFolder, handleNewSession, handleCloseSession)
window.electronAPI.onNewInstance(() => {
handleSelectFolder()
})
window.electronAPI.onInstanceStarted(({ id, port, pid }) => {
console.log("Instance started:", { id, port, pid })
updateInstance(id, { port, pid, status: "ready" })
})
window.electronAPI.onInstanceError(({ id, error }) => {
console.error("Instance error:", { id, error })
updateInstance(id, { status: "error", error })
})
window.electronAPI.onInstanceStopped(({ id }) => {
console.log("Instance stopped:", id)
updateInstance(id, { status: "stopped" })
})
})
return (
<div class="h-screen w-screen flex flex-col">
<Show
when={!hasInstances()}
fallback={
<>
<InstanceTabs
instances={instances()}
activeInstanceId={activeInstanceId()}
onSelect={setActiveInstanceId}
onClose={handleCloseInstance}
onNew={handleSelectFolder}
/>
<Show when={activeInstance()}>
{(instance) => (
<>
<Show
when={activeSessions().size > 0}
fallback={
<div class="flex-1 flex items-center justify-center">
<div class="text-center text-gray-500">
<p class="mb-2">No parent session selected</p>
<p class="text-sm">Select or create a parent session to begin</p>
</div>
</div>
}
>
<SessionTabs
instanceId={instance().id}
sessions={activeSessions()}
activeSessionId={activeSessionIdForInstance()}
onSelect={(id) => setActiveSession(instance().id, id)}
onClose={(id) => handleCloseSession(instance().id, id)}
onNew={() => handleNewSession(instance().id)}
/>
<div class="content-area flex-1 overflow-hidden flex flex-col">
<Show
when={activeSessionIdForInstance() === "logs"}
fallback={
<Show
when={activeSessionIdForInstance()}
fallback={
<div class="flex items-center justify-center h-full">
<div class="text-center text-gray-500">
<p class="mb-2">No session selected</p>
<p class="text-sm">Select a session to view messages</p>
</div>
</div>
}
>
<SessionMessages
sessionId={activeSessionIdForInstance()!}
activeSessions={activeSessions()}
instanceId={activeInstance()!.id}
/>
</Show>
}
>
<div class="p-4 text-gray-600">
<p class="font-semibold mb-2">Server Logs</p>
<p class="text-sm">Log viewer will be implemented in Task 013</p>
</div>
</Show>
</div>
</Show>
</>
)}
</Show>
</>
}
>
<EmptyState onSelectFolder={handleSelectFolder} isLoading={isSelectingFolder()} />
</Show>
<Show when={sessionPickerInstance()}>
{(instanceId) => <SessionPicker instanceId={instanceId()} open={true} onClose={hideSessionPicker} />}
</Show>
</div>
)
}
export default App

View File

@@ -0,0 +1,49 @@
import { Component } from "solid-js"
import { Folder, Loader2 } from "lucide-solid"
interface EmptyStateProps {
onSelectFolder: () => void
isLoading?: boolean
}
const EmptyState: Component<EmptyStateProps> = (props) => {
return (
<div class="flex h-full w-full items-center justify-center bg-gray-50 dark:bg-gray-900">
<div class="max-w-[500px] px-8 py-12 text-center">
<div class="mb-8 flex justify-center">
<Folder class="h-16 w-16 text-gray-400 dark:text-gray-600" />
</div>
<h1 class="mb-4 text-2xl font-semibold text-gray-900 dark:text-gray-100">Welcome to OpenCode Client</h1>
<p class="mb-8 text-base text-gray-600 dark:text-gray-400">Select a folder to start coding with AI</p>
<button
onClick={props.onSelectFolder}
disabled={props.isLoading}
class="mb-4 inline-flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 text-base font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
{props.isLoading ? (
<>
<Loader2 class="h-4 w-4 animate-spin" />
Selecting...
</>
) : (
"Select Folder"
)}
</button>
<p class="text-sm text-gray-500 dark:text-gray-500">
Keyboard shortcut: {navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"}+N
</p>
<div class="mt-6 space-y-1 text-sm text-gray-400 dark:text-gray-600">
<p>Examples: ~/projects/my-app</p>
<p>You can have multiple instances of the same folder</p>
</div>
</div>
</div>
)
}
export default EmptyState

View File

@@ -0,0 +1,61 @@
import { Component } from "solid-js"
import type { Instance } from "../types/instance"
import { FolderOpen, X } from "lucide-solid"
interface InstanceTabProps {
instance: Instance
active: boolean
onSelect: () => void
onClose: () => void
}
function formatFolderName(path: string, instances: Instance[], currentInstance: Instance): string {
const name = path.split("/").pop() || path
const duplicates = instances.filter((i) => {
const iName = i.folder.split("/").pop() || i.folder
return iName === name
})
if (duplicates.length > 1) {
const index = duplicates.findIndex((i) => i.id === currentInstance.id)
return `~/${name} (${index + 1})`
}
return `~/${name}`
}
const InstanceTab: Component<InstanceTabProps> = (props) => {
return (
<div class="instance-tab-container group">
<button
class={`instance-tab inline-flex items-center gap-2 px-3 py-2 rounded-t-md max-w-[200px] transition-colors ${
props.active ? "bg-blue-500 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
onClick={props.onSelect}
title={props.instance.folder}
role="tab"
aria-selected={props.active}
>
<FolderOpen class="w-4 h-4 flex-shrink-0" />
<span class="tab-label truncate text-sm">
{props.instance.folder.split("/").pop() || props.instance.folder}
</span>
<span
class="tab-close opacity-0 group-hover:opacity-100 hover:bg-red-500 hover:text-white rounded p-0.5 transition-opacity ml-auto cursor-pointer"
onClick={(e) => {
e.stopPropagation()
props.onClose()
}}
role="button"
tabIndex={0}
aria-label="Close instance"
>
<X class="w-3 h-3" />
</span>
</button>
</div>
)
}
export default InstanceTab

View File

@@ -0,0 +1,41 @@
import { Component, For } from "solid-js"
import type { Instance } from "../types/instance"
import InstanceTab from "./instance-tab"
import { Plus } from "lucide-solid"
interface InstanceTabsProps {
instances: Map<string, Instance>
activeInstanceId: string | null
onSelect: (instanceId: string) => void
onClose: (instanceId: string) => void
onNew: () => void
}
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
return (
<div class="instance-tabs bg-gray-50 border-b border-gray-200">
<div class="tabs-container flex items-center gap-1 px-2 py-1 overflow-x-auto" role="tablist">
<For each={Array.from(props.instances.entries())}>
{([id, instance]) => (
<InstanceTab
instance={instance}
active={id === props.activeInstanceId}
onSelect={() => props.onSelect(id)}
onClose={() => props.onClose(id)}
/>
)}
</For>
<button
class="new-tab-button inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-600 hover:bg-gray-200 transition-colors"
onClick={props.onNew}
title="New instance (Cmd/Ctrl+N)"
aria-label="New instance"
>
<Plus class="w-4 h-4" />
</button>
</div>
</div>
)
}
export default InstanceTabs

View File

@@ -0,0 +1,70 @@
import { For, Show } from "solid-js"
import type { Message } from "../types/message"
import MessagePart from "./message-part"
interface MessageItemProps {
message: Message
messageInfo?: any
}
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" })
}
const errorMessage = () => {
if (!props.messageInfo?.error) return null
const error = props.messageInfo.error
if (error.name === "ProviderAuthError") {
return error.data?.message || "Authentication error"
}
if (error.name === "MessageOutputLengthError") {
return "Message output length exceeded"
}
if (error.name === "MessageAbortedError") {
return "Request was aborted"
}
if (error.name === "UnknownError") {
return error.data?.message || "Unknown error occurred"
}
return null
}
const hasContent = () => {
return props.message.parts.length > 0 || errorMessage() !== null
}
const isGenerating = () => {
return !hasContent() && props.messageInfo?.time?.completed === 0
}
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">
<Show when={errorMessage()}>
<div class="message-error-block"> {errorMessage()}</div>
</Show>
<Show when={isGenerating()}>
<div class="message-generating">
<span class="generating-spinner"></span> Generating...
</div>
</Show>
<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>
)
}

View File

@@ -0,0 +1,37 @@
import { Show, Match, Switch } from "solid-js"
import ToolCall from "./tool-call"
interface MessagePartProps {
part: any
}
export default function MessagePart(props: MessagePartProps) {
const partType = () => props.part?.type || ""
return (
<Switch>
<Match when={partType() === "text"}>
<Show when={!props.part.synthetic && props.part.text}>
<div class="message-text">{props.part.text}</div>
</Show>
</Match>
<Match when={partType() === "tool"}>
<ToolCall toolCall={props.part} />
</Match>
<Match when={partType() === "error"}>
<div class="message-error-part"> {props.part.message}</div>
</Match>
<Match when={partType() === "reasoning"}>
<div class="message-reasoning">
<details>
<summary class="text-sm text-gray-500 cursor-pointer">Reasoning</summary>
<div class="message-text mt-2">{props.part.text || ""}</div>
</details>
</div>
</Match>
</Switch>
)
}

View File

@@ -0,0 +1,138 @@
import { For, Show, createSignal, createEffect, createMemo } from "solid-js"
import type { Message } from "../types/message"
import MessageItem from "./message-item"
import ToolCall from "./tool-call"
interface MessageStreamProps {
sessionId: string
messages: Message[]
messagesInfo?: Map<string, any>
loading?: boolean
}
interface DisplayItem {
type: "message" | "tool"
data: any
messageInfo?: any
}
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)
}
const displayItems = createMemo(() => {
const items: DisplayItem[] = []
for (const message of props.messages) {
const messageInfo = props.messagesInfo?.get(message.id)
const textParts = message.parts.filter((p) => p.type === "text" && !p.synthetic)
const toolParts = message.parts.filter((p) => p.type === "tool")
const reasoningParts = message.parts.filter((p) => p.type === "reasoning")
if (textParts.length > 0 || reasoningParts.length > 0 || messageInfo?.error) {
items.push({
type: "message",
data: {
...message,
parts: [...textParts, ...reasoningParts],
},
messageInfo,
})
}
for (const toolPart of toolParts) {
items.push({
type: "tool",
data: toolPart,
messageInfo,
})
}
}
return items
})
const itemsLength = () => displayItems().length
createEffect(() => {
itemsLength()
if (autoScroll()) {
setTimeout(scrollToBottom, 0)
}
})
return (
<div class="message-stream-container">
<div ref={containerRef} class="message-stream" onScroll={handleScroll}>
<Show when={!props.loading && displayItems().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={displayItems()}>
{(item) => (
<Show
when={item.type === "message"}
fallback={
<div class="tool-call-message">
<div class="tool-call-header-label">
<span class="tool-call-icon">🔧</span>
<span>Tool Call</span>
<span class="tool-name">{item.data?.tool || "unknown"}</span>
</div>
<ToolCall toolCall={item.data} />
</div>
}
>
<MessageItem message={item.data} messageInfo={item.messageInfo} />
</Show>
)}
</For>
</div>
<Show when={showScrollButton()}>
<button class="scroll-to-bottom" onClick={scrollToBottom} aria-label="Scroll to bottom">
</button>
</Show>
</div>
)
}

View File

@@ -0,0 +1,149 @@
import { Component, createSignal, Show, For, createEffect } from "solid-js"
import { Dialog } from "@kobalte/core/dialog"
import type { Session, Agent } from "../types/session"
import { getParentSessions, createSession, setActiveParentSession } from "../stores/sessions"
import { instances, stopInstance } from "../stores/instances"
import { agents } from "../stores/sessions"
interface SessionPickerProps {
instanceId: string
open: boolean
onClose: () => void
}
const SessionPicker: Component<SessionPickerProps> = (props) => {
const [selectedAgent, setSelectedAgent] = createSignal<string>("")
const [isCreating, setIsCreating] = createSignal(false)
const instance = () => instances().get(props.instanceId)
const parentSessions = () => getParentSessions(props.instanceId)
const agentList = () => agents().get(props.instanceId) || []
createEffect(() => {
const list = agentList()
if (list.length > 0 && !selectedAgent()) {
setSelectedAgent(list[0].name)
}
})
function formatRelativeTime(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) return `${days}d ago`
if (hours > 0) return `${hours}h ago`
if (minutes > 0) return `${minutes}m ago`
return "just now"
}
async function handleSessionSelect(sessionId: string) {
setActiveParentSession(props.instanceId, sessionId)
props.onClose()
}
async function handleNewSession() {
setIsCreating(true)
try {
const session = await createSession(props.instanceId, selectedAgent())
setActiveParentSession(props.instanceId, session.id)
props.onClose()
} catch (error) {
console.error("Failed to create session:", error)
} finally {
setIsCreating(false)
}
}
async function handleCancel() {
await stopInstance(props.instanceId)
props.onClose()
}
return (
<Dialog open={props.open} onOpenChange={(open) => !open && handleCancel()}>
<Dialog.Portal>
<Dialog.Overlay class="fixed inset-0 bg-black/50 z-50" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="bg-white rounded-lg shadow-2xl w-full max-w-lg p-6">
<Dialog.Title class="text-xl font-semibold text-gray-900 mb-4">
OpenCode {instance()?.folder.split("/").pop()}
</Dialog.Title>
<div class="space-y-6">
<Show
when={parentSessions().length > 0}
fallback={<div class="text-center py-4 text-gray-500 text-sm">No previous sessions</div>}
>
<div>
<h3 class="text-sm font-medium text-gray-700 mb-2">Resume a session ({parentSessions().length}):</h3>
<div class="space-y-1 max-h-[400px] overflow-y-auto">
<For each={parentSessions()}>
{(session) => (
<button
class="w-full text-left px-3 py-2 rounded hover:bg-gray-100 transition-colors group"
onClick={() => handleSessionSelect(session.id)}
>
<div class="flex justify-between items-start">
<span class="text-sm text-gray-900 truncate flex-1">{session.title || "Untitled"}</span>
<span class="text-xs text-gray-500 ml-2 flex-shrink-0">
{formatRelativeTime(session.time.updated)}
</span>
</div>
</button>
)}
</For>
</div>
</div>
</Show>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300" />
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500">or</span>
</div>
</div>
<div>
<h3 class="text-sm font-medium text-gray-700 mb-2">Start new session:</h3>
<div class="space-y-3">
<Show
when={agentList().length > 0}
fallback={<div class="text-sm text-gray-500">Loading agents...</div>}
>
<select
class="w-full px-3 py-2 border border-gray-300 rounded text-sm bg-white hover:border-gray-400 transition-colors"
value={selectedAgent()}
onChange={(e) => setSelectedAgent(e.currentTarget.value)}
>
<For each={agentList()}>{(agent) => <option value={agent.name}>{agent.name}</option>}</For>
</select>
</Show>
<button
class="w-full px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
onClick={handleNewSession}
disabled={isCreating() || agentList().length === 0}
>
{isCreating() ? "Creating..." : "Start"}
</button>
</div>
</div>
</div>
<div class="mt-6 flex justify-end">
<button class="px-4 py-2 text-sm text-gray-700 hover:text-gray-900" onClick={handleCancel}>
Cancel
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}
export default SessionPicker

View File

@@ -0,0 +1,56 @@
import { Component, Show } from "solid-js"
import type { Session } from "../types/session"
import { MessageSquare, Terminal, X } from "lucide-solid"
interface SessionTabProps {
session?: Session
special?: "logs"
active: boolean
isParent?: boolean
onSelect: () => void
onClose?: () => void
}
const SessionTab: Component<SessionTabProps> = (props) => {
const label = () => {
if (props.special === "logs") return "Logs"
return props.session?.title || "Untitled"
}
return (
<div class="session-tab-container group">
<button
class={`session-tab inline-flex items-center gap-2 px-3 py-1.5 rounded-t-md max-w-[150px] transition-colors text-sm ${
props.active
? "bg-white border-b-2 border-blue-500 font-medium text-gray-900"
: "text-gray-600 hover:bg-gray-100"
} ${props.special === "logs" ? "text-gray-500" : ""} ${props.isParent && !props.active ? "font-semibold" : ""}`}
onClick={props.onSelect}
title={label()}
role="tab"
aria-selected={props.active}
>
<Show when={props.special === "logs"} fallback={<MessageSquare class="w-3.5 h-3.5 flex-shrink-0" />}>
<Terminal class="w-3.5 h-3.5 flex-shrink-0" />
</Show>
<span class="tab-label truncate">{label()}</span>
<Show when={!props.special && props.onClose}>
<span
class="tab-close opacity-0 group-hover:opacity-100 hover:bg-red-500 hover:text-white rounded p-0.5 transition-opacity cursor-pointer"
onClick={(e) => {
e.stopPropagation()
props.onClose?.()
}}
role="button"
tabIndex={0}
aria-label="Close session"
>
<X class="w-3 h-3" />
</span>
</Show>
</button>
</div>
)
}
export default SessionTab

View File

@@ -0,0 +1,46 @@
import { Component, For } from "solid-js"
import type { Session } from "../types/session"
import SessionTab from "./session-tab"
import { Plus } from "lucide-solid"
interface SessionTabsProps {
instanceId: string
sessions: Map<string, Session>
activeSessionId: string | null
onSelect: (sessionId: string) => void
onClose: (sessionId: string) => void
onNew: () => void
}
const SessionTabs: Component<SessionTabsProps> = (props) => {
const sessionsList = () => Array.from(props.sessions.entries())
return (
<div class="session-tabs bg-white border-b border-gray-200">
<div class="tabs-container flex items-center gap-1 px-2 py-1 overflow-x-auto" role="tablist">
<For each={sessionsList()}>
{([id, session]) => (
<SessionTab
session={session}
active={id === props.activeSessionId}
isParent={session.parentId === null}
onSelect={() => props.onSelect(id)}
onClose={session.parentId === null ? () => props.onClose(id) : undefined}
/>
)}
</For>
<SessionTab special="logs" active={props.activeSessionId === "logs"} onSelect={() => props.onSelect("logs")} />
<button
class="new-tab-button inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-600 hover:bg-gray-100 transition-colors"
onClick={props.onNew}
title="New parent session (Cmd/Ctrl+T)"
aria-label="New parent session"
>
<Plus class="w-4 h-4" />
</button>
</div>
</div>
)
}
export default SessionTabs

View File

@@ -0,0 +1,139 @@
import { createSignal, Show } from "solid-js"
interface ToolCallProps {
toolCall: any
}
export default function ToolCall(props: ToolCallProps) {
const [expanded, setExpanded] = createSignal(false)
const statusIcon = () => {
const status = props.toolCall?.state?.status || ""
switch (status) {
case "pending":
return "⏳"
case "running":
return "⏳"
case "completed":
return "✓"
case "error":
return "✗"
default:
return ""
}
}
const statusClass = () => {
const status = props.toolCall?.state?.status || "pending"
return `tool-call-status-${status}`
}
function toggleExpanded() {
setExpanded(!expanded())
}
function formatToolSummary() {
const toolName = props.toolCall?.tool || ""
const state = props.toolCall?.state || {}
const input = state.input || {}
if (state.title) {
return state.title
}
switch (toolName) {
case "bash":
return `bash: ${input.command || ""}`
case "edit":
return `edit ${input.filePath || ""}`
case "read":
return `read ${input.filePath || ""}`
case "write":
return `write ${input.filePath || ""}`
case "glob":
return `glob ${input.pattern || ""}`
case "grep":
return `grep ${input.pattern || ""}`
default:
return toolName || "Unknown tool"
}
}
function formatToolOutput() {
const state = props.toolCall?.state || {}
if (state.error) {
return `Error: ${state.error}`
}
if (state.output) {
return state.output
}
return "No output"
}
function formatOutputPreview() {
const state = props.toolCall?.state || {}
if (state.error) {
return state.error
}
if (state.output) {
const output = state.output
const lines = output.split("\n")
if (lines.length <= 10) {
return output
}
const firstTenLines = lines.slice(0, 10).join("\n")
return firstTenLines + "\n..."
}
return "No output"
}
const hasResult = () => {
const status = props.toolCall?.state?.status || ""
return status === "completed" || status === "error"
}
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() && hasResult()}>
<div class="tool-call-preview">
<span class="tool-call-preview-label">Output:</span>
<span class="tool-call-preview-text">{formatOutputPreview()}</span>
</div>
</Show>
<Show when={expanded()}>
<div class="tool-call-details">
<div class="tool-call-section">
<h4>Input:</h4>
<pre>
<code>{JSON.stringify(props.toolCall?.state?.input || {}, null, 2)}</code>
</pre>
</div>
<Show when={hasResult()}>
<div class="tool-call-section">
<h4>Output:</h4>
<pre>
<code>{formatToolOutput()}</code>
</pre>
</div>
</Show>
</div>
</Show>
</div>
)
}

470
src/index.css Normal file
View File

@@ -0,0 +1,470 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--user-message-bg: #f0f7ff;
--assistant-message-bg: #faf5ff;
--success-color: #4caf50;
--error-color: #f44336;
--warning-color: #ff9800;
--code-bg: #f8f8f8;
--hover-bg: #e0e0e0;
--border-color: #e0e0e0;
--text-muted: #666666;
--accent-color: #0066ff;
--secondary-bg: #f5f5f5;
--background: #ffffff;
--user-border: #2196f3;
--assistant-border: #9c27b0;
}
[data-theme="dark"] {
--user-message-bg: #1a2332;
--assistant-message-bg: #251a2e;
--code-bg: #1a1a1a;
--hover-bg: #3a3a3a;
--border-color: #3a3a3a;
--text-muted: #999999;
--accent-color: #0080ff;
--secondary-bg: #2a2a2a;
--background: #1a1a1a;
--user-border: #42a5f5;
--assistant-border: #ba68c8;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
width: 100vw;
height: 100vh;
}
.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;
width: 100%;
}
.message-item.user {
background-color: var(--user-message-bg);
border-left: 4px solid var(--user-border);
}
.message-item.assistant {
background-color: var(--assistant-message-bg);
border-left: 4px solid var(--assistant-border);
}
.tool-call-message {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
border-radius: 8px;
width: 100%;
background-color: #f8f9fa;
border-left: 4px solid #6c757d;
}
[data-theme="dark"] .tool-call-message {
background-color: #212529;
border-left-color: #adb5bd;
}
.tool-call-header-label {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 14px;
color: #495057;
margin-bottom: 4px;
}
[data-theme="dark"] .tool-call-header-label {
color: #adb5bd;
}
.tool-call-header-label .tool-call-icon {
font-size: 16px;
}
.tool-call-header-label .tool-name {
font-family: monospace;
color: #212529;
background-color: #e9ecef;
padding: 2px 6px;
border-radius: 3px;
font-size: 13px;
}
[data-theme="dark"] .tool-call-header-label .tool-name {
color: #f8f9fa;
background-color: #343a40;
}
.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;
overflow-wrap: break-word;
hyphens: auto;
}
.message-text pre {
overflow-x: auto;
padding: 8px;
background-color: var(--code-bg);
border-radius: 4px;
}
.message-error {
color: var(--error-color);
font-size: 13px;
margin-top: 4px;
}
.message-error-part {
color: var(--error-color);
font-size: 14px;
padding: 8px;
background-color: rgba(244, 67, 54, 0.1);
border-radius: 4px;
}
.message-error-block {
color: var(--error-color);
font-size: 14px;
padding: 12px;
background-color: rgba(244, 67, 54, 0.1);
border-radius: 4px;
border-left: 3px solid var(--error-color);
margin: 8px 0;
}
.message-generating {
color: var(--text-muted);
font-size: 14px;
font-style: italic;
padding: 8px 0;
}
.generating-spinner {
display: inline-block;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.message-reasoning {
margin: 8px 0;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--secondary-bg);
}
.message-reasoning details {
padding: 8px 12px;
}
.message-reasoning summary {
font-size: 13px;
color: var(--text-muted);
cursor: pointer;
user-select: none;
}
.message-reasoning summary:hover {
color: var(--accent-color);
}
.tool-call {
border: 1px solid var(--border-color);
border-radius: 6px;
overflow: hidden;
}
.tool-call-message .tool-call {
border: none;
border-radius: 0;
margin: 0;
}
.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;
text-align: left;
}
.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,
.tool-call-status-pending {
border-left: 3px solid var(--warning-color);
}
.tool-call-preview {
padding: 8px 12px;
background-color: var(--code-bg);
border-top: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 6px;
}
.tool-call-preview-label {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.tool-call-preview-text {
font-family: monospace;
font-size: 12px;
line-height: 1.4;
color: var(--text-muted);
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
max-height: calc(10 * 1.4em);
overflow-y: auto;
}
.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;
max-height: calc(25 * 1.4em);
overflow-y: auto;
}
.tool-call-section code {
font-family: monospace;
font-size: 12px;
line-height: 1.4;
}
.tool-call-section pre::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.tool-call-section pre::-webkit-scrollbar-track {
background: var(--secondary-bg);
border-radius: 4px;
}
.tool-call-section pre::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
.tool-call-section pre::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.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);
}
}

43
src/lib/keyboard.ts Normal file
View File

@@ -0,0 +1,43 @@
import { instances, activeInstanceId, setActiveInstanceId } from "../stores/instances"
import { activeSessionId, setActiveSession, getSessions } from "../stores/sessions"
export function setupTabKeyboardShortcuts(
handleNewInstance: () => void,
handleNewSession: (instanceId: string) => void,
handleCloseSession: (instanceId: string, sessionId: string) => void,
) {
window.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key >= "1" && e.key <= "9") {
e.preventDefault()
const index = parseInt(e.key) - 1
const instanceIds = Array.from(instances().keys())
if (instanceIds[index]) {
setActiveInstanceId(instanceIds[index])
}
}
if ((e.metaKey || e.ctrlKey) && e.key === "n") {
e.preventDefault()
handleNewInstance()
}
if ((e.metaKey || e.ctrlKey) && e.key === "t") {
e.preventDefault()
const instanceId = activeInstanceId()
if (instanceId) {
handleNewSession(instanceId)
}
}
if ((e.metaKey || e.ctrlKey) && e.key === "w") {
e.preventDefault()
const instanceId = activeInstanceId()
if (!instanceId) return
const sessionId = activeSessionId().get(instanceId)
if (sessionId && sessionId !== "logs") {
handleCloseSession(instanceId, sessionId)
}
}
})
}

32
src/lib/sdk-manager.ts Normal file
View File

@@ -0,0 +1,32 @@
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/client"
class SDKManager {
private clients = new Map<number, OpencodeClient>()
createClient(port: number): OpencodeClient {
if (this.clients.has(port)) {
return this.clients.get(port)!
}
const client = createOpencodeClient({
baseUrl: `http://localhost:${port}`,
})
this.clients.set(port, client)
return client
}
getClient(port: number): OpencodeClient | null {
return this.clients.get(port) || null
}
destroyClient(port: number): void {
this.clients.delete(port)
}
destroyAll(): void {
this.clients.clear()
}
}
export const sdkManager = new SDKManager()

11
src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { render } from "solid-js/web"
import App from "./App"
import "./index.css"
const root = document.getElementById("root")
if (!root) {
throw new Error("Root element not found")
}
render(() => <App />, root)

12
src/renderer/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenCode Client</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="../main.tsx"></script>
</body>
</html>

119
src/stores/instances.ts Normal file
View File

@@ -0,0 +1,119 @@
import { createSignal } from "solid-js"
import type { Instance } from "../types/instance"
import { sdkManager } from "../lib/sdk-manager"
import { fetchSessions, fetchAgents, fetchProviders } from "./sessions"
import { showSessionPicker } from "./ui"
const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null)
function addInstance(instance: Instance) {
setInstances((prev) => {
const next = new Map(prev)
next.set(instance.id, instance)
return next
})
}
function updateInstance(id: string, updates: Partial<Instance>) {
setInstances((prev) => {
const next = new Map(prev)
const instance = next.get(id)
if (instance) {
next.set(id, { ...instance, ...updates })
}
return next
})
}
function removeInstance(id: string) {
setInstances((prev) => {
const next = new Map(prev)
next.delete(id)
return next
})
if (activeInstanceId() === id) {
setActiveInstanceId(null)
}
}
async function createInstance(folder: string): Promise<string> {
const tempId = `temp-${Date.now()}`
const instance: Instance = {
id: tempId,
folder,
port: 0,
pid: 0,
status: "starting",
client: null,
}
addInstance(instance)
try {
const { port, pid } = await window.electronAPI.createInstance(folder)
const client = sdkManager.createClient(port)
updateInstance(tempId, {
port,
pid,
client,
status: "ready",
})
setActiveInstanceId(tempId)
try {
await fetchSessions(tempId)
await fetchAgents(tempId)
await fetchProviders(tempId)
} catch (error) {
console.error("Failed to fetch initial data:", error)
}
showSessionPicker(tempId)
return tempId
} catch (error) {
updateInstance(tempId, {
status: "error",
error: error instanceof Error ? error.message : String(error),
})
throw error
}
}
async function stopInstance(id: string) {
const instance = instances().get(id)
if (!instance) return
if (instance.port) {
sdkManager.destroyClient(instance.port)
}
if (instance.pid) {
await window.electronAPI.stopInstance(instance.pid)
}
removeInstance(id)
}
function getActiveInstance(): Instance | null {
const id = activeInstanceId()
return id ? instances().get(id) || null : null
}
export {
instances,
activeInstanceId,
setActiveInstanceId,
addInstance,
updateInstance,
removeInstance,
createInstance,
stopInstance,
getActiveInstance,
}

419
src/stores/sessions.ts Normal file
View File

@@ -0,0 +1,419 @@
import { createSignal } from "solid-js"
import type { Session, Agent, Provider } from "../types/session"
import type { Message } from "../types/message"
import { instances } from "./instances"
const [sessions, setSessions] = createSignal<Map<string, Map<string, Session>>>(new Map())
const [activeSessionId, setActiveSessionId] = createSignal<Map<string, string>>(new Map())
const [activeParentSessionId, setActiveParentSessionId] = createSignal<Map<string, string>>(new Map())
const [agents, setAgents] = createSignal<Map<string, Agent[]>>(new Map())
const [providers, setProviders] = createSignal<Map<string, Provider[]>>(new Map())
const [loading, setLoading] = createSignal({
fetchingSessions: new Map<string, boolean>(),
creatingSession: new Map<string, boolean>(),
deletingSession: new Map<string, Set<string>>(),
loadingMessages: new Map<string, Set<string>>(),
})
const [messagesLoaded, setMessagesLoaded] = createSignal<Map<string, Set<string>>>(new Map())
async function fetchSessions(instanceId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
setLoading((prev) => {
const next = { ...prev }
next.fetchingSessions.set(instanceId, true)
return next
})
try {
const response = await instance.client.session.list()
const sessionMap = new Map<string, Session>()
if (!response.data || !Array.isArray(response.data)) {
return
}
for (const apiSession of response.data) {
sessionMap.set(apiSession.id, {
id: apiSession.id,
instanceId,
title: apiSession.title || "Untitled",
parentId: apiSession.parentID || null,
agent: "",
model: { providerId: "", modelId: "" },
time: {
created: apiSession.time.created,
updated: apiSession.time.updated,
},
messages: [],
messagesInfo: new Map(),
})
}
setSessions((prev) => {
const next = new Map(prev)
next.set(instanceId, sessionMap)
return next
})
} catch (error) {
console.error("Failed to fetch sessions:", error)
throw error
} finally {
setLoading((prev) => {
const next = { ...prev }
next.fetchingSessions.set(instanceId, false)
return next
})
}
}
async function createSession(instanceId: string, agent?: string): Promise<Session> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
setLoading((prev) => {
const next = { ...prev }
next.creatingSession.set(instanceId, true)
return next
})
try {
const response = await instance.client.session.create()
if (!response.data) {
throw new Error("Failed to create session: No data returned")
}
const session: Session = {
id: response.data.id,
instanceId,
title: response.data.title || "New Session",
parentId: null,
agent: agent || "",
model: { providerId: "", modelId: "" },
time: {
created: response.data.time.created,
updated: response.data.time.updated,
},
messages: [],
messagesInfo: new Map(),
}
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = next.get(instanceId) || new Map()
instanceSessions.set(session.id, session)
next.set(instanceId, instanceSessions)
return next
})
return session
} catch (error) {
console.error("Failed to create session:", error)
throw error
} finally {
setLoading((prev) => {
const next = { ...prev }
next.creatingSession.set(instanceId, false)
return next
})
}
}
async function deleteSession(instanceId: string, sessionId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
setLoading((prev) => {
const next = { ...prev }
const deleting = next.deletingSession.get(instanceId) || new Set()
deleting.add(sessionId)
next.deletingSession.set(instanceId, deleting)
return next
})
try {
await instance.client.session.delete({ path: { id: sessionId } })
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = next.get(instanceId)
if (instanceSessions) {
instanceSessions.delete(sessionId)
}
return next
})
if (activeSessionId().get(instanceId) === sessionId) {
setActiveSessionId((prev) => {
const next = new Map(prev)
next.delete(instanceId)
return next
})
}
} catch (error) {
console.error("Failed to delete session:", error)
throw error
} finally {
setLoading((prev) => {
const next = { ...prev }
const deleting = next.deletingSession.get(instanceId)
if (deleting) {
deleting.delete(sessionId)
}
return next
})
}
}
async function fetchAgents(instanceId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
try {
const response = await instance.client.app.agents()
const agentList = (response.data ?? [])
.filter((agent) => agent.mode !== "subagent")
.map((agent) => ({
name: agent.name,
description: agent.description || "",
mode: agent.mode,
}))
setAgents((prev) => {
const next = new Map(prev)
next.set(instanceId, agentList)
return next
})
} catch (error) {
console.error("Failed to fetch agents:", error)
}
}
async function fetchProviders(instanceId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
try {
const response = await instance.client.config.providers()
if (!response.data) return
const providerList = 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,
})),
}))
setProviders((prev) => {
const next = new Map(prev)
next.set(instanceId, providerList)
return next
})
} catch (error) {
console.error("Failed to fetch providers:", error)
}
}
function setActiveSession(instanceId: string, sessionId: string): void {
setActiveSessionId((prev) => {
const next = new Map(prev)
next.set(instanceId, sessionId)
return next
})
}
function setActiveParentSession(instanceId: string, parentSessionId: string): void {
setActiveParentSessionId((prev) => {
const next = new Map(prev)
next.set(instanceId, parentSessionId)
return next
})
setActiveSession(instanceId, parentSessionId)
}
function clearActiveParentSession(instanceId: string): void {
setActiveParentSessionId((prev) => {
const next = new Map(prev)
next.delete(instanceId)
return next
})
setActiveSessionId((prev) => {
const next = new Map(prev)
next.delete(instanceId)
return next
})
}
function getActiveParentSession(instanceId: string): Session | null {
const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return null
const instanceSessions = sessions().get(instanceId)
return instanceSessions?.get(parentId) || null
}
function getActiveSession(instanceId: string): Session | null {
const sessionId = activeSessionId().get(instanceId)
if (!sessionId) return null
const instanceSessions = sessions().get(instanceId)
return instanceSessions?.get(sessionId) || null
}
function getSessions(instanceId: string): Session[] {
const instanceSessions = sessions().get(instanceId)
return instanceSessions ? Array.from(instanceSessions.values()) : []
}
function getParentSessions(instanceId: string): Session[] {
const allSessions = getSessions(instanceId)
return allSessions.filter((s) => s.parentId === null)
}
function getChildSessions(instanceId: string, parentId: string): Session[] {
const allSessions = getSessions(instanceId)
return allSessions.filter((s) => s.parentId === parentId)
}
function getSessionFamily(instanceId: string, parentId: string): Session[] {
const parent = sessions().get(instanceId)?.get(parentId)
if (!parent) return []
const children = getChildSessions(instanceId, parentId)
return [parent, ...children]
}
async function loadMessages(instanceId: string, sessionId: string): Promise<void> {
const alreadyLoaded = messagesLoaded().get(instanceId)?.has(sessionId)
if (alreadyLoaded) {
return
}
const isLoading = loading().loadingMessages.get(instanceId)?.has(sessionId)
if (isLoading) {
return
}
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId)
if (!session) {
throw new Error("Session not found")
}
setLoading((prev) => {
const next = { ...prev }
const loadingSet = next.loadingMessages.get(instanceId) || new Set()
loadingSet.add(sessionId)
next.loadingMessages.set(instanceId, loadingSet)
return next
})
try {
const response = await instance.client.session.messages({ path: { id: sessionId } })
if (!response.data || !Array.isArray(response.data)) {
return
}
const messagesInfo = new Map<string, any>()
const messages: Message[] = response.data.map((apiMessage: any) => {
const info = apiMessage.info || apiMessage
const role = info.role || "assistant"
const messageId = info.id || String(Date.now())
messagesInfo.set(messageId, info)
return {
id: messageId,
sessionId,
type: role === "user" ? "user" : "assistant",
parts: apiMessage.parts || [],
timestamp: info.time?.created || Date.now(),
status: "complete" as const,
}
})
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = next.get(instanceId)
if (instanceSessions) {
const session = instanceSessions.get(sessionId)
if (session) {
const updatedInstanceSessions = new Map(instanceSessions)
updatedInstanceSessions.set(sessionId, { ...session, messages, messagesInfo })
next.set(instanceId, updatedInstanceSessions)
}
}
return next
})
setMessagesLoaded((prev) => {
const next = new Map(prev)
const loadedSet = next.get(instanceId) || new Set()
loadedSet.add(sessionId)
next.set(instanceId, loadedSet)
return next
})
} catch (error) {
console.error("Failed to load messages:", error)
throw error
} finally {
setLoading((prev) => {
const next = { ...prev }
const loadingSet = next.loadingMessages.get(instanceId)
if (loadingSet) {
loadingSet.delete(sessionId)
}
return next
})
}
}
export {
sessions,
activeSessionId,
activeParentSessionId,
agents,
providers,
loading,
fetchSessions,
createSession,
deleteSession,
fetchAgents,
fetchProviders,
loadMessages,
setActiveSession,
setActiveParentSession,
clearActiveParentSession,
getActiveSession,
getActiveParentSession,
getSessions,
getParentSessions,
getChildSessions,
getSessionFamily,
}

47
src/stores/ui.ts Normal file
View File

@@ -0,0 +1,47 @@
import { createSignal } from "solid-js"
const [hasInstances, setHasInstances] = createSignal(false)
const [selectedFolder, setSelectedFolder] = createSignal<string | null>(null)
const [isSelectingFolder, setIsSelectingFolder] = createSignal(false)
const [sessionPickerInstance, setSessionPickerInstance] = createSignal<string | null>(null)
const [instanceTabOrder, setInstanceTabOrder] = createSignal<string[]>([])
const [sessionTabOrder, setSessionTabOrder] = createSignal<Map<string, string[]>>(new Map())
function showSessionPicker(instanceId: string) {
setSessionPickerInstance(instanceId)
}
function hideSessionPicker() {
setSessionPickerInstance(null)
}
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
})
}
export {
hasInstances,
setHasInstances,
selectedFolder,
setSelectedFolder,
isSelectingFolder,
setIsSelectingFolder,
sessionPickerInstance,
showSessionPicker,
hideSessionPicker,
instanceTabOrder,
setInstanceTabOrder,
sessionTabOrder,
setSessionTabOrder,
reorderInstanceTabs,
reorderSessionTabs,
}

9
src/types/electron.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import type { ElectronAPI } from "../../electron/preload/index"
declare global {
interface Window {
electronAPI: ElectronAPI
}
}
export {}

17
src/types/instance.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { OpencodeClient } from "@opencode-ai/sdk/client"
export interface Instance {
id: string
folder: string
port: number
pid: number
status: "starting" | "ready" | "error" | "stopped"
error?: string
client: OpencodeClient | null
}
export interface LogEntry {
timestamp: number
level: "info" | "error"
message: string
}

8
src/types/message.ts Normal file
View File

@@ -0,0 +1,8 @@
export interface Message {
id: string
sessionId: string
type: "user" | "assistant"
parts: any[]
timestamp: number
status: "sending" | "sent" | "streaming" | "complete" | "error"
}

37
src/types/session.ts Normal file
View File

@@ -0,0 +1,37 @@
import type { Message } from "./message"
export interface Session {
id: string
instanceId: string
title: string
parentId: string | null
agent: string
model: {
providerId: string
modelId: string
}
time: {
created: number
updated: number
}
messages: Message[]
messagesInfo: Map<string, any>
}
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
}

7
tailwind.config.js Normal file
View File

@@ -0,0 +1,7 @@
export default {
content: ["./src/**/*.{ts,tsx}", "./src/renderer/**/*.html"],
theme: {
extend: {},
},
plugins: [],
}

177
tasks/README.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

23
tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "preserve",
"jsxImportSource": "solid-js",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}

18
tsconfig.node.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020"],
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["electron/**/*.ts", "electron.vite.config.ts"],
"exclude": ["node_modules", "dist"]
}