commit fa77b4e82ef0dac24eee9f5e6ca1177a37b1406e Author: Shantur Rathore Date: Wed Oct 22 22:10:51 2025 +0100 Working messages display diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..878dbe41 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +release/ +.DS_Store +*.log +.vite/ +.electron-vite/ +out/ diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 00000000..275d38be --- /dev/null +++ b/PROGRESS.md @@ -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) diff --git a/README.md b/README.md new file mode 100644 index 00000000..29cfa92a --- /dev/null +++ b/README.md @@ -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. diff --git a/docs/INDEX.md b/docs/INDEX.md new file mode 100644 index 00000000..460e6132 --- /dev/null +++ b/docs/INDEX.md @@ -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._ diff --git a/docs/MVP-PRINCIPLES.md b/docs/MVP-PRINCIPLES.md new file mode 100644 index 00000000..8605164c --- /dev/null +++ b/docs/MVP-PRINCIPLES.md @@ -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 + + {(message) => } + +``` + +**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 diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md new file mode 100644 index 00000000..5c820123 --- /dev/null +++ b/docs/SUMMARY.md @@ -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! 🚀 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..246b5959 --- /dev/null +++ b/docs/architecture.md @@ -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 + 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 diff --git a/docs/build-roadmap.md b/docs/build-roadmap.md new file mode 100644 index 00000000..386a3d2f --- /dev/null +++ b/docs/build-roadmap.md @@ -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 diff --git a/docs/technical-implementation.md b/docs/technical-implementation.md new file mode 100644 index 00000000..e08be617 --- /dev/null +++ b/docs/technical-implementation.md @@ -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 + activeInstanceId: string | null + + // Actions + createInstance(folder: string): Promise + removeInstance(id: string): Promise + 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 + 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 + deleteSession(instanceId: string, sessionId: string): Promise + setActiveSession(instanceId: string, sessionId: string): void + updateSession(instanceId: string, sessionId: string, updates: Partial): 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 // 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 + kill(pid: number): Promise + restart(pid: number, folder: string): Promise +} + +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 + + {(message) => } + + +// 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 + "instance:create": (folder: string) => Promise<{ port: number; pid: number }> + "instance:stop": (pid: number) => Promise + "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/*"] + } + } +} +``` diff --git a/docs/user-interface.md b/docs/user-interface.md new file mode 100644 index 00000000..58a748ec --- /dev/null +++ b/docs/user-interface.md @@ -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..." diff --git a/electron.vite.config.ts b/electron.vite.config.ts new file mode 100644 index 00000000..ded6a215 --- /dev/null +++ b/electron.vite.config.ts @@ -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", + }, + }, +}) diff --git a/electron/main/ipc.ts b/electron/main/ipc.ts new file mode 100644 index 00000000..4c52535b --- /dev/null +++ b/electron/main/ipc.ts @@ -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() + +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()) + }) +} diff --git a/electron/main/main.ts b/electron/main/main.ts new file mode 100644 index 00000000..272f4101 --- /dev/null +++ b/electron/main/main.ts @@ -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() + } +}) diff --git a/electron/main/menu.ts b/electron/main/menu.ts new file mode 100644 index 00000000..6ce07972 --- /dev/null +++ b/electron/main/menu.ts @@ -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) +} diff --git a/electron/main/process-manager.ts b/electron/main/process-manager.ts new file mode 100644 index 00000000..8143cc50 --- /dev/null +++ b/electron/main/process-manager.ts @@ -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() + + async spawn(folder: string): Promise { + 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 { + 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 { + return new Map(this.processes) + } + + async cleanup(): Promise { + 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) +}) diff --git a/electron/preload/index.ts b/electron/preload/index.ts new file mode 100644 index 00000000..732dc1a6 --- /dev/null +++ b/electron/preload/index.ts @@ -0,0 +1,43 @@ +import { contextBridge, ipcRenderer } from "electron" + +export interface ElectronAPI { + selectFolder: () => Promise + createInstance: (folder: string) => Promise<{ port: number; pid: number }> + stopInstance: (pid: number) => Promise + 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 + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..f942e07b --- /dev/null +++ b/package.json @@ -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" + ] + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 00000000..2e7af2b7 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100644 index 00000000..118a52d7 --- /dev/null +++ b/scripts/dev.sh @@ -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 diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 00000000..b800b057 --- /dev/null +++ b/src/App.tsx @@ -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 + 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 ( + +
Session not found
+ + } + > + {(s) => } +
+ ) +} + +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 ( +
+ + + + + {(instance) => ( + <> + 0} + fallback={ +
+
+

No parent session selected

+

Select or create a parent session to begin

+
+
+ } + > + setActiveSession(instance().id, id)} + onClose={(id) => handleCloseSession(instance().id, id)} + onNew={() => handleNewSession(instance().id)} + /> + +
+ +
+

No session selected

+

Select a session to view messages

+
+
+ } + > + +
+ } + > +
+

Server Logs

+

Log viewer will be implemented in Task 013

+
+
+
+ + + )} + + + } + > + + + + + {(instanceId) => } + + + ) +} + +export default App diff --git a/src/components/empty-state.tsx b/src/components/empty-state.tsx new file mode 100644 index 00000000..d01fcad8 --- /dev/null +++ b/src/components/empty-state.tsx @@ -0,0 +1,49 @@ +import { Component } from "solid-js" +import { Folder, Loader2 } from "lucide-solid" + +interface EmptyStateProps { + onSelectFolder: () => void + isLoading?: boolean +} + +const EmptyState: Component = (props) => { + return ( +
+
+
+ +
+ +

Welcome to OpenCode Client

+ +

Select a folder to start coding with AI

+ + + +

+ Keyboard shortcut: {navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"}+N +

+ +
+

Examples: ~/projects/my-app

+

You can have multiple instances of the same folder

+
+
+
+ ) +} + +export default EmptyState diff --git a/src/components/instance-tab.tsx b/src/components/instance-tab.tsx new file mode 100644 index 00000000..21c314a6 --- /dev/null +++ b/src/components/instance-tab.tsx @@ -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 = (props) => { + return ( +
+ +
+ ) +} + +export default InstanceTab diff --git a/src/components/instance-tabs.tsx b/src/components/instance-tabs.tsx new file mode 100644 index 00000000..cd18689f --- /dev/null +++ b/src/components/instance-tabs.tsx @@ -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 + activeInstanceId: string | null + onSelect: (instanceId: string) => void + onClose: (instanceId: string) => void + onNew: () => void +} + +const InstanceTabs: Component = (props) => { + return ( +
+
+ + {([id, instance]) => ( + props.onSelect(id)} + onClose={() => props.onClose(id)} + /> + )} + + +
+
+ ) +} + +export default InstanceTabs diff --git a/src/components/message-item.tsx b/src/components/message-item.tsx new file mode 100644 index 00000000..c4e362e8 --- /dev/null +++ b/src/components/message-item.tsx @@ -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 ( +
+
+ {isUser() ? "You" : "Assistant"} + {timestamp()} +
+ +
+ +
⚠️ {errorMessage()}
+
+ + +
+ Generating... +
+
+ + {(part) => } +
+ + +
⚠ Message failed to send
+
+
+ ) +} diff --git a/src/components/message-part.tsx b/src/components/message-part.tsx new file mode 100644 index 00000000..07e77b71 --- /dev/null +++ b/src/components/message-part.tsx @@ -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 ( + + + +
{props.part.text}
+
+
+ + + + + + +
⚠ {props.part.message}
+
+ + +
+
+ Reasoning +
{props.part.text || ""}
+
+
+
+
+ ) +} diff --git a/src/components/message-stream.tsx b/src/components/message-stream.tsx new file mode 100644 index 00000000..e5710751 --- /dev/null +++ b/src/components/message-stream.tsx @@ -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 + 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 ( +
+
+ +
+
+

Start a conversation

+

Type a message below or try:

+
    +
  • + /init-project +
  • +
  • Ask about your codebase
  • +
  • + Attach files with @ +
  • +
+
+
+
+ + +
+
+

Loading messages...

+
+ + + + {(item) => ( + +
+ 🔧 + Tool Call + {item.data?.tool || "unknown"} +
+ +
+ } + > + +
+ )} + +
+ + + + +
+ ) +} diff --git a/src/components/session-picker.tsx b/src/components/session-picker.tsx new file mode 100644 index 00000000..d7b82858 --- /dev/null +++ b/src/components/session-picker.tsx @@ -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 = (props) => { + const [selectedAgent, setSelectedAgent] = createSignal("") + 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 ( + !open && handleCancel()}> + + +
+ + + OpenCode • {instance()?.folder.split("/").pop()} + + +
+ 0} + fallback={
No previous sessions
} + > +
+

Resume a session ({parentSessions().length}):

+
+ + {(session) => ( + + )} + +
+
+
+ +
+
+
+
+
+ or +
+
+ +
+

Start new session:

+
+ 0} + fallback={
Loading agents...
} + > + +
+ + +
+
+
+ +
+ +
+ +
+ +
+ ) +} + +export default SessionPicker diff --git a/src/components/session-tab.tsx b/src/components/session-tab.tsx new file mode 100644 index 00000000..7239392f --- /dev/null +++ b/src/components/session-tab.tsx @@ -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 = (props) => { + const label = () => { + if (props.special === "logs") return "Logs" + return props.session?.title || "Untitled" + } + + return ( +
+ +
+ ) +} + +export default SessionTab diff --git a/src/components/session-tabs.tsx b/src/components/session-tabs.tsx new file mode 100644 index 00000000..9dafd825 --- /dev/null +++ b/src/components/session-tabs.tsx @@ -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 + activeSessionId: string | null + onSelect: (sessionId: string) => void + onClose: (sessionId: string) => void + onNew: () => void +} + +const SessionTabs: Component = (props) => { + const sessionsList = () => Array.from(props.sessions.entries()) + + return ( +
+
+ + {([id, session]) => ( + props.onSelect(id)} + onClose={session.parentId === null ? () => props.onClose(id) : undefined} + /> + )} + + props.onSelect("logs")} /> + +
+
+ ) +} + +export default SessionTabs diff --git a/src/components/tool-call.tsx b/src/components/tool-call.tsx new file mode 100644 index 00000000..5a895679 --- /dev/null +++ b/src/components/tool-call.tsx @@ -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 ( +
+ + + +
+ Output: + {formatOutputPreview()} +
+
+ + +
+
+

Input:

+
+              {JSON.stringify(props.toolCall?.state?.input || {}, null, 2)}
+            
+
+ + +
+

Output:

+
+                {formatToolOutput()}
+              
+
+
+
+
+
+ ) +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 00000000..b418ccd2 --- /dev/null +++ b/src/index.css @@ -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); + } +} diff --git a/src/lib/keyboard.ts b/src/lib/keyboard.ts new file mode 100644 index 00000000..ed29517f --- /dev/null +++ b/src/lib/keyboard.ts @@ -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) + } + } + }) +} diff --git a/src/lib/sdk-manager.ts b/src/lib/sdk-manager.ts new file mode 100644 index 00000000..0f681830 --- /dev/null +++ b/src/lib/sdk-manager.ts @@ -0,0 +1,32 @@ +import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/client" + +class SDKManager { + private clients = new Map() + + 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() diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 00000000..470e224a --- /dev/null +++ b/src/main.tsx @@ -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(() => , root) diff --git a/src/renderer/index.html b/src/renderer/index.html new file mode 100644 index 00000000..6b17ca75 --- /dev/null +++ b/src/renderer/index.html @@ -0,0 +1,12 @@ + + + + + + OpenCode Client + + +
+ + + diff --git a/src/stores/instances.ts b/src/stores/instances.ts new file mode 100644 index 00000000..2e8cf382 --- /dev/null +++ b/src/stores/instances.ts @@ -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>(new Map()) +const [activeInstanceId, setActiveInstanceId] = createSignal(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) { + 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 { + 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, +} diff --git a/src/stores/sessions.ts b/src/stores/sessions.ts new file mode 100644 index 00000000..fb812896 --- /dev/null +++ b/src/stores/sessions.ts @@ -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>>(new Map()) +const [activeSessionId, setActiveSessionId] = createSignal>(new Map()) +const [activeParentSessionId, setActiveParentSessionId] = createSignal>(new Map()) +const [agents, setAgents] = createSignal>(new Map()) +const [providers, setProviders] = createSignal>(new Map()) + +const [loading, setLoading] = createSignal({ + fetchingSessions: new Map(), + creatingSession: new Map(), + deletingSession: new Map>(), + loadingMessages: new Map>(), +}) + +const [messagesLoaded, setMessagesLoaded] = createSignal>>(new Map()) + +async function fetchSessions(instanceId: string): Promise { + 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() + + 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 { + 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 { + 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 { + 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 { + 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 { + 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() + 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, +} diff --git a/src/stores/ui.ts b/src/stores/ui.ts new file mode 100644 index 00000000..9ae79352 --- /dev/null +++ b/src/stores/ui.ts @@ -0,0 +1,47 @@ +import { createSignal } from "solid-js" + +const [hasInstances, setHasInstances] = createSignal(false) +const [selectedFolder, setSelectedFolder] = createSignal(null) +const [isSelectingFolder, setIsSelectingFolder] = createSignal(false) +const [sessionPickerInstance, setSessionPickerInstance] = createSignal(null) + +const [instanceTabOrder, setInstanceTabOrder] = createSignal([]) +const [sessionTabOrder, setSessionTabOrder] = createSignal>(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, +} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts new file mode 100644 index 00000000..1b6a6a11 --- /dev/null +++ b/src/types/electron.d.ts @@ -0,0 +1,9 @@ +import type { ElectronAPI } from "../../electron/preload/index" + +declare global { + interface Window { + electronAPI: ElectronAPI + } +} + +export {} diff --git a/src/types/instance.ts b/src/types/instance.ts new file mode 100644 index 00000000..7c8b7d3b --- /dev/null +++ b/src/types/instance.ts @@ -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 +} diff --git a/src/types/message.ts b/src/types/message.ts new file mode 100644 index 00000000..1be64000 --- /dev/null +++ b/src/types/message.ts @@ -0,0 +1,8 @@ +export interface Message { + id: string + sessionId: string + type: "user" | "assistant" + parts: any[] + timestamp: number + status: "sending" | "sent" | "streaming" | "complete" | "error" +} diff --git a/src/types/session.ts b/src/types/session.ts new file mode 100644 index 00000000..d81bfc70 --- /dev/null +++ b/src/types/session.ts @@ -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 +} + +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 +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 00000000..171f87ba --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,7 @@ +export default { + content: ["./src/**/*.{ts,tsx}", "./src/renderer/**/*.html"], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/tasks/README.md b/tasks/README.md new file mode 100644 index 00000000..057c6640 --- /dev/null +++ b/tasks/README.md @@ -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 diff --git a/tasks/done/001-project-setup.md b/tasks/done/001-project-setup.md new file mode 100644 index 00000000..537582ec --- /dev/null +++ b/tasks/done/001-project-setup.md @@ -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 diff --git a/tasks/done/002-empty-state-ui.md b/tasks/done/002-empty-state-ui.md new file mode 100644 index 00000000..f4743a16 --- /dev/null +++ b/tasks/done/002-empty-state-ui.md @@ -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 +} +``` + +**Type definitions:** + +```typescript +interface ElectronAPI { + selectFolder: () => Promise +} + +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 diff --git a/tasks/done/003-process-manager.md b/tasks/done/003-process-manager.md new file mode 100644 index 00000000..c2671df5 --- /dev/null +++ b/tasks/done/003-process-manager.md @@ -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 + kill(pid: number): Promise + getStatus(pid: number): "running" | "stopped" | "unknown" + getAllProcesses(): Map +} + +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() +``` + +**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 + getServerStatus: (pid: number) => Promise + + 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 + killServer: (pid: number) => Promise + 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 + 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) diff --git a/tasks/done/004-sdk-integration.md b/tasks/done/004-sdk-integration.md new file mode 100644 index 00000000..0ac579ff --- /dev/null +++ b/tasks/done/004-sdk-integration.md @@ -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 +- 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> + + // Active session per instance + activeSessionId: Map +} +``` + +**Core actions:** + +```typescript +// Fetch all sessions for an instance +async function fetchSessions(instanceId: string): Promise + +// Create new session +async function createSession(instanceId: string, agent: string): Promise + +// Delete session +async function deleteSession(instanceId: string, sessionId: string): Promise + +// 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() + + 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 { + 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 { + 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 { + 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 { + 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(fn: () => Promise, maxRetries = 3, delay = 1000): Promise { + 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 + creatingSession: Map + deletingSession: Map> +} + +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 diff --git a/tasks/done/005-session-picker-modal.md b/tasks/done/005-session-picker-modal.md new file mode 100644 index 00000000..943d28ce --- /dev/null +++ b/tasks/done/005-session-picker-modal.md @@ -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' + + !open && props.onClose()}> + + + + {/* Modal content */} + + + +``` + +**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 + + {(instanceId) => ( + ui.hideSessionPicker()} + onSessionSelect={(id) => handleSessionSelect(instanceId(), id)} + onNewSession={(agent) => handleNewSession(instanceId(), agent)} + /> + )} + +``` + +## 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) diff --git a/tasks/done/006-instance-session-tabs.md b/tasks/done/006-instance-session-tabs.md new file mode 100644 index 00000000..1188a754 --- /dev/null +++ b/tasks/done/006-instance-session-tabs.md @@ -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 + activeInstanceId: string | null + onSelect: (instanceId: string) => void + onClose: (instanceId: string) => void + onNew: () => void +} +``` + +**Structure:** + +```tsx +
+
+ + {([id, instance]) => ( + onSelect(id)} + onClose={() => onClose(id)} + /> + )} + + +
+
+``` + +**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 + + +``` + +**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 + activeSessionId: string | null + onSelect: (sessionId: string) => void + onClose: (sessionId: string) => void + onNew: () => void +} +``` + +**Structure:** + +```tsx +
+
+ + {([id, session]) => ( + onSelect(id)} + onClose={() => onClose(id)} + /> + )} + + onSelect("logs")} /> + +
+
+``` + +**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 + + + +``` + +**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 + + reorderInstanceTabs: (newOrder: string[]) => void + reorderSessionTabs: (instanceId: string, newOrder: string[]) => void +} + +const [instanceTabOrder, setInstanceTabOrder] = createSignal([]) +const [sessionTabOrder, setSessionTabOrder] = createSignal>(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 +
+ 0} fallback={}> + + + + {(instance) => ( + <> + setActiveSession(instance().id, id)} + onClose={(id) => handleCloseSession(instance().id, id)} + onNew={() => handleNewSession(instance().id)} + /> + +
+ {/* Message stream and input will go here in Task 007 */} + + + + +
Session content will appear here (Task 007)
+
+
+ + )} +
+
+
+``` + +### 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 +
+ +
+ +
+ {/* Session tabs */} +
+``` + +**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 diff --git a/tasks/done/007-message-display.md b/tasks/done/007-message-display.md new file mode 100644 index 00000000..00c4599e --- /dev/null +++ b/tasks/done/007-message-display.md @@ -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 ( +
+
+ +
+
+

Start a conversation

+

Type a message below or try:

+
    +
  • /init-project
  • +
  • Ask about your codebase
  • +
  • Attach files with @
  • +
+
+
+
+ + +
+
+

Loading messages...

+
+ + + + {(message) => ( + + )} + +
+ + + + +
+ ) +} +``` + +### 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 ( +
+
+ + {isUser() ? "You" : "Assistant"} + + {timestamp()} +
+ +
+ + {(part) => } + +
+ + +
+ ⚠ Message failed to send +
+
+
+ ) +} +``` + +### 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 ( + + +
+ {(props.part as any).text} +
+
+ + + + + + +
+ ⚠ {(props.part as any).message} +
+
+
+ ) +} +``` + +### 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 ( +
+ + + +
+
+

Input:

+
{JSON.stringify(props.toolCall.input, null, 2)}
+
+ + +
+

Output:

+
{formatToolOutput()}
+
+
+
+
+
+ ) + + 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 + + {() => { + const session = instance().sessions.get(instance().activeSessionId!) + + return ( + Session not found
}> + {(s) => } + + ) + }} + +``` + +### 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 + + {(s) => { + useSession(instance().id, s().id) + + return + }} + +``` + +### 11. Add Accessibility + +**ARIA attributes:** + +```tsx +
+ {/* Messages */} +
+ +
+ {/* Message content */} +
+``` + +**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 diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..a01b6842 --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 00000000..af517920 --- /dev/null +++ b/tsconfig.node.json @@ -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"] +}