Working messages display
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
.DS_Store
|
||||
*.log
|
||||
.vite/
|
||||
.electron-vite/
|
||||
out/
|
||||
149
PROGRESS.md
Normal file
149
PROGRESS.md
Normal 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
216
README.md
Normal 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
178
docs/INDEX.md
Normal 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
326
docs/MVP-PRINCIPLES.md
Normal 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
344
docs/SUMMARY.md
Normal 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
305
docs/architecture.md
Normal 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
389
docs/build-roadmap.md
Normal 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
|
||||
634
docs/technical-implementation.md
Normal file
634
docs/technical-implementation.md
Normal 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
493
docs/user-interface.md
Normal 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
53
electron.vite.config.ts
Normal 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
83
electron/main/ipc.ts
Normal 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
69
electron/main/main.ts
Normal 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
84
electron/main/menu.ts
Normal 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)
|
||||
}
|
||||
190
electron/main/process-manager.ts
Normal file
190
electron/main/process-manager.ts
Normal 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
43
electron/preload/index.ts
Normal 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
49
package.json
Normal 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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
24
scripts/dev.sh
Normal file
24
scripts/dev.sh
Normal 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
247
src/App.tsx
Normal 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
|
||||
49
src/components/empty-state.tsx
Normal file
49
src/components/empty-state.tsx
Normal 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
|
||||
61
src/components/instance-tab.tsx
Normal file
61
src/components/instance-tab.tsx
Normal 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
|
||||
41
src/components/instance-tabs.tsx
Normal file
41
src/components/instance-tabs.tsx
Normal 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
|
||||
70
src/components/message-item.tsx
Normal file
70
src/components/message-item.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
src/components/message-part.tsx
Normal file
37
src/components/message-part.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
138
src/components/message-stream.tsx
Normal file
138
src/components/message-stream.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
149
src/components/session-picker.tsx
Normal file
149
src/components/session-picker.tsx
Normal 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
|
||||
56
src/components/session-tab.tsx
Normal file
56
src/components/session-tab.tsx
Normal 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
|
||||
46
src/components/session-tabs.tsx
Normal file
46
src/components/session-tabs.tsx
Normal 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
|
||||
139
src/components/tool-call.tsx
Normal file
139
src/components/tool-call.tsx
Normal 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
470
src/index.css
Normal 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
43
src/lib/keyboard.ts
Normal 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
32
src/lib/sdk-manager.ts
Normal 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
11
src/main.tsx
Normal 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
12
src/renderer/index.html
Normal 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
119
src/stores/instances.ts
Normal 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
419
src/stores/sessions.ts
Normal 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
47
src/stores/ui.ts
Normal 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
9
src/types/electron.d.ts
vendored
Normal 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
17
src/types/instance.ts
Normal 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
8
src/types/message.ts
Normal 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
37
src/types/session.ts
Normal 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
7
tailwind.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
content: ["./src/**/*.{ts,tsx}", "./src/renderer/**/*.html"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
177
tasks/README.md
Normal file
177
tasks/README.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# Task Management
|
||||
|
||||
This directory contains the task breakdown for building the OpenCode Client.
|
||||
|
||||
## Structure
|
||||
|
||||
- `todo/` - Tasks waiting to be worked on
|
||||
- `done/` - Completed tasks (moved from todo/)
|
||||
|
||||
## Task Naming Convention
|
||||
|
||||
Tasks are numbered sequentially with a descriptive name:
|
||||
|
||||
```
|
||||
001-project-setup.md
|
||||
002-empty-state-ui.md
|
||||
003-process-manager.md
|
||||
...
|
||||
```
|
||||
|
||||
## Task Format
|
||||
|
||||
Each task file contains:
|
||||
|
||||
1. **Goal** - What this task achieves
|
||||
2. **Prerequisites** - What must be done first
|
||||
3. **Acceptance Criteria** - Checklist of requirements
|
||||
4. **Steps** - Detailed implementation guide
|
||||
5. **Testing Checklist** - How to verify completion
|
||||
6. **Dependencies** - What blocks/is blocked by this task
|
||||
7. **Estimated Time** - Rough time estimate
|
||||
8. **Notes** - Additional context
|
||||
|
||||
## Workflow
|
||||
|
||||
### Starting a Task
|
||||
|
||||
1. Read the task file thoroughly
|
||||
2. Ensure prerequisites are met
|
||||
3. Check dependencies are complete
|
||||
4. Create a feature branch: `feature/task-XXX-name`
|
||||
|
||||
### Working on a Task
|
||||
|
||||
1. Follow steps in order
|
||||
2. Check off acceptance criteria as you complete them
|
||||
3. Run tests frequently
|
||||
4. Commit regularly with descriptive messages
|
||||
|
||||
### Completing a Task
|
||||
|
||||
1. Verify all acceptance criteria met
|
||||
2. Run full testing checklist
|
||||
3. Update task file with any notes/changes
|
||||
4. Move task from `todo/` to `done/`
|
||||
5. Create PR for review
|
||||
|
||||
## Current Tasks
|
||||
|
||||
### Phase 1: Foundation (Tasks 001-005)
|
||||
|
||||
- [x] 001 - Project Setup
|
||||
- [x] 002 - Empty State UI
|
||||
- [x] 003 - Process Manager
|
||||
- [x] 004 - SDK Integration
|
||||
- [x] 005 - Session Picker Modal
|
||||
|
||||
### Phase 2: Core Chat (Tasks 006-010)
|
||||
|
||||
- [ ] 006 - Instance & Session Tabs
|
||||
- [ ] 007 - Message Display
|
||||
- [ ] 008 - SSE Integration
|
||||
- [ ] 009 - Prompt Input (Basic)
|
||||
- [ ] 010 - Tool Call Rendering
|
||||
|
||||
### Phase 3: Essential Features (Tasks 011-015)
|
||||
|
||||
- [ ] 011 - Agent/Model Selectors
|
||||
- [ ] 012 - Markdown Rendering
|
||||
- [ ] 013 - Logs Tab
|
||||
- [ ] 014 - Error Handling
|
||||
- [ ] 015 - Keyboard Shortcuts
|
||||
|
||||
### Phase 4: Multi-Instance (Tasks 016-020)
|
||||
|
||||
- [ ] 016 - Instance Tabs
|
||||
- [ ] 017 - Instance Persistence
|
||||
- [ ] 018 - Child Session Handling
|
||||
- [ ] 019 - Instance Lifecycle
|
||||
- [ ] 020 - Multiple SDK Clients
|
||||
|
||||
### Phase 5: Advanced Input (Tasks 021-025)
|
||||
|
||||
- [ ] 021 - Slash Commands
|
||||
- [ ] 022 - File Attachments
|
||||
- [ ] 023 - Drag & Drop
|
||||
- [ ] 024 - Attachment Chips
|
||||
- [ ] 025 - Input History
|
||||
|
||||
### Phase 6: Polish (Tasks 026-030)
|
||||
|
||||
- [ ] 026 - Message Actions
|
||||
- [ ] 027 - Search in Session
|
||||
- [ ] 028 - Session Management
|
||||
- [ ] 029 - Settings UI
|
||||
- [ ] 030 - Native Menus
|
||||
|
||||
### Phase 7: System Integration (Tasks 031-035)
|
||||
|
||||
- [ ] 031 - System Tray
|
||||
- [ ] 032 - Notifications
|
||||
- [ ] 033 - Auto-updater
|
||||
- [ ] 034 - Crash Reporting
|
||||
- [ ] 035 - Performance Profiling
|
||||
|
||||
### Phase 8: Advanced (Tasks 036-040)
|
||||
|
||||
- [ ] 036 - Virtual Scrolling
|
||||
- [ ] 037 - Advanced Search
|
||||
- [ ] 038 - Workspace Management
|
||||
- [ ] 039 - Theme Customization
|
||||
- [ ] 040 - Plugin System
|
||||
|
||||
## Priority Levels
|
||||
|
||||
Tasks are prioritized as follows:
|
||||
|
||||
- **P0 (MVP)**: Must have for first release (Tasks 001-015)
|
||||
- **P1 (Beta)**: Important for beta (Tasks 016-030)
|
||||
- **P2 (v1.0)**: Should have for v1.0 (Tasks 031-035)
|
||||
- **P3 (Future)**: Nice to have (Tasks 036-040)
|
||||
|
||||
## Dependencies Graph
|
||||
|
||||
```
|
||||
001 (Setup)
|
||||
├─ 002 (Empty State)
|
||||
│ └─ 003 (Process Manager)
|
||||
│ └─ 004 (SDK Integration)
|
||||
│ └─ 005 (Session Picker)
|
||||
│ ├─ 006 (Tabs)
|
||||
│ │ └─ 007 (Messages)
|
||||
│ │ └─ 008 (SSE)
|
||||
│ │ └─ 009 (Input)
|
||||
│ │ └─ 010 (Tool Calls)
|
||||
│ │ └─ 011-015 (Essential Features)
|
||||
│ │ └─ 016-020 (Multi-Instance)
|
||||
│ │ └─ 021-025 (Advanced Input)
|
||||
│ │ └─ 026-030 (Polish)
|
||||
│ │ └─ 031-035 (System)
|
||||
│ │ └─ 036-040 (Advanced)
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- **Don't skip steps** - They're ordered for a reason
|
||||
- **Test as you go** - Don't wait until the end
|
||||
- **Keep tasks small** - Break down if >1 day of work
|
||||
- **Document issues** - Note any blockers or problems
|
||||
- **Ask questions** - If unclear, ask before proceeding
|
||||
|
||||
## Tracking Progress
|
||||
|
||||
Update this file as tasks complete:
|
||||
|
||||
- Change `[ ]` to `[x]` in the task list
|
||||
- Move completed task files to `done/`
|
||||
- Update build roadmap doc
|
||||
|
||||
## Getting Help
|
||||
|
||||
If stuck on a task:
|
||||
|
||||
1. Review prerequisites and dependencies
|
||||
2. Check related documentation in `docs/`
|
||||
3. Review similar patterns in existing code
|
||||
4. Ask for clarification on unclear requirements
|
||||
262
tasks/done/001-project-setup.md
Normal file
262
tasks/done/001-project-setup.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# Task 001: Project Setup & Boilerplate
|
||||
|
||||
## Goal
|
||||
|
||||
Set up the basic Electron + SolidJS + Vite project structure with all necessary dependencies and configuration files.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+ installed
|
||||
- Bun package manager
|
||||
- OpenCode CLI installed and accessible in PATH
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Project structure matches documented layout
|
||||
- [ ] All dependencies installed
|
||||
- [ ] Dev server starts successfully
|
||||
- [ ] Electron window launches
|
||||
- [ ] Hot reload works for renderer
|
||||
- [ ] TypeScript compilation works
|
||||
- [ ] Basic "Hello World" renders
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Initialize Package
|
||||
|
||||
- Create `package.json` with project metadata
|
||||
- Set `name`: `@opencode-ai/client`
|
||||
- Set `version`: `0.1.0`
|
||||
- Set `type`: `module`
|
||||
- Set `main`: `dist/main/main.js`
|
||||
|
||||
### 2. Install Core Dependencies
|
||||
|
||||
**Production:**
|
||||
|
||||
- `electron` ^28.0.0
|
||||
- `solid-js` ^1.8.0
|
||||
- `@solidjs/router` ^0.13.0
|
||||
- `@opencode-ai/sdk` (from workspace)
|
||||
|
||||
**Development:**
|
||||
|
||||
- `electron-vite` ^2.0.0
|
||||
- `electron-builder` ^24.0.0
|
||||
- `vite` ^5.0.0
|
||||
- `vite-plugin-solid` ^2.10.0
|
||||
- `typescript` ^5.3.0
|
||||
- `tailwindcss` ^4.0.0
|
||||
- `@tailwindcss/vite` ^4.0.0
|
||||
|
||||
**UI Libraries:**
|
||||
|
||||
- `@kobalte/core` ^0.13.0
|
||||
- `shiki` ^1.0.0
|
||||
- `marked` ^12.0.0
|
||||
- `lucide-solid` ^0.300.0
|
||||
|
||||
### 3. Create Directory Structure
|
||||
|
||||
```
|
||||
packages/opencode-client/
|
||||
├── electron/
|
||||
│ ├── main/
|
||||
│ │ └── main.ts
|
||||
│ ├── preload/
|
||||
│ │ └── index.ts
|
||||
│ └── resources/
|
||||
│ └── icon.png
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ ├── stores/
|
||||
│ ├── lib/
|
||||
│ ├── hooks/
|
||||
│ ├── types/
|
||||
│ ├── App.tsx
|
||||
│ ├── main.tsx
|
||||
│ └── index.css
|
||||
├── docs/
|
||||
├── tasks/
|
||||
│ ├── todo/
|
||||
│ └── done/
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── tsconfig.node.json
|
||||
├── electron.vite.config.ts
|
||||
├── tailwind.config.js
|
||||
├── .gitignore
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### 4. Configure TypeScript
|
||||
|
||||
**tsconfig.json** (for renderer):
|
||||
|
||||
- `target`: ES2020
|
||||
- `module`: ESNext
|
||||
- `jsx`: preserve
|
||||
- `jsxImportSource`: solid-js
|
||||
- `moduleResolution`: bundler
|
||||
- `strict`: true
|
||||
- Path alias: `@/*` → `./src/*`
|
||||
|
||||
**tsconfig.node.json** (for main & preload):
|
||||
|
||||
- `target`: ES2020
|
||||
- `module`: ESNext
|
||||
- `moduleResolution`: bundler
|
||||
- Include: `electron/**/*.ts`
|
||||
|
||||
### 5. Configure Electron Vite
|
||||
|
||||
**electron.vite.config.ts:**
|
||||
|
||||
- Main process config: External electron
|
||||
- Preload config: External electron
|
||||
- Renderer config:
|
||||
- SolidJS plugin
|
||||
- TailwindCSS plugin
|
||||
- Path alias resolution
|
||||
- Dev server port: 3000
|
||||
|
||||
### 6. Configure TailwindCSS
|
||||
|
||||
**tailwind.config.js:**
|
||||
|
||||
- Content: `['./src/**/*.{ts,tsx}']`
|
||||
- Theme: Default (will customize later)
|
||||
- Plugins: None initially
|
||||
|
||||
**src/index.css:**
|
||||
|
||||
```css
|
||||
@import "tailwindcss";
|
||||
```
|
||||
|
||||
### 7. Create Main Process Entry
|
||||
|
||||
**electron/main/main.ts:**
|
||||
|
||||
- Import app, BrowserWindow from electron
|
||||
- Set up window creation
|
||||
- Window size: 1400x900
|
||||
- Min size: 800x600
|
||||
- Web preferences:
|
||||
- preload: path to preload script
|
||||
- contextIsolation: true
|
||||
- nodeIntegration: false
|
||||
- Load URL based on environment:
|
||||
- Dev: http://localhost:3000
|
||||
- Prod: Load dist/index.html
|
||||
- Handle app lifecycle:
|
||||
- ready event
|
||||
- window-all-closed (quit on non-macOS)
|
||||
- activate (recreate window on macOS)
|
||||
|
||||
### 8. Create Preload Script
|
||||
|
||||
**electron/preload/index.ts:**
|
||||
|
||||
- Import contextBridge, ipcRenderer
|
||||
- Expose electronAPI object:
|
||||
- Placeholder methods for future IPC
|
||||
- Type definitions for window.electronAPI
|
||||
|
||||
### 9. Create Renderer Entry
|
||||
|
||||
**src/main.tsx:**
|
||||
|
||||
- Import render from solid-js/web
|
||||
- Import App component
|
||||
- Render to #root element
|
||||
|
||||
**src/App.tsx:**
|
||||
|
||||
- Basic component with "Hello OpenCode Client"
|
||||
- Display environment info
|
||||
- Basic styling with TailwindCSS
|
||||
|
||||
**index.html:**
|
||||
|
||||
- Root div with id="root"
|
||||
- Link to src/main.tsx
|
||||
|
||||
### 10. Add Scripts to package.json
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"build": "electron-vite build",
|
||||
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json",
|
||||
"preview": "electron-vite preview",
|
||||
"package:mac": "electron-builder --mac",
|
||||
"package:win": "electron-builder --win",
|
||||
"package:linux": "electron-builder --linux"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 11. Configure Electron Builder
|
||||
|
||||
**electron-builder.yml** or in package.json:
|
||||
|
||||
- appId: ai.opencode.client
|
||||
- Product name: OpenCode Client
|
||||
- Build resources: electron/resources
|
||||
- Files to include: dist/, package.json
|
||||
- Directories:
|
||||
- output: release
|
||||
- buildResources: electron/resources
|
||||
- Platform-specific configs (basic)
|
||||
|
||||
### 12. Add .gitignore
|
||||
|
||||
```
|
||||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
.DS_Store
|
||||
*.log
|
||||
.vite/
|
||||
.electron-vite/
|
||||
```
|
||||
|
||||
### 13. Create README
|
||||
|
||||
- Project description
|
||||
- Prerequisites
|
||||
- Installation instructions
|
||||
- Development commands
|
||||
- Build commands
|
||||
- Architecture overview link
|
||||
|
||||
## Verification Steps
|
||||
|
||||
1. Run `bun install`
|
||||
2. Run `bun run dev`
|
||||
3. Verify Electron window opens
|
||||
4. Verify "Hello OpenCode Client" displays
|
||||
5. Make a change to App.tsx
|
||||
6. Verify hot reload updates UI
|
||||
7. Run `bun run typecheck`
|
||||
8. Verify no TypeScript errors
|
||||
9. Run `bun run build`
|
||||
10. Verify dist/ folder created
|
||||
|
||||
## Dependencies for Next Tasks
|
||||
|
||||
- Task 002 (Empty State) depends on this
|
||||
- Task 003 (Process Manager) depends on this
|
||||
|
||||
## Estimated Time
|
||||
|
||||
2-3 hours
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep this minimal - just the skeleton
|
||||
- Don't add any business logic yet
|
||||
- Focus on getting build pipeline working
|
||||
- Use official Electron + Vite + Solid templates as reference
|
||||
281
tasks/done/002-empty-state-ui.md
Normal file
281
tasks/done/002-empty-state-ui.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# Task 002: Empty State UI & Folder Selection
|
||||
|
||||
## Goal
|
||||
|
||||
Create the initial empty state interface that appears when no instances are running, with folder selection capability.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Task 001 completed (project setup)
|
||||
- Basic understanding of SolidJS components
|
||||
- Electron IPC understanding
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Empty state displays when no instances exist
|
||||
- [ ] "Select Folder" button visible and styled
|
||||
- [ ] Clicking button triggers Electron dialog
|
||||
- [ ] Selected folder path displays temporarily
|
||||
- [ ] UI matches design spec (centered, clean)
|
||||
- [ ] Keyboard shortcut Cmd/Ctrl+N works
|
||||
- [ ] Error handling for cancelled selection
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Create Empty State Component
|
||||
|
||||
**src/components/empty-state.tsx:**
|
||||
|
||||
**Structure:**
|
||||
|
||||
- Centered container
|
||||
- Large folder icon (from lucide-solid)
|
||||
- Heading: "Welcome to OpenCode Client"
|
||||
- Subheading: "Select a folder to start coding with AI"
|
||||
- Primary button: "Select Folder"
|
||||
- Helper text: "Keyboard shortcut: Cmd/Ctrl+N"
|
||||
|
||||
**Styling:**
|
||||
|
||||
- Use TailwindCSS utilities
|
||||
- Center vertically and horizontally
|
||||
- Max width: 500px
|
||||
- Padding: 32px
|
||||
- Icon size: 64px
|
||||
- Text sizes: Heading 24px, body 16px, helper 14px
|
||||
- Colors: Follow design spec (light/dark mode)
|
||||
|
||||
**Props:**
|
||||
|
||||
- `onSelectFolder: () => void` - Callback when button clicked
|
||||
|
||||
### 2. Create UI Store
|
||||
|
||||
**src/stores/ui.ts:**
|
||||
|
||||
**State:**
|
||||
|
||||
```typescript
|
||||
interface UIStore {
|
||||
hasInstances: boolean
|
||||
selectedFolder: string | null
|
||||
isSelectingFolder: boolean
|
||||
}
|
||||
```
|
||||
|
||||
**Signals:**
|
||||
|
||||
- `hasInstances` - Reactive boolean
|
||||
- `selectedFolder` - Reactive string or null
|
||||
- `isSelectingFolder` - Reactive boolean (loading state)
|
||||
|
||||
**Actions:**
|
||||
|
||||
- `setHasInstances(value: boolean)`
|
||||
- `setSelectedFolder(path: string | null)`
|
||||
- `setIsSelectingFolder(value: boolean)`
|
||||
|
||||
### 3. Implement IPC for Folder Selection
|
||||
|
||||
**electron/main/main.ts additions:**
|
||||
|
||||
**IPC Handler:**
|
||||
|
||||
- Register handler for 'dialog:selectFolder'
|
||||
- Use `dialog.showOpenDialog()` with:
|
||||
- `properties: ['openDirectory']`
|
||||
- Title: "Select Project Folder"
|
||||
- Button label: "Select"
|
||||
- Return selected folder path or null if cancelled
|
||||
- Handle errors gracefully
|
||||
|
||||
**electron/preload/index.ts additions:**
|
||||
|
||||
**Expose method:**
|
||||
|
||||
```typescript
|
||||
electronAPI: {
|
||||
selectFolder: () => Promise<string | null>
|
||||
}
|
||||
```
|
||||
|
||||
**Type definitions:**
|
||||
|
||||
```typescript
|
||||
interface ElectronAPI {
|
||||
selectFolder: () => Promise<string | null>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Update App Component
|
||||
|
||||
**src/App.tsx:**
|
||||
|
||||
**Logic:**
|
||||
|
||||
- Import UI store
|
||||
- Import EmptyState component
|
||||
- Check if `hasInstances` is false
|
||||
- If false, render EmptyState
|
||||
- If true, render placeholder for instance UI (future)
|
||||
|
||||
**Folder selection handler:**
|
||||
|
||||
```typescript
|
||||
async function handleSelectFolder() {
|
||||
setIsSelectingFolder(true)
|
||||
try {
|
||||
const folder = await window.electronAPI.selectFolder()
|
||||
if (folder) {
|
||||
setSelectedFolder(folder)
|
||||
// TODO: Will trigger instance creation in Task 003
|
||||
console.log("Selected folder:", folder)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Folder selection failed:", error)
|
||||
// TODO: Show error toast (Task 010)
|
||||
} finally {
|
||||
setIsSelectingFolder(false)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Add Keyboard Shortcut
|
||||
|
||||
**electron/main/menu.ts (new file):**
|
||||
|
||||
**Create application menu:**
|
||||
|
||||
- File menu:
|
||||
- New Instance (Cmd/Ctrl+N)
|
||||
- Click: Send 'menu:newInstance' to renderer
|
||||
- Separator
|
||||
- Quit (Cmd/Ctrl+Q)
|
||||
|
||||
**Platform-specific menu:**
|
||||
|
||||
- macOS: Include app menu with About, Hide, etc.
|
||||
- Windows/Linux: Standard File menu
|
||||
|
||||
**Register menu in main.ts:**
|
||||
|
||||
- Import Menu, buildFromTemplate
|
||||
- Create menu structure
|
||||
- Set as application menu
|
||||
|
||||
**electron/preload/index.ts additions:**
|
||||
|
||||
```typescript
|
||||
electronAPI: {
|
||||
onNewInstance: (callback: () => void) => void
|
||||
}
|
||||
```
|
||||
|
||||
**src/App.tsx additions:**
|
||||
|
||||
- Listen for 'newInstance' event
|
||||
- Trigger handleSelectFolder when received
|
||||
|
||||
### 6. Add Loading State
|
||||
|
||||
**Button states:**
|
||||
|
||||
- Default: "Select Folder"
|
||||
- Loading: "Selecting..." with spinner icon
|
||||
- Disabled when isSelectingFolder is true
|
||||
|
||||
**Spinner component:**
|
||||
|
||||
- Use lucide-solid Loader2 icon
|
||||
- Add spin animation class
|
||||
- Size: 16px
|
||||
|
||||
### 7. Add Validation
|
||||
|
||||
**Folder validation (in handler):**
|
||||
|
||||
- Check if folder exists
|
||||
- Check if readable
|
||||
- Check if it's actually a directory
|
||||
- Show appropriate error if invalid
|
||||
|
||||
**Error messages:**
|
||||
|
||||
- "Folder does not exist"
|
||||
- "Cannot access folder (permission denied)"
|
||||
- "Please select a directory, not a file"
|
||||
|
||||
### 8. Style Refinements
|
||||
|
||||
**Responsive behavior:**
|
||||
|
||||
- Works at minimum window size (800x600)
|
||||
- Maintains centering
|
||||
- Text remains readable
|
||||
|
||||
**Accessibility:**
|
||||
|
||||
- Button has proper ARIA labels
|
||||
- Keyboard focus visible
|
||||
- Screen reader friendly text
|
||||
|
||||
**Theme support:**
|
||||
|
||||
- Test in light mode
|
||||
- Test in dark mode (use prefers-color-scheme)
|
||||
- Icons and text have proper contrast
|
||||
|
||||
### 9. Add Helpful Context
|
||||
|
||||
**Additional helper text:**
|
||||
|
||||
- "Examples: ~/projects/my-app"
|
||||
- "You can have multiple instances of the same folder"
|
||||
|
||||
**Icon improvements:**
|
||||
|
||||
- Use animated folder icon (optional)
|
||||
- Add subtle entrance animation (fade in)
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
**Manual Tests:**
|
||||
|
||||
1. Launch app → Empty state appears
|
||||
2. Click "Select Folder" → Dialog opens
|
||||
3. Select folder → Path logged to console
|
||||
4. Cancel dialog → No error, back to empty state
|
||||
5. Press Cmd/Ctrl+N → Dialog opens
|
||||
6. Select non-directory → Error shown
|
||||
7. Select restricted folder → Permission error shown
|
||||
8. Resize window → Layout stays centered
|
||||
|
||||
**Edge Cases:**
|
||||
|
||||
- Very long folder paths (ellipsis)
|
||||
- Special characters in folder name
|
||||
- Folder on network drive
|
||||
- Folder that gets deleted while selected
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Blocks:** Task 003 (needs folder path to create instance)
|
||||
- **Blocked by:** Task 001 (needs project setup)
|
||||
|
||||
## Estimated Time
|
||||
|
||||
2-3 hours
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep UI simple and clean
|
||||
- Focus on UX - clear messaging
|
||||
- Don't implement instance creation yet (that's Task 003)
|
||||
- Log selected folder to console for verification
|
||||
- Prepare for state management patterns used in later tasks
|
||||
430
tasks/done/003-process-manager.md
Normal file
430
tasks/done/003-process-manager.md
Normal file
@@ -0,0 +1,430 @@
|
||||
# Task 003: OpenCode Server Process Management
|
||||
|
||||
## Goal
|
||||
|
||||
Implement the ability to spawn, manage, and kill OpenCode server processes from the Electron main process.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Task 001 completed (project setup)
|
||||
- Task 002 completed (folder selection working)
|
||||
- OpenCode CLI installed and in PATH
|
||||
- Understanding of Node.js child_process API
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Can spawn `opencode serve` for a folder
|
||||
- [ ] Parses stdout to extract port number
|
||||
- [ ] Returns port and PID to renderer
|
||||
- [ ] Handles spawn errors gracefully
|
||||
- [ ] Can kill process on command
|
||||
- [ ] Captures and forwards stdout/stderr
|
||||
- [ ] Timeout protection (10 seconds)
|
||||
- [ ] Process cleanup on app quit
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Create Process Manager Module
|
||||
|
||||
**electron/main/process-manager.ts:**
|
||||
|
||||
**Exports:**
|
||||
|
||||
```typescript
|
||||
interface ProcessInfo {
|
||||
pid: number
|
||||
port: number
|
||||
}
|
||||
|
||||
interface ProcessManager {
|
||||
spawn(folder: string): Promise<ProcessInfo>
|
||||
kill(pid: number): Promise<void>
|
||||
getStatus(pid: number): "running" | "stopped" | "unknown"
|
||||
getAllProcesses(): Map<number, ProcessMeta>
|
||||
}
|
||||
|
||||
interface ProcessMeta {
|
||||
pid: number
|
||||
port: number
|
||||
folder: string
|
||||
startTime: number
|
||||
childProcess: ChildProcess
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Implement Spawn Logic
|
||||
|
||||
**spawn(folder: string):**
|
||||
|
||||
**Pre-flight checks:**
|
||||
|
||||
- Verify `opencode` binary exists in PATH
|
||||
- Use `which opencode` or `where opencode`
|
||||
- If not found, reject with helpful error
|
||||
- Verify folder exists and is directory
|
||||
- Use `fs.stat()` to check
|
||||
- If invalid, reject with error
|
||||
- Verify folder is readable
|
||||
- Check permissions
|
||||
- If denied, reject with error
|
||||
|
||||
**Process spawning:**
|
||||
|
||||
- Use `child_process.spawn()`
|
||||
- Command: `opencode`
|
||||
- Args: `['serve', '--port', '0']`
|
||||
- Port 0 = random available port
|
||||
- Options:
|
||||
- `cwd`: The selected folder
|
||||
- `stdio`: `['ignore', 'pipe', 'pipe']`
|
||||
- stdin: ignore
|
||||
- stdout: pipe (we'll read it)
|
||||
- stderr: pipe (for errors)
|
||||
- `env`: Inherit process.env
|
||||
- `shell`: false (security)
|
||||
|
||||
**Port extraction:**
|
||||
|
||||
- Listen to stdout data events
|
||||
- Buffer output line by line
|
||||
- Regex match: `/Server listening on port (\d+)/` or similar
|
||||
- Extract port number when found
|
||||
- Store process metadata
|
||||
- Resolve promise with { pid, port }
|
||||
|
||||
**Timeout handling:**
|
||||
|
||||
- Set 10 second timeout
|
||||
- If port not found within timeout:
|
||||
- Kill the process
|
||||
- Reject promise with timeout error
|
||||
- Clear timeout once port found
|
||||
|
||||
**Error handling:**
|
||||
|
||||
- Listen to process 'error' event
|
||||
- Common: ENOENT (binary not found)
|
||||
- Reject promise immediately
|
||||
- Listen to process 'exit' event
|
||||
- If exits before port found:
|
||||
- Read stderr buffer
|
||||
- Reject with exit code and stderr
|
||||
|
||||
### 3. Implement Kill Logic
|
||||
|
||||
**kill(pid: number):**
|
||||
|
||||
**Find process:**
|
||||
|
||||
- Look up pid in internal Map
|
||||
- If not found, reject with "Process not found"
|
||||
|
||||
**Graceful shutdown:**
|
||||
|
||||
- Send SIGTERM signal first
|
||||
- Wait 2 seconds
|
||||
- If still running, send SIGKILL
|
||||
- Remove from internal Map
|
||||
- Resolve when process exits
|
||||
|
||||
**Cleanup:**
|
||||
|
||||
- Close stdio streams
|
||||
- Remove all event listeners
|
||||
- Free resources
|
||||
|
||||
### 4. Implement Status Check
|
||||
|
||||
**getStatus(pid: number):**
|
||||
|
||||
**Check if running:**
|
||||
|
||||
- On Unix: Use `process.kill(pid, 0)`
|
||||
- Returns true if running
|
||||
- Throws if not running
|
||||
- On Windows: Use tasklist or similar
|
||||
- Return 'running', 'stopped', or 'unknown'
|
||||
|
||||
### 5. Add Process Tracking
|
||||
|
||||
**Internal state:**
|
||||
|
||||
```typescript
|
||||
const processes = new Map<number, ProcessMeta>()
|
||||
```
|
||||
|
||||
**Track all spawned processes:**
|
||||
|
||||
- Add on successful spawn
|
||||
- Remove on kill or exit
|
||||
- Use for cleanup on app quit
|
||||
|
||||
### 6. Implement Auto-cleanup
|
||||
|
||||
**On app quit:**
|
||||
|
||||
- Listen to app 'before-quit' event
|
||||
- Kill all tracked processes
|
||||
- Wait for all to exit (with timeout)
|
||||
- Prevent quit until cleanup done
|
||||
|
||||
**On process crash:**
|
||||
|
||||
- Listen to process 'exit' event
|
||||
- If unexpected exit:
|
||||
- Log error
|
||||
- Notify renderer via IPC
|
||||
- Remove from tracking
|
||||
|
||||
### 7. Add Logging
|
||||
|
||||
**Log output forwarding:**
|
||||
|
||||
- Listen to stdout/stderr
|
||||
- Parse into lines
|
||||
- Send to renderer via IPC events
|
||||
- Event: 'instance:log'
|
||||
- Payload: { pid, level: 'info' | 'error', message }
|
||||
|
||||
**Log important events:**
|
||||
|
||||
- Process spawned
|
||||
- Port discovered
|
||||
- Process exited
|
||||
- Errors occurred
|
||||
|
||||
### 8. Add IPC Handlers
|
||||
|
||||
**electron/main/ipc.ts (new file):**
|
||||
|
||||
**Register handlers:**
|
||||
|
||||
```typescript
|
||||
ipcMain.handle("process:spawn", async (event, folder: string) => {
|
||||
return await processManager.spawn(folder)
|
||||
})
|
||||
|
||||
ipcMain.handle("process:kill", async (event, pid: number) => {
|
||||
return await processManager.kill(pid)
|
||||
})
|
||||
|
||||
ipcMain.handle("process:status", async (event, pid: number) => {
|
||||
return processManager.getStatus(pid)
|
||||
})
|
||||
```
|
||||
|
||||
**Send events:**
|
||||
|
||||
```typescript
|
||||
// When process exits unexpectedly
|
||||
webContents.send("process:exited", { pid, code, signal })
|
||||
|
||||
// When log output received
|
||||
webContents.send("process:log", { pid, level, message })
|
||||
```
|
||||
|
||||
### 9. Update Preload Script
|
||||
|
||||
**electron/preload/index.ts additions:**
|
||||
|
||||
**Expose methods:**
|
||||
|
||||
```typescript
|
||||
electronAPI: {
|
||||
spawnServer: (folder: string) => Promise<{ pid: number, port: number }>
|
||||
killServer: (pid: number) => Promise<void>
|
||||
getServerStatus: (pid: number) => Promise<string>
|
||||
|
||||
onServerExited: (callback: (data: any) => void) => void
|
||||
onServerLog: (callback: (data: any) => void) => void
|
||||
}
|
||||
```
|
||||
|
||||
**Type definitions:**
|
||||
|
||||
```typescript
|
||||
interface ProcessInfo {
|
||||
pid: number
|
||||
port: number
|
||||
}
|
||||
|
||||
interface ElectronAPI {
|
||||
// ... previous methods
|
||||
spawnServer: (folder: string) => Promise<ProcessInfo>
|
||||
killServer: (pid: number) => Promise<void>
|
||||
getServerStatus: (pid: number) => Promise<"running" | "stopped" | "unknown">
|
||||
onServerExited: (callback: (data: { pid: number; code: number }) => void) => void
|
||||
onServerLog: (callback: (data: { pid: number; level: string; message: string }) => void) => void
|
||||
}
|
||||
```
|
||||
|
||||
### 10. Create Instance Store
|
||||
|
||||
**src/stores/instances.ts:**
|
||||
|
||||
**State:**
|
||||
|
||||
```typescript
|
||||
interface Instance {
|
||||
id: string // UUID
|
||||
folder: string
|
||||
port: number
|
||||
pid: number
|
||||
status: "starting" | "ready" | "error" | "stopped"
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface InstanceStore {
|
||||
instances: Map<string, Instance>
|
||||
activeInstanceId: string | null
|
||||
}
|
||||
```
|
||||
|
||||
**Actions:**
|
||||
|
||||
```typescript
|
||||
async function createInstance(folder: string) {
|
||||
const id = generateId()
|
||||
|
||||
// Add with 'starting' status
|
||||
instances.set(id, {
|
||||
id,
|
||||
folder,
|
||||
port: 0,
|
||||
pid: 0,
|
||||
status: "starting",
|
||||
})
|
||||
|
||||
try {
|
||||
// Spawn server
|
||||
const { pid, port } = await window.electronAPI.spawnServer(folder)
|
||||
|
||||
// Update with port and pid
|
||||
instances.set(id, {
|
||||
...instances.get(id)!,
|
||||
port,
|
||||
pid,
|
||||
status: "ready",
|
||||
})
|
||||
|
||||
return id
|
||||
} catch (error) {
|
||||
// Update with error
|
||||
instances.set(id, {
|
||||
...instances.get(id)!,
|
||||
status: "error",
|
||||
error: error.message,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function removeInstance(id: string) {
|
||||
const instance = instances.get(id)
|
||||
if (!instance) return
|
||||
|
||||
// Kill server
|
||||
if (instance.pid) {
|
||||
await window.electronAPI.killServer(instance.pid)
|
||||
}
|
||||
|
||||
// Remove from store
|
||||
instances.delete(id)
|
||||
|
||||
// If was active, clear active
|
||||
if (activeInstanceId === id) {
|
||||
activeInstanceId = null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 11. Wire Up Folder Selection
|
||||
|
||||
**src/App.tsx updates:**
|
||||
|
||||
**After folder selected:**
|
||||
|
||||
```typescript
|
||||
async function handleSelectFolder() {
|
||||
const folder = await window.electronAPI.selectFolder()
|
||||
if (!folder) return
|
||||
|
||||
try {
|
||||
const instanceId = await createInstance(folder)
|
||||
setActiveInstance(instanceId)
|
||||
|
||||
// Hide empty state, show instance UI
|
||||
setHasInstances(true)
|
||||
} catch (error) {
|
||||
console.error("Failed to create instance:", error)
|
||||
// TODO: Show error toast
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Listen for process exit:**
|
||||
|
||||
```typescript
|
||||
onMount(() => {
|
||||
window.electronAPI.onServerExited(({ pid }) => {
|
||||
// Find instance by PID
|
||||
const instance = Array.from(instances.values()).find((i) => i.pid === pid)
|
||||
|
||||
if (instance) {
|
||||
// Update status
|
||||
instances.set(instance.id, {
|
||||
...instance,
|
||||
status: "stopped",
|
||||
})
|
||||
|
||||
// TODO: Show notification (Task 010)
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
**Manual Tests:**
|
||||
|
||||
1. Select folder → Server spawns
|
||||
2. Console shows "Spawned PID: XXX, Port: YYYY"
|
||||
3. Check `ps aux | grep opencode` → Process running
|
||||
4. Quit app → Process killed
|
||||
5. Select invalid folder → Error shown
|
||||
6. Select without opencode installed → Helpful error
|
||||
7. Spawn multiple instances → All tracked
|
||||
8. Kill one instance → Others continue running
|
||||
|
||||
**Error Cases:**
|
||||
|
||||
- opencode not in PATH
|
||||
- Permission denied on folder
|
||||
- Port already in use (should not happen with port 0)
|
||||
- Server crashes immediately
|
||||
- Timeout (server takes >10s to start)
|
||||
|
||||
**Edge Cases:**
|
||||
|
||||
- Very long folder path
|
||||
- Folder with spaces in name
|
||||
- Folder on network drive (slow to spawn)
|
||||
- Multiple instances same folder (different ports)
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Blocks:** Task 004 (needs running server to connect SDK)
|
||||
- **Blocked by:** Task 001, Task 002
|
||||
|
||||
## Estimated Time
|
||||
|
||||
4-5 hours
|
||||
|
||||
## Notes
|
||||
|
||||
- Security: Never use shell execution with user input
|
||||
- Cross-platform: Test on macOS, Windows, Linux
|
||||
- Error messages must be actionable
|
||||
- Log everything for debugging
|
||||
- Consider rate limiting (max 10 instances?)
|
||||
- Memory: Track process memory usage (future enhancement)
|
||||
504
tasks/done/004-sdk-integration.md
Normal file
504
tasks/done/004-sdk-integration.md
Normal file
@@ -0,0 +1,504 @@
|
||||
# Task 004: SDK Client Integration & Session Management
|
||||
|
||||
## Goal
|
||||
|
||||
Integrate the OpenCode SDK to communicate with running servers, fetch session lists, and manage session lifecycle.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Task 003 completed (server spawning works)
|
||||
- OpenCode SDK package available
|
||||
- Understanding of HTTP/REST APIs
|
||||
- Understanding of SolidJS reactivity
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] SDK client created per instance
|
||||
- [ ] Can fetch session list from server
|
||||
- [ ] Can create new session
|
||||
- [ ] Can get session details
|
||||
- [ ] Can delete session
|
||||
- [ ] Client lifecycle tied to instance lifecycle
|
||||
- [ ] Error handling for network failures
|
||||
- [ ] Proper TypeScript types throughout
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Create SDK Manager Module
|
||||
|
||||
**src/lib/sdk-manager.ts:**
|
||||
|
||||
**Purpose:**
|
||||
|
||||
- Manage SDK client instances
|
||||
- One client per server (per port)
|
||||
- Create, retrieve, destroy clients
|
||||
|
||||
**Interface:**
|
||||
|
||||
```typescript
|
||||
interface SDKManager {
|
||||
createClient(port: number): OpenCodeClient
|
||||
getClient(port: number): OpenCodeClient | null
|
||||
destroyClient(port: number): void
|
||||
destroyAll(): void
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation details:**
|
||||
|
||||
- Store clients in Map<port, client>
|
||||
- Create client with base URL: `http://localhost:${port}`
|
||||
- Handle client creation errors
|
||||
- Clean up on destroy
|
||||
|
||||
### 2. Update Instance Store
|
||||
|
||||
**src/stores/instances.ts additions:**
|
||||
|
||||
**Add client to Instance:**
|
||||
|
||||
```typescript
|
||||
interface Instance {
|
||||
// ... existing fields
|
||||
client: OpenCodeClient | null
|
||||
}
|
||||
```
|
||||
|
||||
**Update createInstance:**
|
||||
|
||||
- After server spawns successfully
|
||||
- Create SDK client for that port
|
||||
- Store in instance.client
|
||||
- Handle client creation errors
|
||||
|
||||
**Update removeInstance:**
|
||||
|
||||
- Destroy SDK client before removing
|
||||
- Call sdkManager.destroyClient(port)
|
||||
|
||||
### 3. Create Session Store
|
||||
|
||||
**src/stores/sessions.ts:**
|
||||
|
||||
**State structure:**
|
||||
|
||||
```typescript
|
||||
interface Session {
|
||||
id: string
|
||||
instanceId: string
|
||||
title: string
|
||||
parentId: string | null
|
||||
agent: string
|
||||
model: {
|
||||
providerId: string
|
||||
modelId: string
|
||||
}
|
||||
time: {
|
||||
created: number
|
||||
updated: number
|
||||
}
|
||||
}
|
||||
|
||||
interface SessionStore {
|
||||
// Sessions grouped by instance
|
||||
sessions: Map<string, Map<string, Session>>
|
||||
|
||||
// Active session per instance
|
||||
activeSessionId: Map<string, string>
|
||||
}
|
||||
```
|
||||
|
||||
**Core actions:**
|
||||
|
||||
```typescript
|
||||
// Fetch all sessions for an instance
|
||||
async function fetchSessions(instanceId: string): Promise<void>
|
||||
|
||||
// Create new session
|
||||
async function createSession(instanceId: string, agent: string): Promise<Session>
|
||||
|
||||
// Delete session
|
||||
async function deleteSession(instanceId: string, sessionId: string): Promise<void>
|
||||
|
||||
// Set active session
|
||||
function setActiveSession(instanceId: string, sessionId: string): void
|
||||
|
||||
// Get active session
|
||||
function getActiveSession(instanceId: string): Session | null
|
||||
|
||||
// Get all sessions for instance
|
||||
function getSessions(instanceId: string): Session[]
|
||||
```
|
||||
|
||||
### 4. Implement Session Fetching
|
||||
|
||||
**fetchSessions implementation:**
|
||||
|
||||
```typescript
|
||||
async function fetchSessions(instanceId: string) {
|
||||
const instance = instances.get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await instance.client.session.list()
|
||||
|
||||
// Convert API response to Session objects
|
||||
const sessionMap = new Map<string, Session>()
|
||||
|
||||
for (const apiSession of response.data) {
|
||||
sessionMap.set(apiSession.id, {
|
||||
id: apiSession.id,
|
||||
instanceId,
|
||||
title: apiSession.title || "Untitled",
|
||||
parentId: apiSession.parentId || null,
|
||||
agent: "", // Will be populated from messages
|
||||
model: { providerId: "", modelId: "" },
|
||||
time: {
|
||||
created: apiSession.time.created,
|
||||
updated: apiSession.time.updated,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
sessions.set(instanceId, sessionMap)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch sessions:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Implement Session Creation
|
||||
|
||||
**createSession implementation:**
|
||||
|
||||
```typescript
|
||||
async function createSession(instanceId: string, agent: string): Promise<Session> {
|
||||
const instance = instances.get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await instance.client.session.create({
|
||||
// OpenCode API might need specific params
|
||||
})
|
||||
|
||||
const session: Session = {
|
||||
id: response.data.id,
|
||||
instanceId,
|
||||
title: "New Session",
|
||||
parentId: null,
|
||||
agent,
|
||||
model: { providerId: "", modelId: "" },
|
||||
time: {
|
||||
created: Date.now(),
|
||||
updated: Date.now(),
|
||||
},
|
||||
}
|
||||
|
||||
// Add to store
|
||||
const instanceSessions = sessions.get(instanceId) || new Map()
|
||||
instanceSessions.set(session.id, session)
|
||||
sessions.set(instanceId, instanceSessions)
|
||||
|
||||
return session
|
||||
} catch (error) {
|
||||
console.error("Failed to create session:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Implement Session Deletion
|
||||
|
||||
**deleteSession implementation:**
|
||||
|
||||
```typescript
|
||||
async function deleteSession(instanceId: string, sessionId: string): Promise<void> {
|
||||
const instance = instances.get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
try {
|
||||
await instance.client.session.delete({ path: { id: sessionId } })
|
||||
|
||||
// Remove from store
|
||||
const instanceSessions = sessions.get(instanceId)
|
||||
if (instanceSessions) {
|
||||
instanceSessions.delete(sessionId)
|
||||
}
|
||||
|
||||
// Clear active if it was active
|
||||
if (activeSessionId.get(instanceId) === sessionId) {
|
||||
activeSessionId.delete(instanceId)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete session:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Implement Agent & Model Fetching
|
||||
|
||||
**Fetch available agents:**
|
||||
|
||||
```typescript
|
||||
interface Agent {
|
||||
name: string
|
||||
description: string
|
||||
mode: string
|
||||
}
|
||||
|
||||
async function fetchAgents(instanceId: string): Promise<Agent[]> {
|
||||
const instance = instances.get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await instance.client.agent.list()
|
||||
return response.data.filter((agent) => agent.mode !== "subagent")
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch agents:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Fetch available models:**
|
||||
|
||||
```typescript
|
||||
interface Provider {
|
||||
id: string
|
||||
name: string
|
||||
models: Model[]
|
||||
}
|
||||
|
||||
interface Model {
|
||||
id: string
|
||||
name: string
|
||||
providerId: string
|
||||
}
|
||||
|
||||
async function fetchProviders(instanceId: string): Promise<Provider[]> {
|
||||
const instance = instances.get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await instance.client.config.providers()
|
||||
return response.data.providers.map((provider) => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
models: Object.entries(provider.models).map(([id, model]) => ({
|
||||
id,
|
||||
name: model.name,
|
||||
providerId: provider.id,
|
||||
})),
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch providers:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Add Error Handling
|
||||
|
||||
**Network error handling:**
|
||||
|
||||
```typescript
|
||||
function handleSDKError(error: any): string {
|
||||
if (error.code === "ECONNREFUSED") {
|
||||
return "Cannot connect to server. Is it running?"
|
||||
}
|
||||
if (error.code === "ETIMEDOUT") {
|
||||
return "Request timed out. Please try again."
|
||||
}
|
||||
if (error.response?.status === 404) {
|
||||
return "Resource not found"
|
||||
}
|
||||
if (error.response?.status === 500) {
|
||||
return "Server error. Check logs."
|
||||
}
|
||||
return error.message || "Unknown error occurred"
|
||||
}
|
||||
```
|
||||
|
||||
**Retry logic (for transient failures):**
|
||||
|
||||
```typescript
|
||||
async function withRetry<T>(fn: () => Promise<T>, maxRetries = 3, delay = 1000): Promise<T> {
|
||||
let lastError
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (i < maxRetries - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
```
|
||||
|
||||
### 9. Add Loading States
|
||||
|
||||
**Track loading states:**
|
||||
|
||||
```typescript
|
||||
interface LoadingState {
|
||||
fetchingSessions: Map<string, boolean>
|
||||
creatingSession: Map<string, boolean>
|
||||
deletingSession: Map<string, Set<string>>
|
||||
}
|
||||
|
||||
const loading: LoadingState = {
|
||||
fetchingSessions: new Map(),
|
||||
creatingSession: new Map(),
|
||||
deletingSession: new Map(),
|
||||
}
|
||||
```
|
||||
|
||||
**Use in actions:**
|
||||
|
||||
```typescript
|
||||
async function fetchSessions(instanceId: string) {
|
||||
loading.fetchingSessions.set(instanceId, true)
|
||||
try {
|
||||
// ... fetch logic
|
||||
} finally {
|
||||
loading.fetchingSessions.set(instanceId, false)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10. Wire Up to Instance Creation
|
||||
|
||||
**src/stores/instances.ts updates:**
|
||||
|
||||
**After server ready:**
|
||||
|
||||
```typescript
|
||||
async function createInstance(folder: string) {
|
||||
// ... spawn server ...
|
||||
|
||||
// Create SDK client
|
||||
const client = sdkManager.createClient(port)
|
||||
|
||||
// Update instance
|
||||
instances.set(id, {
|
||||
...instances.get(id)!,
|
||||
port,
|
||||
pid,
|
||||
client,
|
||||
status: "ready",
|
||||
})
|
||||
|
||||
// Fetch initial data
|
||||
try {
|
||||
await fetchSessions(id)
|
||||
await fetchAgents(id)
|
||||
await fetchProviders(id)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch initial data:", error)
|
||||
// Don't fail instance creation, just log
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
```
|
||||
|
||||
### 11. Add Type Safety
|
||||
|
||||
**src/types/session.ts:**
|
||||
|
||||
```typescript
|
||||
export interface Session {
|
||||
id: string
|
||||
instanceId: string
|
||||
title: string
|
||||
parentId: string | null
|
||||
agent: string
|
||||
model: {
|
||||
providerId: string
|
||||
modelId: string
|
||||
}
|
||||
time: {
|
||||
created: number
|
||||
updated: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
name: string
|
||||
description: string
|
||||
mode: string
|
||||
}
|
||||
|
||||
export interface Provider {
|
||||
id: string
|
||||
name: string
|
||||
models: Model[]
|
||||
}
|
||||
|
||||
export interface Model {
|
||||
id: string
|
||||
name: string
|
||||
providerId: string
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
**Manual Tests:**
|
||||
|
||||
1. Create instance → Sessions fetched automatically
|
||||
2. Console shows session list
|
||||
3. Create new session → Appears in list
|
||||
4. Delete session → Removed from list
|
||||
5. Network fails → Error message shown
|
||||
6. Server not running → Graceful error
|
||||
|
||||
**Error Cases:**
|
||||
|
||||
- Server not responding (ECONNREFUSED)
|
||||
- Request timeout
|
||||
- 404 on session endpoint
|
||||
- 500 server error
|
||||
- Invalid session ID
|
||||
|
||||
**Edge Cases:**
|
||||
|
||||
- No sessions exist (empty list)
|
||||
- Many sessions (100+)
|
||||
- Session with very long title
|
||||
- Parent-child session relationships
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Blocks:** Task 005 (needs session data)
|
||||
- **Blocked by:** Task 003 (needs running server)
|
||||
|
||||
## Estimated Time
|
||||
|
||||
3-4 hours
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep SDK calls isolated in store actions
|
||||
- All SDK calls should have error handling
|
||||
- Consider caching to reduce API calls
|
||||
- Log all API calls for debugging
|
||||
- Handle slow connections gracefully
|
||||
333
tasks/done/005-session-picker-modal.md
Normal file
333
tasks/done/005-session-picker-modal.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# Task 005: Session Picker Modal
|
||||
|
||||
## Goal
|
||||
|
||||
Create the session picker modal that appears when an instance starts, allowing users to resume an existing session or create a new one.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Task 004 completed (SDK integration, session fetching)
|
||||
- Understanding of modal/dialog patterns
|
||||
- Kobalte UI primitives knowledge
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Modal appears after instance becomes ready
|
||||
- [ ] Displays list of existing sessions
|
||||
- [ ] Shows session metadata (title, timestamp)
|
||||
- [ ] Allows creating new session with agent selection
|
||||
- [ ] Can close modal (cancels instance creation)
|
||||
- [ ] Keyboard navigation works (up/down, enter)
|
||||
- [ ] Properly styled and accessible
|
||||
- [ ] Loading states during fetch
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Create Session Picker Component
|
||||
|
||||
**src/components/session-picker.tsx:**
|
||||
|
||||
**Props:**
|
||||
|
||||
```typescript
|
||||
interface SessionPickerProps {
|
||||
instanceId: string
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSessionSelect: (sessionId: string) => void
|
||||
onNewSession: (agent: string) => void
|
||||
}
|
||||
```
|
||||
|
||||
**Structure:**
|
||||
|
||||
- Modal backdrop (semi-transparent overlay)
|
||||
- Modal dialog (centered card)
|
||||
- Header: "OpenCode • {folder}"
|
||||
- Section 1: Resume session list
|
||||
- Separator: "or"
|
||||
- Section 2: Create new session
|
||||
- Footer: Cancel button
|
||||
|
||||
### 2. Use Kobalte Dialog
|
||||
|
||||
**Implementation approach:**
|
||||
|
||||
```typescript
|
||||
import { Dialog } from '@kobalte/core'
|
||||
|
||||
<Dialog.Root open={props.open} onOpenChange={(open) => !open && props.onClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay />
|
||||
<Dialog.Content>
|
||||
{/* Modal content */}
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
```
|
||||
|
||||
**Styling:**
|
||||
|
||||
- Overlay: Dark background, 50% opacity
|
||||
- Content: White card, max-width 500px, centered
|
||||
- Padding: 24px
|
||||
- Border radius: 8px
|
||||
- Shadow: Large elevation
|
||||
|
||||
### 3. Create Session List Section
|
||||
|
||||
**Resume Section:**
|
||||
|
||||
- Header: "Resume a session:"
|
||||
- List of sessions (max 10 recent)
|
||||
- Each item shows:
|
||||
- Title (truncated at 50 chars)
|
||||
- Relative timestamp ("2h ago")
|
||||
- Hover state
|
||||
- Active selection state
|
||||
|
||||
**Session Item Component:**
|
||||
|
||||
```typescript
|
||||
interface SessionItemProps {
|
||||
session: Session
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
```
|
||||
|
||||
**Empty state:**
|
||||
|
||||
- Show when no sessions exist
|
||||
- Text: "No previous sessions"
|
||||
- Muted styling
|
||||
|
||||
**Scrollable:**
|
||||
|
||||
- If >5 sessions, add scroll
|
||||
- Max height: 300px
|
||||
|
||||
### 4. Create New Session Section
|
||||
|
||||
**Structure:**
|
||||
|
||||
- Header: "Start new session:"
|
||||
- Agent selector dropdown
|
||||
- "Start" button
|
||||
|
||||
**Agent Selector:**
|
||||
|
||||
- Dropdown using Kobalte Select
|
||||
- Shows agent name
|
||||
- Grouped by category if applicable
|
||||
- Default: "Build" agent
|
||||
|
||||
**Start Button:**
|
||||
|
||||
- Primary button style
|
||||
- Click triggers onNewSession callback
|
||||
- Disabled while creating
|
||||
|
||||
### 5. Add Loading States
|
||||
|
||||
**While fetching sessions:**
|
||||
|
||||
- Show skeleton list (3-4 placeholder items)
|
||||
- Shimmer animation
|
||||
|
||||
**While fetching agents:**
|
||||
|
||||
- Agent dropdown shows "Loading..."
|
||||
- Disabled state
|
||||
|
||||
**While creating session:**
|
||||
|
||||
- Start button shows spinner
|
||||
- Disabled state
|
||||
- Text changes to "Creating..."
|
||||
|
||||
### 6. Wire Up to Instance Store
|
||||
|
||||
**Show modal after instance ready:**
|
||||
|
||||
**src/stores/ui.ts additions:**
|
||||
|
||||
```typescript
|
||||
interface UIStore {
|
||||
sessionPickerInstance: string | null
|
||||
}
|
||||
|
||||
function showSessionPicker(instanceId: string) {
|
||||
sessionPickerInstance = instanceId
|
||||
}
|
||||
|
||||
function hideSessionPicker() {
|
||||
sessionPickerInstance = null
|
||||
}
|
||||
```
|
||||
|
||||
**src/stores/instances.ts updates:**
|
||||
|
||||
```typescript
|
||||
async function createInstance(folder: string) {
|
||||
// ... spawn and connect ...
|
||||
|
||||
// Show session picker
|
||||
showSessionPicker(id)
|
||||
|
||||
return id
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Handle Session Selection
|
||||
|
||||
**Resume session:**
|
||||
|
||||
```typescript
|
||||
function handleSessionSelect(sessionId: string) {
|
||||
setActiveSession(instanceId, sessionId)
|
||||
hideSessionPicker()
|
||||
|
||||
// Will trigger session display in Task 006
|
||||
}
|
||||
```
|
||||
|
||||
**Create new session:**
|
||||
|
||||
```typescript
|
||||
async function handleNewSession(agent: string) {
|
||||
try {
|
||||
const session = await createSession(instanceId, agent)
|
||||
setActiveSession(instanceId, session.id)
|
||||
hideSessionPicker()
|
||||
} catch (error) {
|
||||
// Show error toast (Task 010)
|
||||
console.error("Failed to create session:", error)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Handle Cancel
|
||||
|
||||
**Close modal:**
|
||||
|
||||
```typescript
|
||||
function handleClose() {
|
||||
// Remove instance since user cancelled
|
||||
await removeInstance(instanceId)
|
||||
hideSessionPicker()
|
||||
}
|
||||
```
|
||||
|
||||
**Confirmation if needed:**
|
||||
|
||||
- If server already started, ask "Stop server?"
|
||||
- Otherwise, just close
|
||||
|
||||
### 9. Add Keyboard Navigation
|
||||
|
||||
**Keyboard shortcuts:**
|
||||
|
||||
- Up/Down: Navigate session list
|
||||
- Enter: Select highlighted session
|
||||
- Escape: Close modal (cancel)
|
||||
- Tab: Cycle through sections
|
||||
|
||||
**Implement focus management:**
|
||||
|
||||
- Auto-focus first session on open
|
||||
- Trap focus within modal
|
||||
- Restore focus on close
|
||||
|
||||
### 10. Add Accessibility
|
||||
|
||||
**ARIA attributes:**
|
||||
|
||||
- `role="dialog"`
|
||||
- `aria-labelledby="dialog-title"`
|
||||
- `aria-describedby="dialog-description"`
|
||||
- `aria-modal="true"`
|
||||
|
||||
**Screen reader support:**
|
||||
|
||||
- Announce "X sessions available"
|
||||
- Announce selection changes
|
||||
- Clear focus indicators
|
||||
|
||||
### 11. Style Refinements
|
||||
|
||||
**Light/Dark mode:**
|
||||
|
||||
- Test in both themes
|
||||
- Ensure contrast meets WCAG AA
|
||||
- Use CSS variables for colors
|
||||
|
||||
**Responsive:**
|
||||
|
||||
- Works at minimum window size
|
||||
- Mobile-friendly (future web version)
|
||||
- Scales text appropriately
|
||||
|
||||
**Animations:**
|
||||
|
||||
- Fade in backdrop (200ms)
|
||||
- Scale in content (200ms)
|
||||
- Smooth transitions on hover
|
||||
|
||||
### 12. Update App Component
|
||||
|
||||
**src/App.tsx:**
|
||||
|
||||
**Render session picker:**
|
||||
|
||||
```typescript
|
||||
<Show when={ui.sessionPickerInstance}>
|
||||
{(instanceId) => (
|
||||
<SessionPicker
|
||||
instanceId={instanceId()}
|
||||
open={true}
|
||||
onClose={() => ui.hideSessionPicker()}
|
||||
onSessionSelect={(id) => handleSessionSelect(instanceId(), id)}
|
||||
onNewSession={(agent) => handleNewSession(instanceId(), agent)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
**Manual Tests:**
|
||||
|
||||
1. Create instance → Modal appears
|
||||
2. Shows session list if sessions exist
|
||||
3. Shows empty state if no sessions
|
||||
4. Click session → Modal closes, session activates
|
||||
5. Select agent, click Start → New session created
|
||||
6. Press Escape → Modal closes, instance removed
|
||||
7. Keyboard navigation works
|
||||
8. Screen reader announces content
|
||||
|
||||
**Edge Cases:**
|
||||
|
||||
- No sessions + no agents (error state)
|
||||
- Very long session titles (truncate)
|
||||
- Many sessions (scroll works)
|
||||
- Create session fails (error shown)
|
||||
- Slow network (loading states)
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Blocks:** Task 006 (needs active session)
|
||||
- **Blocked by:** Task 004 (needs session data)
|
||||
|
||||
## Estimated Time
|
||||
|
||||
3-4 hours
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep modal simple and focused
|
||||
- Clear call-to-action
|
||||
- Don't overwhelm with options
|
||||
- Loading states crucial for UX
|
||||
- Consider adding search if >20 sessions (future)
|
||||
591
tasks/done/006-instance-session-tabs.md
Normal file
591
tasks/done/006-instance-session-tabs.md
Normal file
@@ -0,0 +1,591 @@
|
||||
# Task 006: Instance & Session Tabs
|
||||
|
||||
## Goal
|
||||
|
||||
Create the two-level tab navigation system: instance tabs (Level 1) and session tabs (Level 2) that allow users to switch between projects and conversations.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Task 005 completed (Session picker modal, active session selection)
|
||||
- Understanding of tab navigation patterns
|
||||
- Familiarity with SolidJS For/Show components
|
||||
- Knowledge of keyboard accessibility
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Instance tabs render at top level
|
||||
- [ ] Session tabs render below instance tabs for active instance
|
||||
- [ ] Can switch between instance tabs
|
||||
- [ ] Can switch between session tabs within an instance
|
||||
- [ ] Active tab is visually highlighted
|
||||
- [ ] Tab labels show appropriate text (folder name, session title)
|
||||
- [ ] Close buttons work on tabs (with confirmation)
|
||||
- [ ] "+" button creates new instance/session
|
||||
- [ ] Keyboard navigation works (Cmd/Ctrl+1-9 for tabs)
|
||||
- [ ] Tabs scroll horizontally when many exist
|
||||
- [ ] Properly styled and accessible
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Create Instance Tabs Component
|
||||
|
||||
**src/components/instance-tabs.tsx:**
|
||||
|
||||
**Props:**
|
||||
|
||||
```typescript
|
||||
interface InstanceTabsProps {
|
||||
instances: Map<string, Instance>
|
||||
activeInstanceId: string | null
|
||||
onSelect: (instanceId: string) => void
|
||||
onClose: (instanceId: string) => void
|
||||
onNew: () => void
|
||||
}
|
||||
```
|
||||
|
||||
**Structure:**
|
||||
|
||||
```tsx
|
||||
<div class="instance-tabs">
|
||||
<div class="tabs-container">
|
||||
<For each={Array.from(instances.entries())}>
|
||||
{([id, instance]) => (
|
||||
<InstanceTab
|
||||
instance={instance}
|
||||
active={id === activeInstanceId}
|
||||
onSelect={() => onSelect(id)}
|
||||
onClose={() => onClose(id)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<button class="new-tab-button" onClick={onNew}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Styling:**
|
||||
|
||||
- Horizontal layout
|
||||
- Background: Secondary background color
|
||||
- Border bottom: 1px solid border color
|
||||
- Height: 40px
|
||||
- Padding: 0 8px
|
||||
- Overflow-x: auto (for many tabs)
|
||||
|
||||
### 2. Create Instance Tab Item Component
|
||||
|
||||
**src/components/instance-tab.tsx:**
|
||||
|
||||
**Props:**
|
||||
|
||||
```typescript
|
||||
interface InstanceTabProps {
|
||||
instance: Instance
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
```
|
||||
|
||||
**Structure:**
|
||||
|
||||
```tsx
|
||||
<button class={`instance-tab ${active ? "active" : ""}`} onClick={onSelect}>
|
||||
<span class="tab-icon">📁</span>
|
||||
<span class="tab-label">{formatFolderName(instance.folder)}</span>
|
||||
<button
|
||||
class="tab-close"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</button>
|
||||
```
|
||||
|
||||
**Styling:**
|
||||
|
||||
- Display: inline-flex
|
||||
- Align items center
|
||||
- Gap: 8px
|
||||
- Padding: 8px 12px
|
||||
- Border radius: 6px 6px 0 0
|
||||
- Max width: 200px
|
||||
- Truncate text with ellipsis
|
||||
- Active: Background accent color
|
||||
- Inactive: Transparent background
|
||||
- Hover: Light background
|
||||
|
||||
**Folder Name Formatting:**
|
||||
|
||||
```typescript
|
||||
function formatFolderName(path: string): string {
|
||||
const name = path.split("/").pop() || path
|
||||
return `~/${name}`
|
||||
}
|
||||
```
|
||||
|
||||
**Handle Duplicates:**
|
||||
|
||||
- If multiple instances have same folder name, add counter
|
||||
- Example: `~/project`, `~/project (2)`, `~/project (3)`
|
||||
|
||||
### 3. Create Session Tabs Component
|
||||
|
||||
**src/components/session-tabs.tsx:**
|
||||
|
||||
**Props:**
|
||||
|
||||
```typescript
|
||||
interface SessionTabsProps {
|
||||
instanceId: string
|
||||
sessions: Map<string, Session>
|
||||
activeSessionId: string | null
|
||||
onSelect: (sessionId: string) => void
|
||||
onClose: (sessionId: string) => void
|
||||
onNew: () => void
|
||||
}
|
||||
```
|
||||
|
||||
**Structure:**
|
||||
|
||||
```tsx
|
||||
<div class="session-tabs">
|
||||
<div class="tabs-container">
|
||||
<For each={Array.from(sessions.entries())}>
|
||||
{([id, session]) => (
|
||||
<SessionTab
|
||||
session={session}
|
||||
active={id === activeSessionId}
|
||||
onSelect={() => onSelect(id)}
|
||||
onClose={() => onClose(id)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<SessionTab special="logs" active={activeSessionId === "logs"} onSelect={() => onSelect("logs")} />
|
||||
<button class="new-tab-button" onClick={onNew}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Styling:**
|
||||
|
||||
- Similar to instance tabs but smaller
|
||||
- Height: 36px
|
||||
- Font size: 13px
|
||||
- Less prominent than instance tabs
|
||||
|
||||
### 4. Create Session Tab Item Component
|
||||
|
||||
**src/components/session-tab.tsx:**
|
||||
|
||||
**Props:**
|
||||
|
||||
```typescript
|
||||
interface SessionTabProps {
|
||||
session?: Session
|
||||
special?: "logs"
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
```
|
||||
|
||||
**Structure:**
|
||||
|
||||
```tsx
|
||||
<button class={`session-tab ${active ? "active" : ""} ${special ? "special" : ""}`} onClick={onSelect}>
|
||||
<span class="tab-label">{special === "logs" ? "Logs" : session?.title || "Untitled"}</span>
|
||||
<Show when={!special && onClose}>
|
||||
<button
|
||||
class="tab-close"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClose?.()
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Show>
|
||||
</button>
|
||||
```
|
||||
|
||||
**Styling:**
|
||||
|
||||
- Max width: 150px
|
||||
- Truncate with ellipsis
|
||||
- Active: Underline or bold text
|
||||
- Logs tab: Slightly different color/icon
|
||||
|
||||
### 5. Add Tab State Management
|
||||
|
||||
**src/stores/ui.ts updates:**
|
||||
|
||||
```typescript
|
||||
interface UIState {
|
||||
instanceTabOrder: string[]
|
||||
sessionTabOrder: Map<string, string[]>
|
||||
|
||||
reorderInstanceTabs: (newOrder: string[]) => void
|
||||
reorderSessionTabs: (instanceId: string, newOrder: string[]) => void
|
||||
}
|
||||
|
||||
const [instanceTabOrder, setInstanceTabOrder] = createSignal<string[]>([])
|
||||
const [sessionTabOrder, setSessionTabOrder] = createSignal<Map<string, string[]>>(new Map())
|
||||
|
||||
function reorderInstanceTabs(newOrder: string[]) {
|
||||
setInstanceTabOrder(newOrder)
|
||||
}
|
||||
|
||||
function reorderSessionTabs(instanceId: string, newOrder: string[]) {
|
||||
setSessionTabOrder((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, newOrder)
|
||||
return next
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Wire Up Tab Selection
|
||||
|
||||
**src/stores/instances.ts updates:**
|
||||
|
||||
```typescript
|
||||
function setActiveInstance(id: string) {
|
||||
activeInstanceId = id
|
||||
|
||||
// Auto-select first session or show session picker
|
||||
const instance = instances.get(id)
|
||||
if (instance) {
|
||||
const sessions = Array.from(instance.sessions.values())
|
||||
if (sessions.length > 0 && !instance.activeSessionId) {
|
||||
instance.activeSessionId = sessions[0].id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveSession(instanceId: string, sessionId: string) {
|
||||
const instance = instances.get(instanceId)
|
||||
if (instance) {
|
||||
instance.activeSessionId = sessionId
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Handle Tab Close Actions
|
||||
|
||||
**Close Instance Tab:**
|
||||
|
||||
```typescript
|
||||
async function handleCloseInstance(instanceId: string) {
|
||||
const confirmed = await showConfirmDialog({
|
||||
title: "Stop OpenCode instance?",
|
||||
message: `This will stop the server for ${instance.folder}`,
|
||||
confirmText: "Stop Instance",
|
||||
destructive: true,
|
||||
})
|
||||
|
||||
if (confirmed) {
|
||||
await removeInstance(instanceId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Close Session Tab:**
|
||||
|
||||
```typescript
|
||||
async function handleCloseSession(instanceId: string, sessionId: string) {
|
||||
const session = getInstance(instanceId)?.sessions.get(sessionId)
|
||||
|
||||
if (session && session.messages.length > 0) {
|
||||
const confirmed = await showConfirmDialog({
|
||||
title: "Delete session?",
|
||||
message: `This will permanently delete "${session.title}"`,
|
||||
confirmText: "Delete",
|
||||
destructive: true,
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
}
|
||||
|
||||
await deleteSession(instanceId, sessionId)
|
||||
|
||||
// Switch to another session
|
||||
const instance = getInstance(instanceId)
|
||||
const remainingSessions = Array.from(instance.sessions.values())
|
||||
if (remainingSessions.length > 0) {
|
||||
setActiveSession(instanceId, remainingSessions[0].id)
|
||||
} else {
|
||||
// Show session picker
|
||||
showSessionPicker(instanceId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Handle New Tab Buttons
|
||||
|
||||
**New Instance:**
|
||||
|
||||
```typescript
|
||||
async function handleNewInstance() {
|
||||
const folder = await window.electronAPI.selectFolder()
|
||||
if (folder) {
|
||||
await createInstance(folder)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**New Session:**
|
||||
|
||||
```typescript
|
||||
async function handleNewSession(instanceId: string) {
|
||||
// For now, use default agent
|
||||
// Later (Task 011) will show agent selector
|
||||
const session = await createSession(instanceId, "build")
|
||||
setActiveSession(instanceId, session.id)
|
||||
}
|
||||
```
|
||||
|
||||
### 9. Update App Layout
|
||||
|
||||
**src/App.tsx:**
|
||||
|
||||
```tsx
|
||||
<div class="app">
|
||||
<Show when={instances.size > 0} fallback={<EmptyState />}>
|
||||
<InstanceTabs
|
||||
instances={instances()}
|
||||
activeInstanceId={activeInstanceId()}
|
||||
onSelect={setActiveInstance}
|
||||
onClose={handleCloseInstance}
|
||||
onNew={handleNewInstance}
|
||||
/>
|
||||
|
||||
<Show when={activeInstance()}>
|
||||
{(instance) => (
|
||||
<>
|
||||
<SessionTabs
|
||||
instanceId={instance().id}
|
||||
sessions={instance().sessions}
|
||||
activeSessionId={instance().activeSessionId}
|
||||
onSelect={(id) => setActiveSession(instance().id, id)}
|
||||
onClose={(id) => handleCloseSession(instance().id, id)}
|
||||
onNew={() => handleNewSession(instance().id)}
|
||||
/>
|
||||
|
||||
<div class="content-area">
|
||||
{/* Message stream and input will go here in Task 007 */}
|
||||
<Show when={instance().activeSessionId === "logs"}>
|
||||
<LogsView logs={instance().logs} />
|
||||
</Show>
|
||||
<Show when={instance().activeSessionId !== "logs"}>
|
||||
<div class="placeholder">Session content will appear here (Task 007)</div>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 10. Add Keyboard Shortcuts
|
||||
|
||||
**Keyboard navigation:**
|
||||
|
||||
```typescript
|
||||
// src/lib/keyboard.ts
|
||||
|
||||
export function setupTabKeyboardShortcuts() {
|
||||
window.addEventListener("keydown", (e) => {
|
||||
// Cmd/Ctrl + 1-9: Switch instance tabs
|
||||
if ((e.metaKey || e.ctrlKey) && e.key >= "1" && e.key <= "9") {
|
||||
e.preventDefault()
|
||||
const index = parseInt(e.key) - 1
|
||||
const instances = Array.from(instanceStore.instances.keys())
|
||||
if (instances[index]) {
|
||||
setActiveInstance(instances[index])
|
||||
}
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + N: New instance
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "n") {
|
||||
e.preventDefault()
|
||||
handleNewInstance()
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + T: New session
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "t") {
|
||||
e.preventDefault()
|
||||
if (activeInstanceId()) {
|
||||
handleNewSession(activeInstanceId()!)
|
||||
}
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + W: Close current tab
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "w") {
|
||||
e.preventDefault()
|
||||
const instanceId = activeInstanceId()
|
||||
const instance = getInstance(instanceId)
|
||||
if (instance?.activeSessionId && instance.activeSessionId !== "logs") {
|
||||
handleCloseSession(instanceId!, instance.activeSessionId)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Call in main.tsx:**
|
||||
|
||||
```typescript
|
||||
import { setupTabKeyboardShortcuts } from "./lib/keyboard"
|
||||
|
||||
onMount(() => {
|
||||
setupTabKeyboardShortcuts()
|
||||
})
|
||||
```
|
||||
|
||||
### 11. Add Accessibility
|
||||
|
||||
**ARIA attributes:**
|
||||
|
||||
```tsx
|
||||
<div role="tablist" aria-label="Instance tabs">
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
aria-controls={`instance-panel-${instance.id}`}
|
||||
>
|
||||
...
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={`instance-panel-${instance.id}`}
|
||||
aria-labelledby={`instance-tab-${instance.id}`}
|
||||
>
|
||||
{/* Session tabs */}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Focus management:**
|
||||
|
||||
- Tab key cycles through tabs
|
||||
- Arrow keys navigate within tab list
|
||||
- Focus indicators visible
|
||||
- Skip links for screen readers
|
||||
|
||||
### 12. Style Refinements
|
||||
|
||||
**Horizontal scroll:**
|
||||
|
||||
```css
|
||||
.tabs-container {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.tabs-container::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.tabs-container::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 2px;
|
||||
}
|
||||
```
|
||||
|
||||
**Tab animations:**
|
||||
|
||||
```css
|
||||
.instance-tab,
|
||||
.session-tab {
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.instance-tab:hover,
|
||||
.session-tab:hover {
|
||||
background-color: var(--hover-background);
|
||||
}
|
||||
|
||||
.instance-tab.active,
|
||||
.session-tab.active {
|
||||
background-color: var(--active-background);
|
||||
}
|
||||
```
|
||||
|
||||
**Close button styling:**
|
||||
|
||||
```css
|
||||
.tab-close {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.instance-tab:hover .tab-close,
|
||||
.session-tab:hover .tab-close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
background-color: var(--danger-background);
|
||||
color: var(--danger-color);
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
**Manual Tests:**
|
||||
|
||||
1. Create instance → Instance tab appears
|
||||
2. Click instance tab → Switches active instance
|
||||
3. Session tabs appear below active instance
|
||||
4. Click session tab → Switches active session
|
||||
5. Click "+" on instance tabs → Opens folder picker
|
||||
6. Click "+" on session tabs → Creates new session
|
||||
7. Click close on instance tab → Shows confirmation, closes
|
||||
8. Click close on session tab → Closes session
|
||||
9. Cmd/Ctrl+1 switches to first instance
|
||||
10. Cmd/Ctrl+N opens new instance
|
||||
11. Cmd/Ctrl+T creates new session
|
||||
12. Cmd/Ctrl+W closes active session
|
||||
13. Tabs scroll when many exist
|
||||
14. Logs tab always visible and non-closable
|
||||
15. Tab labels truncate long names
|
||||
|
||||
**Edge Cases:**
|
||||
|
||||
- Only one instance (no scrolling needed)
|
||||
- Many instances (>10, horizontal scroll)
|
||||
- No sessions in instance (only Logs tab visible)
|
||||
- Duplicate folder names (counter added)
|
||||
- Very long folder/session names (ellipsis)
|
||||
- Close last session (session picker appears)
|
||||
- Switch instance while session is streaming
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Blocks:** Task 007 (needs tab structure to display messages)
|
||||
- **Blocked by:** Task 005 (needs session selection to work)
|
||||
|
||||
## Estimated Time
|
||||
|
||||
4-5 hours
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep tab design clean and minimal
|
||||
- Don't over-engineer tab reordering (can add later)
|
||||
- Focus on functionality over fancy animations
|
||||
- Ensure keyboard accessibility from the start
|
||||
- Tab state will persist in Task 017
|
||||
- Context menus for tabs can be added in Task 026
|
||||
810
tasks/done/007-message-display.md
Normal file
810
tasks/done/007-message-display.md
Normal file
@@ -0,0 +1,810 @@
|
||||
# Task 007: Message Display
|
||||
|
||||
## Goal
|
||||
|
||||
Create the message display component that renders user and assistant messages in a scrollable stream, showing message content, tool calls, and streaming states.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Task 006 completed (Tab navigation in place)
|
||||
- Understanding of message part structure from OpenCode SDK
|
||||
- Familiarity with markdown rendering
|
||||
- Knowledge of SolidJS For/Show components
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Messages render in chronological order
|
||||
- [ ] User messages display with correct styling
|
||||
- [ ] Assistant messages display with agent label
|
||||
- [ ] Text content renders properly
|
||||
- [ ] Tool calls display inline with collapse/expand
|
||||
- [ ] Auto-scroll to bottom on new messages
|
||||
- [ ] Manual scroll up disables auto-scroll
|
||||
- [ ] "Scroll to bottom" button appears when scrolled up
|
||||
- [ ] Empty state shows when no messages
|
||||
- [ ] Loading state shows when fetching messages
|
||||
- [ ] Timestamps display for each message
|
||||
- [ ] Messages are accessible and keyboard-navigable
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Define Message Types
|
||||
|
||||
**src/types/message.ts:**
|
||||
|
||||
```typescript
|
||||
export interface Message {
|
||||
id: string
|
||||
sessionId: string
|
||||
type: "user" | "assistant"
|
||||
parts: MessagePart[]
|
||||
timestamp: number
|
||||
status: "sending" | "sent" | "streaming" | "complete" | "error"
|
||||
}
|
||||
|
||||
export type MessagePart = TextPart | ToolCallPart | ToolResultPart | ErrorPart
|
||||
|
||||
export interface TextPart {
|
||||
type: "text"
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface ToolCallPart {
|
||||
type: "tool_call"
|
||||
id: string
|
||||
tool: string
|
||||
input: any
|
||||
status: "pending" | "running" | "success" | "error"
|
||||
}
|
||||
|
||||
export interface ToolResultPart {
|
||||
type: "tool_result"
|
||||
toolCallId: string
|
||||
output: any
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ErrorPart {
|
||||
type: "error"
|
||||
message: string
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Create Message Stream Component
|
||||
|
||||
**src/components/message-stream.tsx:**
|
||||
|
||||
```typescript
|
||||
import { For, Show, createSignal, onMount, onCleanup } from "solid-js"
|
||||
import { Message } from "../types/message"
|
||||
import MessageItem from "./message-item"
|
||||
|
||||
interface MessageStreamProps {
|
||||
sessionId: string
|
||||
messages: Message[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export default function MessageStream(props: MessageStreamProps) {
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
const [autoScroll, setAutoScroll] = createSignal(true)
|
||||
const [showScrollButton, setShowScrollButton] = createSignal(false)
|
||||
|
||||
function scrollToBottom() {
|
||||
if (containerRef) {
|
||||
containerRef.scrollTop = containerRef.scrollHeight
|
||||
setAutoScroll(true)
|
||||
setShowScrollButton(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
if (!containerRef) return
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50
|
||||
|
||||
setAutoScroll(isAtBottom)
|
||||
setShowScrollButton(!isAtBottom)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (autoScroll()) {
|
||||
scrollToBottom()
|
||||
}
|
||||
})
|
||||
|
||||
// Auto-scroll when new messages arrive
|
||||
const messagesLength = () => props.messages.length
|
||||
createEffect(() => {
|
||||
messagesLength() // Track changes
|
||||
if (autoScroll()) {
|
||||
setTimeout(scrollToBottom, 0)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="message-stream-container">
|
||||
<div
|
||||
ref={containerRef}
|
||||
class="message-stream"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<Show when={!props.loading && props.messages.length === 0}>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-content">
|
||||
<h3>Start a conversation</h3>
|
||||
<p>Type a message below or try:</p>
|
||||
<ul>
|
||||
<li><code>/init-project</code></li>
|
||||
<li>Ask about your codebase</li>
|
||||
<li>Attach files with <code>@</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.loading}>
|
||||
<div class="loading-state">
|
||||
<div class="spinner" />
|
||||
<p>Loading messages...</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<For each={props.messages}>
|
||||
{(message) => (
|
||||
<MessageItem message={message} />
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={showScrollButton()}>
|
||||
<button
|
||||
class="scroll-to-bottom"
|
||||
onClick={scrollToBottom}
|
||||
aria-label="Scroll to bottom"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Create Message Item Component
|
||||
|
||||
**src/components/message-item.tsx:**
|
||||
|
||||
```typescript
|
||||
import { For, Show } from "solid-js"
|
||||
import { Message } from "../types/message"
|
||||
import MessagePart from "./message-part"
|
||||
|
||||
interface MessageItemProps {
|
||||
message: Message
|
||||
}
|
||||
|
||||
export default function MessageItem(props: MessageItemProps) {
|
||||
const isUser = () => props.message.type === "user"
|
||||
const timestamp = () => {
|
||||
const date = new Date(props.message.timestamp)
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={`message-item ${isUser() ? "user" : "assistant"}`}>
|
||||
<div class="message-header">
|
||||
<span class="message-sender">
|
||||
{isUser() ? "You" : "Assistant"}
|
||||
</span>
|
||||
<span class="message-timestamp">{timestamp()}</span>
|
||||
</div>
|
||||
|
||||
<div class="message-content">
|
||||
<For each={props.message.parts}>
|
||||
{(part) => <MessagePart part={part} />}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={props.message.status === "error"}>
|
||||
<div class="message-error">
|
||||
⚠ Message failed to send
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Create Message Part Component
|
||||
|
||||
**src/components/message-part.tsx:**
|
||||
|
||||
```typescript
|
||||
import { Show, Match, Switch } from "solid-js"
|
||||
import { MessagePart as MessagePartType } from "../types/message"
|
||||
import ToolCall from "./tool-call"
|
||||
|
||||
interface MessagePartProps {
|
||||
part: MessagePartType
|
||||
}
|
||||
|
||||
export default function MessagePart(props: MessagePartProps) {
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.part.type === "text"}>
|
||||
<div class="message-text">
|
||||
{(props.part as any).text}
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={props.part.type === "tool_call"}>
|
||||
<ToolCall toolCall={props.part as any} />
|
||||
</Match>
|
||||
|
||||
<Match when={props.part.type === "error"}>
|
||||
<div class="message-error-part">
|
||||
⚠ {(props.part as any).message}
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Create Tool Call Component
|
||||
|
||||
**src/components/tool-call.tsx:**
|
||||
|
||||
```typescript
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { ToolCallPart } from "../types/message"
|
||||
|
||||
interface ToolCallProps {
|
||||
toolCall: ToolCallPart
|
||||
}
|
||||
|
||||
export default function ToolCall(props: ToolCallProps) {
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
|
||||
const statusIcon = () => {
|
||||
switch (props.toolCall.status) {
|
||||
case "pending":
|
||||
return "⏳"
|
||||
case "running":
|
||||
return "⏳"
|
||||
case "success":
|
||||
return "✓"
|
||||
case "error":
|
||||
return "✗"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
const statusClass = () => {
|
||||
return `tool-call-status-${props.toolCall.status}`
|
||||
}
|
||||
|
||||
function toggleExpanded() {
|
||||
setExpanded(!expanded())
|
||||
}
|
||||
|
||||
function formatToolSummary() {
|
||||
// Create a brief summary of the tool call
|
||||
const { tool, input } = props.toolCall
|
||||
|
||||
switch (tool) {
|
||||
case "bash":
|
||||
return `bash: ${input.command}`
|
||||
case "edit":
|
||||
return `edit ${input.filePath}`
|
||||
case "read":
|
||||
return `read ${input.filePath}`
|
||||
case "write":
|
||||
return `write ${input.filePath}`
|
||||
default:
|
||||
return `${tool}`
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={`tool-call ${statusClass()}`}>
|
||||
<button
|
||||
class="tool-call-header"
|
||||
onClick={toggleExpanded}
|
||||
aria-expanded={expanded()}
|
||||
>
|
||||
<span class="tool-call-icon">
|
||||
{expanded() ? "▼" : "▶"}
|
||||
</span>
|
||||
<span class="tool-call-summary">
|
||||
{formatToolSummary()}
|
||||
</span>
|
||||
<span class="tool-call-status">
|
||||
{statusIcon()}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Show when={expanded()}>
|
||||
<div class="tool-call-details">
|
||||
<div class="tool-call-section">
|
||||
<h4>Input:</h4>
|
||||
<pre><code>{JSON.stringify(props.toolCall.input, null, 2)}</code></pre>
|
||||
</div>
|
||||
|
||||
<Show when={props.toolCall.status === "success" || props.toolCall.status === "error"}>
|
||||
<div class="tool-call-section">
|
||||
<h4>Output:</h4>
|
||||
<pre><code>{formatToolOutput()}</code></pre>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
function formatToolOutput() {
|
||||
// This will be enhanced in later tasks
|
||||
// For now, just stringify
|
||||
return "Output will be displayed here"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Add Message Store Integration
|
||||
|
||||
**src/stores/sessions.ts updates:**
|
||||
|
||||
```typescript
|
||||
interface Session {
|
||||
// ... existing fields
|
||||
messages: Message[]
|
||||
}
|
||||
|
||||
async function loadMessages(instanceId: string, sessionId: string) {
|
||||
const instance = getInstance(instanceId)
|
||||
if (!instance) return
|
||||
|
||||
try {
|
||||
// Fetch messages from SDK
|
||||
const response = await instance.client.session.getMessages(sessionId)
|
||||
|
||||
// Update session with messages
|
||||
const session = instance.sessions.get(sessionId)
|
||||
if (session) {
|
||||
session.messages = response.messages.map(transformMessage)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load messages:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function transformMessage(apiMessage: any): Message {
|
||||
return {
|
||||
id: apiMessage.id,
|
||||
sessionId: apiMessage.sessionId,
|
||||
type: apiMessage.type,
|
||||
parts: apiMessage.parts || [],
|
||||
timestamp: apiMessage.timestamp || Date.now(),
|
||||
status: "complete",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Update App to Show Messages
|
||||
|
||||
**src/App.tsx updates:**
|
||||
|
||||
```tsx
|
||||
<Show when={instance().activeSessionId !== "logs"}>
|
||||
{() => {
|
||||
const session = instance().sessions.get(instance().activeSessionId!)
|
||||
|
||||
return (
|
||||
<Show when={session} fallback={<div>Session not found</div>}>
|
||||
{(s) => <MessageStream sessionId={s().id} messages={s().messages} loading={false} />}
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
```
|
||||
|
||||
### 8. Add Styling
|
||||
|
||||
**src/components/message-stream.css:**
|
||||
|
||||
```css
|
||||
.message-stream-container {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.message-stream {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.message-item.user {
|
||||
align-self: flex-end;
|
||||
background-color: var(--user-message-bg);
|
||||
}
|
||||
|
||||
.message-item.assistant {
|
||||
align-self: flex-start;
|
||||
background-color: var(--assistant-message-bg);
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message-sender {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.message-timestamp {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.tool-call {
|
||||
margin: 8px 0;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tool-call-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
width: 100%;
|
||||
background-color: var(--secondary-bg);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tool-call-header:hover {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.tool-call-icon {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.tool-call-summary {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tool-call-status {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tool-call-status-success {
|
||||
border-left: 3px solid var(--success-color);
|
||||
}
|
||||
|
||||
.tool-call-status-error {
|
||||
border-left: 3px solid var(--error-color);
|
||||
}
|
||||
|
||||
.tool-call-status-running {
|
||||
border-left: 3px solid var(--warning-color);
|
||||
}
|
||||
|
||||
.tool-call-details {
|
||||
padding: 12px;
|
||||
background-color: var(--code-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tool-call-section h4 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-call-section pre {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
background-color: var(--background);
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tool-call-section code {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.scroll-to-bottom {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
border: none;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.scroll-to-bottom:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.empty-state-content {
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.empty-state-content h3 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-state-content p {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-state-content ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty-state-content li {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state-content code {
|
||||
background-color: var(--code-bg);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border-color);
|
||||
border-top-color: var(--accent-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 9. Add CSS Variables
|
||||
|
||||
**src/index.css updates:**
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Message colors */
|
||||
--user-message-bg: #e3f2fd;
|
||||
--assistant-message-bg: #f5f5f5;
|
||||
|
||||
/* Status colors */
|
||||
--success-color: #4caf50;
|
||||
--error-color: #f44336;
|
||||
--warning-color: #ff9800;
|
||||
|
||||
/* Code colors */
|
||||
--code-bg: #f8f8f8;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--user-message-bg: #1e3a5f;
|
||||
--assistant-message-bg: #2a2a2a;
|
||||
--code-bg: #1a1a1a;
|
||||
}
|
||||
```
|
||||
|
||||
### 10. Load Messages on Session Switch
|
||||
|
||||
**src/hooks/use-session.ts:**
|
||||
|
||||
```typescript
|
||||
import { createEffect } from "solid-js"
|
||||
|
||||
export function useSession(instanceId: string, sessionId: string) {
|
||||
createEffect(() => {
|
||||
// Load messages when session becomes active
|
||||
if (sessionId && sessionId !== "logs") {
|
||||
loadMessages(instanceId, sessionId).catch(console.error)
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Use in App.tsx:**
|
||||
|
||||
```tsx
|
||||
<Show when={session}>
|
||||
{(s) => {
|
||||
useSession(instance().id, s().id)
|
||||
|
||||
return <MessageStream sessionId={s().id} messages={s().messages} loading={false} />
|
||||
}}
|
||||
</Show>
|
||||
```
|
||||
|
||||
### 11. Add Accessibility
|
||||
|
||||
**ARIA attributes:**
|
||||
|
||||
```tsx
|
||||
<div
|
||||
class="message-stream"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
aria-label="Message history"
|
||||
>
|
||||
{/* Messages */}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="message-item"
|
||||
role="article"
|
||||
aria-label={`${isUser() ? "Your" : "Assistant"} message at ${timestamp()}`}
|
||||
>
|
||||
{/* Message content */}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Keyboard navigation:**
|
||||
|
||||
- Messages should be accessible via Tab key
|
||||
- Tool calls can be expanded with Enter/Space
|
||||
- Screen readers announce new messages
|
||||
|
||||
### 12. Handle Long Messages
|
||||
|
||||
**Text wrapping:**
|
||||
|
||||
```css
|
||||
.message-text {
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
```
|
||||
|
||||
**Code blocks (for now, just basic):**
|
||||
|
||||
```css
|
||||
.message-text pre {
|
||||
overflow-x: auto;
|
||||
padding: 8px;
|
||||
background-color: var(--code-bg);
|
||||
border-radius: 4px;
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
**Manual Tests:**
|
||||
|
||||
1. Empty session shows empty state
|
||||
2. Messages load when switching sessions
|
||||
3. User messages appear on right
|
||||
4. Assistant messages appear on left
|
||||
5. Timestamps display correctly
|
||||
6. Tool calls appear inline
|
||||
7. Tool calls expand/collapse on click
|
||||
8. Auto-scroll works for new messages
|
||||
9. Manual scroll up disables auto-scroll
|
||||
10. Scroll to bottom button appears/works
|
||||
11. Long messages wrap correctly
|
||||
12. Multiple messages display properly
|
||||
13. Messages are keyboard accessible
|
||||
|
||||
**Edge Cases:**
|
||||
|
||||
- Session with 1 message
|
||||
- Session with 100+ messages
|
||||
- Messages with very long text
|
||||
- Messages with no parts
|
||||
- Tool calls with large output
|
||||
- Rapid message updates
|
||||
- Switching sessions while loading
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Blocks:** Task 008 (SSE will update these messages in real-time)
|
||||
- **Blocked by:** Task 006 (needs tab structure)
|
||||
|
||||
## Estimated Time
|
||||
|
||||
4-5 hours
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep styling simple for now - markdown rendering comes in Task 012
|
||||
- Tool output formatting will be enhanced in Task 010
|
||||
- Focus on basic text display and structure
|
||||
- Don't optimize for virtual scrolling yet (MVP principle)
|
||||
- Message actions (copy, edit, etc.) come in Task 026
|
||||
- This is the foundation for real-time updates in Task 008
|
||||
23
tsconfig.json
Normal file
23
tsconfig.json
Normal 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
18
tsconfig.node.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user