diff --git a/CHANGELOG.md b/CHANGELOG.md index a74faf2f..46d005d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,48 @@ All notable changes to Crystal will be documented in this file. +## [0.2.3] - 2025-08-25 + +### Fixed +- **Session completion status updates** - Fixed "Rebase to main" button not updating when a session completed +- **Session archiving performance** - Made session archiving operation much quicker + +## [0.2.2] - 2025-08-23 + +### Added +- **Homebrew install** - Crystal can now be installed with `brew install --cask stravu-crystal` on OSX + +### Changed +- **Performance improvements** - Optimized application performance for faster response times and smoother operation + +## [0.2.1] - 2025-08-21 + +### Added +- **'Auto' model selection** - New automatic model selection option that lets Claude Code choose the best model +- **Sub-agent output visualization** - Enhanced UX for displaying sub-agent task outputs +- **Terminal/Logs separation** - Logs from run scripts now have their own dedicated tab with filter and search +- **Dev mode for debugging** - New developer mode to view raw Claude Code messages in the Messages tab +- **Canary channel publishing** - Automatic canary builds from main branch for early testing + +### Changed +- **Performance improvements** - Various optimizations for better responsiveness +- **Auto-scroll reliability** - More reliable auto-scrolling behavior that only activates when at bottom of output +- **Project panel usage** - Now uses project panel instead of special (main) session +- **Git branch actions labeling** - Renamed "Branch Actions" to "Git Branch Actions" for clarity + +### Fixed +- **Double prompt submission prevention** - Fixed issue where prompts could be submitted twice +- **Commit message display** - Fixed showing commit messages in diff viewer +- **Worktree file references** - Don't show worktree files when using @ to reference files +- **Git status indicators** - Fixed "Behind Only - No unique changes" status marker +- **Revert button backend** - Updated backend to use after_commit_hash for revert button functionality +- **Run script log clearing** - Clear logs on fresh run script execution +- **Canary build process** - Fixed canary build issues + +### Documentation +- **Demo video updated** - Updated demo video to showcase latest features +- **Markdown cleanup** - Cleaned up and reorganized documentation structure + ## [0.2.0] - 2025-08-05 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 4ae23502..f7cd84e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,7 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + # Crystal - Multi-Session Claude Code Manager Created by [Stravu](https://stravu.com/?utm_source=Crystal&utm_medium=OS&utm_campaign=Crystal&utm_id=1) @@ -569,74 +573,194 @@ The react-diff-viewer-continued library uses emotion/styled-components internall 5. **Real-time Updates**: IPC streams session status and terminal output 6. **Session Management**: Users can switch between sessions, continue conversations -## Available Commands +## Development Commands + +### Essential Commands for Development ```bash -# One-time setup (install, build, and rebuild native modules) +# One-time setup (install, build, and rebuild native modules) - REQUIRED FIRST pnpm run setup -# Run as Electron app in development mode +# Development mode - Run Electron app with live reload pnpm electron-dev # Or use the shorthand: pnpm run dev -# Run frontend only (without Electron shell) -pnpm dev +# Build main process only (required after changes to main/ directory) +pnpm run build:main + +# Build frontend only +pnpm run build:frontend + +# Run frontend in browser (without Electron shell) - for UI development +pnpm --filter frontend dev -# Build for production +# Full production build pnpm build +``` + +### Code Quality Commands -# Type checking +```bash +# Type checking across all packages pnpm typecheck -# Linting +# Linting across all packages pnpm lint + +# Type check individual packages +pnpm --filter frontend typecheck +pnpm --filter main typecheck + +# Lint individual packages +pnpm --filter frontend lint +pnpm --filter main lint +``` + +### Testing Commands + +```bash +# Run Playwright tests +pnpm test + +# Run tests with UI +pnpm test:ui + +# Run tests in headed mode (see browser) +pnpm test:headed + +# Main process unit tests +pnpm --filter main test ``` -**Note:** You must run `pnpm run build:main` at least once before running `pnpm electron-dev` to compile the main process. +### Building for Production -### Building Packaged Electron App +```bash +# macOS builds (universal binary - works on both Intel and Apple Silicon) +pnpm build:mac + +# macOS specific architectures +pnpm build:mac:x64 # Intel only +pnpm build:mac:arm64 # Apple Silicon only + +# Linux builds +pnpm build:linux # All Linux formats (deb, AppImage, pacman) +pnpm build:arch # Arch Linux only (pacman) + +# Release builds (with publishing to GitHub) +pnpm release:mac +pnpm release:linux # All Linux formats +pnpm release:arch # Arch Linux only +``` + +### Database and Development Tools ```bash -# Build for macOS -pnpm build:mac # macOS (only works on macOS) +# Rebuild native dependencies (if you get node-pty or better-sqlite3 errors) +pnpm run electron:rebuild + +# Generate third-party license notices +pnpm run generate-notices + +# Inject build information (used by CI) +pnpm run inject-build-info ``` -## Project Structure +**Critical Notes:** +- You MUST run `pnpm run setup` at least once before development +- You MUST run `pnpm run build:main` after changes to main/ directory before running `pnpm electron-dev` +- The main process must be compiled before Electron can start +- Use separate terminals for main process compilation (`pnpm --filter main dev`) and Electron runtime during development +- **For Arch Linux builds**: Install `bsdtar` package before building: `sudo pacman -S bsdtar` (Arch) or `sudo apt-get install bsdtar` (Ubuntu/Debian) + +## Project Architecture and Structure + +### High-Level Architecture + +Crystal is a modular Electron application with clear separation between the main process (Node.js backend) and renderer process (React frontend), connected via secure IPC communication. ``` crystal/ -├── frontend/ # React renderer process +├── frontend/ # React 19 renderer process (Vite + TypeScript) │ ├── src/ -│ │ ├── components/ # React components -│ │ │ ├── Help.tsx # Help dialog -│ │ │ └── ... # Other UI components -│ │ ├── hooks/ # Custom React hooks -│ │ │ └── useSessionView.ts # Session view logic (941 lines) -│ │ ├── stores/ # Zustand state stores -│ │ └── utils/ # Utility functions -├── main/ # Electron main process +│ │ ├── components/ # React UI components +│ │ │ ├── Help.tsx # Comprehensive help dialog +│ │ │ ├── SessionView.tsx # Main session interface +│ │ │ ├── DraggableProjectTreeView.tsx # Project/session tree +│ │ │ ├── DiffViewer.tsx # Git diff visualization +│ │ │ └── CombinedDiffView.tsx # Combined diff viewer +│ │ ├── hooks/ # Custom React hooks +│ │ │ ├── useSessionView.ts # Session view logic (941 lines) +│ │ │ └── useProjects.ts # Project management state +│ │ ├── stores/ # Zustand state management +│ │ │ ├── useSessionStore.ts # Session state +│ │ │ └── useAppStore.ts # Global app state +│ │ └── utils/ # Frontend utilities +│ │ ├── timestampUtils.ts # Timezone-safe timestamp handling +│ │ └── ipcRenderer.ts # IPC communication helpers +│ +├── main/ # Electron main process (Node.js + TypeScript) │ ├── src/ -│ │ ├── index.ts # Main entry point (reduced to 414 lines) -│ │ ├── preload.ts # Preload script -│ │ ├── events.ts # Event handling (359 lines) -│ │ ├── database/ # SQLite database -│ │ ├── ipc/ # IPC handlers (modular) -│ │ │ ├── git.ts # Git operation handlers (843 lines) -│ │ │ ├── session.ts # Session operation handlers (428 lines) -│ │ │ └── ... # Other IPC handlers -│ │ ├── services/ # Business logic services -│ │ │ ├── taskQueue.ts # Bull queue for async tasks -│ │ │ └── ... # Other service modules -│ │ ├── routes/ # API routes -│ │ └── types/ # TypeScript types -│ └── dist/ # Compiled output -├── shared/ # Shared TypeScript types -├── dist-electron/ # Packaged Electron app -├── package.json # Root workspace configuration -└── pnpm-workspace.yaml +│ │ ├── index.ts # Main entry point (414 lines) +│ │ ├── preload.ts # Secure IPC bridge +│ │ ├── events.ts # Event coordination (359 lines) +│ │ │ +│ │ ├── ipc/ # Modular IPC handlers +│ │ │ ├── git.ts # Git operations (843 lines) +│ │ │ ├── session.ts # Session management (428 lines) +│ │ │ ├── project.ts # Project operations +│ │ │ ├── config.ts # Configuration management +│ │ │ └── index.ts # IPC handler registration +│ │ │ +│ │ ├── services/ # Core business logic +│ │ │ ├── sessionManager.ts # Claude Code process management +│ │ │ ├── worktreeManager.ts # Git worktree operations +│ │ │ ├── claudeCodeManager.ts # Claude Code SDK integration +│ │ │ ├── gitDiffManager.ts # Diff generation and parsing +│ │ │ ├── taskQueue.ts # Async task management (Bull) +│ │ │ └── configManager.ts # Application configuration +│ │ │ +│ │ ├── database/ # SQLite database layer +│ │ │ ├── database.ts # Database connection and setup +│ │ │ ├── models.ts # Database models and queries +│ │ │ ├── schema.sql # Database schema +│ │ │ └── migrations/ # Schema migrations +│ │ │ +│ │ └── utils/ # Backend utilities +│ │ ├── timestampUtils.ts # Database timestamp handling +│ │ └── fileUtils.ts # File system operations +│ │ +├── shared/ # Shared TypeScript types +│ ├── types.ts # Core type definitions +│ └── ipcTypes.ts # IPC message types +│ +├── package.json # Root workspace config (pnpm workspace) +├── pnpm-workspace.yaml # Workspace definition +└── dist-electron/ # Built Electron app ``` +### Key Architectural Patterns + +**IPC Communication Pattern:** +- All backend operations are exposed via IPC handlers in `main/src/ipc/` +- Frontend uses typed IPC calls through `utils/ipcRenderer.ts` +- Events flow from main process to renderer for real-time updates + +**Service Layer Architecture:** +- Business logic is encapsulated in service classes in `main/src/services/` +- Services are stateful and manage their own lifecycle +- Database operations are centralized in the database layer + +**State Management:** +- Frontend uses Zustand for reactive state management +- State updates follow targeted update patterns (avoid global refreshes) +- Backend state is the single source of truth + +**Database Schema:** +- SQLite database with tables: sessions, session_outputs, conversation_messages, execution_diffs, prompt_markers, projects +- UTC timestamp storage with frontend timezone conversion +- Migration system for schema evolution + ## User Guide ### Quick Start @@ -713,6 +837,56 @@ In development mode, Crystal automatically captures all frontend console logs an Crystal is an independent project created by [Stravu](https://stravu.com/?utm_source=Crystal&utm_medium=OS&utm_campaign=Crystal&utm_id=1). Claude™ is a trademark of Anthropic, PBC. Crystal is not affiliated with, endorsed by, or sponsored by Anthropic. This tool is designed to work with Claude Code, which must be installed separately. +## Common Development Workflows + +### Adding New Features +1. **Create a git worktree or branch** for your feature development +2. **Understand the architecture**: Features typically span IPC handlers, services, and React components +3. **Update TypeScript types** in `shared/types.ts` if adding new data structures +4. **Add IPC handlers** in `main/src/ipc/` for backend operations +5. **Implement business logic** in appropriate service classes in `main/src/services/` +6. **Create React components** in `frontend/src/components/` for UI +7. **Update state management** in Zustand stores if needed +8. **Run tests** with `pnpm test` to ensure nothing breaks +9. **Test the feature** with `pnpm electron-dev` + +### Debugging Session Issues +- **Check main process logs**: Enable verbose logging in settings +- **Inspect SQLite database**: Located in `~/.crystal/crystal.db` (use DB Browser for SQLite) +- **Review IPC communication**: Add console.logs in IPC handlers and frontend +- **Monitor Claude Code processes**: Sessions spawn separate Claude Code processes via node-pty +- **Frontend debugging logs**: Development mode writes to `crystal-frontend-debug.log` + +### Working with Git Worktrees +- **Worktree creation**: Handled by `worktreeManager.ts` service +- **Branch naming**: Generated by `worktreeNameGenerator.ts` +- **Git operations**: All git commands go through `ipc/git.ts` handlers +- **Cleanup**: Worktrees are automatically cleaned up when sessions are deleted + +### Database Changes +1. **Create migration** in `main/src/database/migrations/` +2. **Update schema.sql** with the new structure +3. **Update TypeScript types** in `shared/types.ts` +4. **Update database queries** in `main/src/database/models.ts` +5. **Test migration** by deleting `~/.crystal/crystal.db` and restarting + +### Performance Considerations +- **Targeted updates**: Never reload entire lists when only one item changes (see State Management Guidelines) +- **Session output handling**: The session output system is fragile - avoid modifications without explicit permission +- **IPC efficiency**: Batch IPC calls when possible, avoid rapid successive calls +- **Database queries**: Use proper indexes and limit result sets for large datasets + +## Important Development Reminders + +- **NEVER modify session output handling** without explicit permission - it's complex and fragile +- **ALWAYS use timestamp utility functions** instead of raw Date parsing for timezone safety +- **PREFER targeted state updates** over global refreshes for better performance +- **RUN `pnpm run build:main`** after any changes to the main process before testing +- **USE the existing service layer** rather than creating new architectural patterns +- **FOLLOW the modular IPC pattern** - keep related handlers together in appropriate files +- **TEST timestamp handling** across different timezones when working with time-based features +- **VALIDATE database operations** with proper error handling and transactions where needed + ## important-instruction-reminders Do what has been asked; nothing more, nothing less. NEVER create files unless they're absolutely necessary for achieving your goal. diff --git a/NOTICES b/NOTICES index 5c83c854..f3ac29b9 100644 --- a/NOTICES +++ b/NOTICES @@ -32,6 +32,7 @@ The following packages are licensed under the MIT license: - @babel/template (7.27.2) - The Babel Team (https://babel.dev/team) - @babel/traverse (7.28.0) - The Babel Team (https://babel.dev/team) - @babel/types (7.28.1) - The Babel Team (https://babel.dev/team) + - @bcoe/v8-coverage (0.2.3) - Charles Samborski (https://demurgos.net) - @braintree/sanitize-url (7.1.1) - @develar/schema-utils (2.6.5) - webpack Contrib (https://github.com/webpack-contrib) - @electron/asar (3.4.1) @@ -49,7 +50,8 @@ The following packages are licensed under the MIT license: - @emotion/unitless (0.10.0) - @emotion/utils (1.4.2) - @emotion/weak-memoize (0.4.0) - - @esbuild/darwin-arm64 (0.25.7) + - @esbuild/linux-x64 (0.21.5) + - @esbuild/linux-x64 (0.25.7) - @eslint-community/regexpp (4.12.1) - Toru Nagashima - @floating-ui/core (1.7.2) - atomiks - @floating-ui/dom (1.7.2) - atomiks @@ -61,6 +63,7 @@ The following packages are licensed under the MIT license: - @ioredis/commands (1.2.0) - Zihua Li (http://zihua.li) - @isaacs/balanced-match (4.0.1) - @isaacs/brace-expansion (5.0.0) + - @istanbuljs/schema (0.1.3) - Corey Farrell - @jridgewell/gen-mapping (0.3.12) - Justin Ridgewell - @jridgewell/resolve-uri (3.1.2) - Justin Ridgewell - @jridgewell/sourcemap-codec (1.5.4) - Justin Ridgewell @@ -69,7 +72,7 @@ The following packages are licensed under the MIT license: - @mermaid-js/parser (0.6.2) - Yokozuna59 - @modelcontextprotocol/sdk (1.16.0) - Anthropic, PBC (https://anthropic.com) - @monaco-editor/loader (1.5.0) - Suren Atoyan - - @msgpackr-extract/msgpackr-extract-darwin-arm64 (3.0.3) - Kris Zyp + - @msgpackr-extract/msgpackr-extract-linux-x64 (3.0.3) - Kris Zyp - @nodelib/fs.scandir (2.1.5) - @nodelib/fs.stat (2.0.5) - @nodelib/fs.walk (1.2.8) @@ -79,10 +82,17 @@ The following packages are licensed under the MIT license: - @radix-ui/primitive (1.1.2) - @radix-ui/rect (1.1.1) - @rolldown/pluginutils (1.0.0-beta.27) - - @rollup/rollup-darwin-arm64 (4.45.1) - Lukas Taegert-Atkinson + - @rollup/rollup-linux-x64-gnu (4.45.1) - Lukas Taegert-Atkinson + - @rollup/rollup-linux-x64-musl (4.45.1) - Lukas Taegert-Atkinson - @sindresorhus/is (4.6.0) - Sindre Sorhus - @szmarczak/http-timer (4.0.6) - Szymon Marczak - @tootallnate/once (2.0.0) - Nathan Rajlich (http://n8.io/) + - @vitest/expect (2.1.9) + - @vitest/pretty-format (2.1.9) + - @vitest/runner (2.1.9) + - @vitest/snapshot (2.1.9) + - @vitest/spy (2.1.9) + - @vitest/utils (2.1.9) - @xmldom/xmldom (0.8.10) - @xterm/xterm (5.5.0) - 7zip-bin (5.2.0) @@ -101,16 +111,13 @@ The following packages are licensed under the MIT license: - ansi-regex (6.1.0) - Sindre Sorhus - ansi-styles (4.3.0) - Sindre Sorhus - ansi-styles (6.2.1) - Sindre Sorhus + - ansi-to-html (0.7.2) - Rob Burns - any-promise (1.3.0) - Kevin Beaty - app-builder-bin (5.0.0-alpha.10) - app-builder-lib (25.1.8) - Vladimir Krivosheev - - archiver (5.3.2) - Chris Talkington - - archiver-utils (2.1.0) - Chris Talkington - - archiver-utils (3.0.4) - Chris Talkington - arg (5.0.2) - Josh Junon - aria-hidden (1.2.6) - Anton Korzunov - - assert-plus (1.0.0) - Mark Cavage - - astral-regex (2.0.0) - Kevin Mårtensson + - assertion-error (2.0.1) - Jake Luer (http://qualiancy.com) - async (3.2.6) - Caolan McMahon - async-exit-hook (2.0.1) - Tapani Moilanen - asynckit (0.4.0) - Alex Indigo @@ -139,6 +146,7 @@ The following packages are licensed under the MIT license: - builder-util-runtime (9.3.1) - Vladimir Krivosheev - bull (4.16.5) - OptimalBits - bytes (3.1.2) - TJ Holowaychuk (http://tjholowaychuk.com) + - cac (6.7.14) - egoist <0x142857@gmail.com> - cacheable-lookup (5.0.4) - Szymon Marczak - cacheable-request (7.0.4) - Luke Childs (http://lukechilds.co.uk) - call-bind-apply-helpers (1.0.2) - Jordan Harband @@ -146,11 +154,13 @@ The following packages are licensed under the MIT license: - callsites (3.1.0) - Sindre Sorhus - camelcase-css (2.0.1) - Steven Vachon (https://www.svachon.com/) - ccount (2.0.1) - Titus Wormer (https://wooorm.com) + - chai (5.2.1) - Jake Luer - chalk (4.1.2) - character-entities (2.0.2) - Titus Wormer (https://wooorm.com) - character-entities-html4 (2.1.0) - Titus Wormer (https://wooorm.com) - character-entities-legacy (3.0.0) - Titus Wormer (https://wooorm.com) - character-reference-invalid (2.0.1) - Titus Wormer (https://wooorm.com) + - check-error (2.1.1) - Jake Luer (http://alogicalparadox.com) - chevrotain-allstar (0.3.1) - TypeFox - chokidar (3.6.0) - Paul Miller (https://paulmillr.com) - chromium-pickle-js (0.2.0) @@ -159,7 +169,6 @@ The following packages are licensed under the MIT license: - clean-stack (2.2.0) - Sindre Sorhus - cli-cursor (3.1.0) - Sindre Sorhus - cli-spinners (2.9.2) - Sindre Sorhus - - cli-truncate (2.1.0) - Sindre Sorhus - clone (1.0.4) - Paul Vorbach (http://paul.vorba.ch/) - clone-response (1.0.3) - Luke Childs (http://lukechilds.co.uk) - clsx (2.1.1) - Luke Edwards @@ -172,7 +181,6 @@ The following packages are licensed under the MIT license: - commander (7.2.0) - TJ Holowaychuk - commander (8.3.0) - TJ Holowaychuk - compare-version (0.1.2) - Kevin Mårtensson - - compress-commons (4.1.2) - Chris Talkington - concat-map (0.0.1) - James Halliday - conf (14.0.0) - Sindre Sorhus - confbox (0.1.8) @@ -184,14 +192,10 @@ The following packages are licensed under the MIT license: - convert-source-map (2.0.0) - Thorsten Lorenz - cookie (0.7.2) - Roman Shtylman - cookie-signature (1.2.2) - TJ Holowaychuk - - core-util-is (1.0.2) - Isaac Z. Schlueter (http://blog.izs.me/) - - core-util-is (1.0.3) - Isaac Z. Schlueter (http://blog.izs.me/) - cors (2.8.5) - Troy Goode (https://github.com/troygoode/) - cose-base (1.0.3) - cose-base (2.2.0) - cosmiconfig (7.1.0) - David Clark - - crc (3.8.0) - Alex Gorbatchev - - crc32-stream (4.0.3) - Chris Talkington - cron-parser (4.9.0) - Harri Siirak - cross-spawn (7.0.6) - André Cruz - cssesc (3.0.0) - Mathias Bynens @@ -206,6 +210,7 @@ The following packages are licensed under the MIT license: - debug (4.4.1) - Josh Junon (https://github.com/qix-) - decode-named-character-reference (1.2.0) - Titus Wormer (https://wooorm.com) - decompress-response (6.0.0) - Sindre Sorhus + - deep-eql (5.0.2) - Jake Luer - deep-extend (0.6.0) - Viacheslav Lotsmanov - deep-is (0.1.4) - Thorsten Lorenz - defaults (1.0.4) - Elijah Insua @@ -222,7 +227,6 @@ The following packages are licensed under the MIT license: - dir-compare (4.2.0) - Liviu Grigorescu - dlv (1.1.3) - Jason Miller (http://jasonformat.com) - dmg-builder (25.1.8) - Vladimir Krivosheev - - dmg-license (1.0.11) - argvminusone - dot-prop (9.0.0) - Sindre Sorhus - dunder-proto (1.0.1) - Jordan Harband - eastasianwidth (0.2.0) - Masaki Komagata @@ -242,15 +246,18 @@ The following packages are licensed under the MIT license: - error-ex (1.3.2) - es-define-property (1.0.1) - Jordan Harband - es-errors (1.3.0) - Jordan Harband + - es-module-lexer (1.7.0) - Guy Bedford - es-object-atoms (1.1.1) - Jordan Harband - es-set-tostringtag (2.1.0) - Jordan Harband - es6-error (4.1.1) - Ben Youngblood + - esbuild (0.21.5) - esbuild (0.25.7) - escalade (3.2.0) - Luke Edwards - escape-html (1.0.3) - escape-string-regexp (4.0.0) - Sindre Sorhus - escape-string-regexp (5.0.0) - Sindre Sorhus - estree-util-is-identifier-name (3.0.0) - Titus Wormer (https://wooorm.com) + - estree-walker (3.0.3) - Rich Harris - etag (1.8.1) - eventsource (3.0.7) - Espen Hovlandsdal - eventsource-parser (3.0.3) - Espen Hovlandsdal @@ -258,7 +265,6 @@ The following packages are licensed under the MIT license: - express-rate-limit (7.5.1) - Nathan Friedly - exsolve (1.0.7) - extend (3.0.2) - Stefan Thomas (http://www.justmoon.net) - - extsprintf (1.4.1) - fast-deep-equal (3.1.3) - Evgeny Poberezkin - fast-glob (3.3.3) - Denis Malinochkin - fast-json-stable-stringify (2.1.0) - James Halliday @@ -282,8 +288,6 @@ The following packages are licensed under the MIT license: - fs-extra (11.3.0) - JP Richardson - fs-extra (8.1.0) - JP Richardson - fs-extra (9.1.0) - JP Richardson - - fsevents (2.3.2) - - fsevents (2.3.3) - function-bind (1.1.2) - Raynos - gensync (1.0.0-beta.2) - Logan Smyth - get-intrinsic (1.3.0) - Jordan Harband @@ -304,6 +308,7 @@ The following packages are licensed under the MIT license: - hasown (2.0.2) - Jordan Harband - hast-util-to-jsx-runtime (2.3.6) - Titus Wormer (https://wooorm.com) - hast-util-whitespace (3.0.0) - Titus Wormer (https://wooorm.com) + - html-escaper (2.0.2) - Andrea Giammarchi - html-url-attributes (3.0.1) - Titus Wormer (https://wooorm.com) - http-errors (2.0.0) - Jonathan Ong (http://jongleberry.com) - http-proxy-agent (5.0.0) - Nathan Rajlich (http://n8.io/) @@ -312,7 +317,6 @@ The following packages are licensed under the MIT license: - https-proxy-agent (5.0.1) - Nathan Rajlich (http://n8.io/) - https-proxy-agent (7.0.6) - Nathan Rajlich (http://n8.io/) - humanize-ms (1.2.1) - dead-horse - - iconv-corefoundation (1.1.7) - argv-minus-one - iconv-lite (0.6.3) - Alexander Shtuchkin - ignore (5.3.2) - kael - ignore (7.0.5) - kael @@ -340,7 +344,6 @@ The following packages are licensed under the MIT license: - is-plain-obj (4.1.0) - Sindre Sorhus - is-promise (4.0.0) - ForbesLindesay - is-unicode-supported (0.1.0) - Sindre Sorhus - - isarray (1.0.0) - Julian Gruber - isbinaryfile (4.0.10) - isbinaryfile (5.0.4) - jiti (1.21.7) @@ -363,7 +366,6 @@ The following packages are licensed under the MIT license: - layout-base (1.0.2) - layout-base (2.0.1) - lazy-val (1.0.5) - Vladimir Krivosheev - - lazystream (1.0.1) - Jonas Pommerening - levn (0.4.1) - George Zahariev - lilconfig (3.1.3) - antonk52 - lines-and-columns (1.2.4) - Brian Donovan @@ -372,20 +374,20 @@ The following packages are licensed under the MIT license: - lodash (4.17.21) - John-David Dalton - lodash-es (4.17.21) - John-David Dalton - lodash.defaults (4.2.0) - John-David Dalton (http://allyoucanleet.com/) - - lodash.difference (4.5.0) - John-David Dalton (http://allyoucanleet.com/) - lodash.escaperegexp (4.1.2) - John-David Dalton (http://allyoucanleet.com/) - - lodash.flatten (4.4.0) - John-David Dalton (http://allyoucanleet.com/) - lodash.isarguments (3.1.0) - John-David Dalton (http://allyoucanleet.com/) - lodash.isequal (4.5.0) - John-David Dalton (http://allyoucanleet.com/) - - lodash.isplainobject (4.0.6) - John-David Dalton (http://allyoucanleet.com/) - lodash.merge (4.6.2) - John-David Dalton - - lodash.union (4.6.0) - John-David Dalton (http://allyoucanleet.com/) - log-symbols (4.1.0) - Sindre Sorhus - longest-streak (3.1.0) - Titus Wormer (https://wooorm.com) - loose-envify (1.4.0) - Andres Suarez + - loupe (3.1.4) - Veselin Todorov - lowercase-keys (2.0.0) - Sindre Sorhus - luxon (3.7.1) - Isaac Cambron - lzma-native (8.0.6) - Anna Henningsen + - magic-string (0.30.17) - Rich Harris + - magicast (0.3.5) + - make-dir (4.0.0) - Sindre Sorhus - markdown-table (3.0.4) - Titus Wormer (https://wooorm.com) - marked (16.1.1) - Christopher Jeffrey - matcher (3.0.0) - Sindre Sorhus @@ -463,7 +465,6 @@ The following packages are licensed under the MIT license: - negotiator (0.6.4) - negotiator (1.0.0) - node-abi (3.75.0) - Lukas Geiger - - node-addon-api (1.7.2) - node-addon-api (3.2.1) - node-addon-api (7.1.1) - node-api-version (0.1.4) - Tim Fish @@ -499,7 +500,9 @@ The following packages are licensed under the MIT license: - path-parse (1.0.7) - Javier Blanco - path-to-regexp (8.2.0) - path-type (4.0.0) - Sindre Sorhus + - pathe (1.1.2) - pathe (2.0.3) + - pathval (2.0.1) - Veselin Todorov - pe-library (0.4.1) - jet - pend (1.2.0) - Andrew Kelley - picomatch (2.3.1) - Jon Schlinkert (https://github.com/jonschlinkert) @@ -514,7 +517,6 @@ The following packages are licensed under the MIT license: - points-on-path (0.2.1) - Preet Shihn - prebuild-install (7.1.3) - Mathias Buus (@mafintosh) - prelude-ls (1.2.1) - George Zahariev - - process-nextick-args (2.0.1) - progress (2.0.3) - TJ Holowaychuk - promise-retry (2.0.1) - IndigoUnited (http://indigounited.com) - prop-types (15.8.1) @@ -536,7 +538,6 @@ The following packages are licensed under the MIT license: - react-refresh (0.17.0) - read-binary-file-arch (1.0.6) - Samuel Maddock - read-cache (1.0.0) - Bogdan Chadkin - - readable-stream (2.3.8) - readable-stream (3.6.2) - readdirp (3.6.0) - Thorsten Lorenz (thlorenz.com) - redis-errors (1.2.0) - Ruben Bridgewater @@ -559,7 +560,6 @@ The following packages are licensed under the MIT license: - roughjs (4.6.6) - Preet Shihn - router (2.2.0) - Douglas Christopher Wilson - run-parallel (1.2.0) - Feross Aboukhadijeh - - safe-buffer (5.1.2) - Feross Aboukhadijeh - safe-buffer (5.2.1) - Feross Aboukhadijeh - safer-buffer (2.1.2) - Nikita Skovoroda - scheduler (0.26.0) @@ -577,18 +577,18 @@ The following packages are licensed under the MIT license: - simple-concat (1.0.1) - Feross Aboukhadijeh - simple-get (4.0.1) - Feross Aboukhadijeh - simple-update-notifier (2.0.0) - alexbrazier - - slice-ansi (3.0.0) - smart-buffer (4.2.0) - Josh Glazebrook - socks (2.8.6) - Josh Glazebrook - socks-proxy-agent (7.0.0) - Nathan Rajlich - source-map-support (0.5.21) - space-separated-tokens (2.0.2) - Titus Wormer (https://wooorm.com) + - stackback (0.0.2) - Roman Shtylman - standard-as-callback (2.1.0) - luin - stat-mode (1.0.0) - Nathan Rajlich (http://n8.io/) - state-local (1.0.7) - Suren Atoyan - statuses (2.0.1) - statuses (2.0.2) - - string_decoder (1.1.1) + - std-env (3.9.0) - string_decoder (1.3.0) - string-width (4.2.3) - Sindre Sorhus - string-width (5.1.2) - Sindre Sorhus @@ -612,8 +612,13 @@ The following packages are licensed under the MIT license: - thenify (3.3.1) - Jonathan Ong (http://jongleberry.com) - thenify-all (1.6.0) - Jonathan Ong (http://jongleberry.com) - tiny-typed-emitter (2.1.0) - Zurab Benashvili + - tinybench (2.9.0) + - tinyexec (0.3.2) - James Garbutt (https://github.com/43081j) - tinyexec (1.0.1) - James Garbutt (https://github.com/43081j) - tinyglobby (0.2.14) - Superchupu + - tinypool (1.1.1) + - tinyrainbow (1.2.0) + - tinyspy (3.0.2) - tmp (0.2.3) - KARASZI István (http://raszi.hu/) - tmp-promise (3.0.3) - Benjamin Gruenbaum and Collaborators. - to-regex-range (5.0.1) - Jon Schlinkert (https://github.com/jonschlinkert) @@ -642,7 +647,6 @@ The following packages are licensed under the MIT license: - uuid (11.1.0) - uuid (8.3.2) - vary (1.1.2) - Douglas Christopher Wilson - - verror (1.10.1) - vfile (6.0.3) - Titus Wormer (https://wooorm.com) - vfile-message (4.0.2) - Titus Wormer (https://wooorm.com) - vscode-jsonrpc (8.2.0) - Microsoft Corporation @@ -652,7 +656,9 @@ The following packages are licensed under the MIT license: - vscode-languageserver-types (3.17.5) - Microsoft Corporation - vscode-uri (3.0.8) - Microsoft - wcwidth (1.0.1) - Tim Oxley + - web-streams-polyfill (3.3.3) - Mattias Buelens - when-exit (2.1.4) + - why-is-node-running (2.3.0) - Mathias Buus (@mafintosh) - word-wrap (1.2.5) - Jon Schlinkert (https://github.com/jonschlinkert) - wrap-ansi (7.0.0) - Sindre Sorhus - wrap-ansi (8.1.0) - Sindre Sorhus @@ -660,7 +666,6 @@ The following packages are licensed under the MIT license: - yargs (17.7.2) - yauzl (2.10.0) - Josh Wolfe - yocto-queue (0.1.0) - Sindre Sorhus - - zip-stream (4.1.1) - Chris Talkington - zod (3.25.76) - Colin McDonnell - zwitch (2.0.4) - Titus Wormer (https://wooorm.com) @@ -778,10 +783,12 @@ The following packages are licensed under the ISC license: - semver (7.7.2) - GitHub Inc. - set-blocking (2.0.0) - Ben Coe - setprototypeof (1.2.0) - Wes Todd + - siginfo (2.0.0) - Emil Bay - signal-exit (3.0.7) - Ben Coe - signal-exit (4.1.0) - Ben Coe - ssri (9.0.1) - GitHub Inc. - tar (6.2.1) - GitHub Inc. + - test-exclude (7.0.1) - Ben Coe - unique-filename (2.0.1) - GitHub Inc. - unique-slug (3.0.0) - GitHub Inc. - which (2.0.2) - Isaac Z. Schlueter (http://blog.izs.me) @@ -827,21 +834,20 @@ The following packages are licensed under the Apache-2.0 license: - @humanwhocodes/module-importer (1.0.1) - Nicholas C. Zaks - @humanwhocodes/retry (0.3.1) - Nicholas C. Zaks - @humanwhocodes/retry (0.4.3) - Nicholas C. Zaks - - @img/sharp-darwin-arm64 (0.33.5) - Lovell Fuller + - @img/sharp-linux-x64 (0.33.5) - Lovell Fuller - @malept/cross-spawn-promise (2.0.0) - Mark Lee - chevrotain (11.0.3) - Shahar Soel - class-variance-authority (0.7.1) - Joe Bell (https://joebell.co.uk) - cluster-key-slot (1.1.2) - Mike Diarmid - - crc-32 (1.2.2) - sheetjs - denque (2.1.0) - Invertase - detect-libc (2.0.4) - Lovell Fuller - didyoumean (1.2.2) - Dave Porter - ejs (3.1.10) - Matthew Eernisse (http://fleegix.org) + - expect-type (1.2.2) - exponential-backoff (3.1.2) - Sami Sayegh - filelist (1.0.4) - Matthew Eernisse (http://fleegix.org) - jake (10.9.2) - Matthew Eernisse (http://fleegix.org) - openai (5.10.1) - OpenAI - - readdir-glob (1.1.3) - Yann Armelin - rxjs (7.8.2) - Ben Lesh - sumchecker (3.0.1) - Mark Lee - ts-interface-checker (0.1.13) - Dmitry S, Grist Labs @@ -1471,6 +1477,134 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -------------------------------------------------------------------------------- +Package: istanbul-lib-coverage +Version: 3.2.2 +Author: Krishnan Anantheswaran +Homepage: https://istanbul.js.org/ + +Copyright 2012-2015 Yahoo! Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the Yahoo! Inc. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL YAHOO! INC. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +Package: istanbul-lib-report +Version: 3.0.1 +Author: Krishnan Anantheswaran +Homepage: https://istanbul.js.org/ + +Copyright 2012-2015 Yahoo! Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the Yahoo! Inc. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL YAHOO! INC. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +Package: istanbul-lib-source-maps +Version: 5.0.6 +Author: Krishnan Anantheswaran +Homepage: https://istanbul.js.org/ + +Copyright 2015 Yahoo! Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the Yahoo! Inc. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL YAHOO! INC. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +Package: istanbul-reports +Version: 3.1.7 +Author: Krishnan Anantheswaran +Homepage: https://istanbul.js.org/ + +Copyright 2012-2015 Yahoo! Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the Yahoo! Inc. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL YAHOO! INC. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + Package: joi Version: 17.13.3 Repository: git://github.com/hapijs/joi @@ -1737,6 +1871,7 @@ The following packages are licensed under the BSD-2-Clause license: - @electron/osx-sign (1.3.1) - electron - dotenv (16.6.1) - dotenv-expand (11.0.7) - motdotla + - entities (2.2.0) - Felix Boehm - espree (10.4.0) - Nicholas C. Zakas - esrecurse (4.3.0) - estraverse (5.3.0) @@ -3212,7 +3347,7 @@ Creative Commons may be contacted at creativecommons.org. ## LGPL-3.0-or-later LICENSE ================================================================================ -Package: @img/sharp-libvips-darwin-arm64 +Package: @img/sharp-libvips-linux-x64 Version: 1.0.4 Author: Lovell Fuller Homepage: https://sharp.pixelplumbing.com @@ -3540,7 +3675,7 @@ For more information, please refer to ================================================================================ Package: Crystal -Version: 0.1.16 +Version: 0.2.3 Author: [object Object] License: MIT diff --git a/README.md b/README.md index ca0e90db..6cb87b34 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,44 @@ When everything looks good: - Open the DMG file and drag Crystal to your Applications folder - On first launch, you may need to right-click and select "Open" due to macOS security settings +- **Linux**: Download from the [latest release](https://github.com/stravu/crystal/releases/latest) + - **Debian/Ubuntu**: `Crystal-{version}-linux-x64.deb` - Install with `sudo dpkg -i Crystal-{version}-linux-x64.deb` + - **Arch Linux**: `Crystal-{version}-linux-x64.pkg.tar.xz` - Install with `sudo pacman -U Crystal-{version}-linux-x64.pkg.tar.xz` + - **Universal**: `Crystal-{version}-linux-x64.AppImage` (works on any Linux distribution) - Make executable and run directly + +### Arch Linux Installation + +For Arch Linux users, you can install Crystal using the pre-built pacman package: + +```bash +# Download and install the package +wget https://github.com/stravu/crystal/releases/latest/download/Crystal-{version}-linux-x64.pkg.tar.xz +sudo pacman -U Crystal-{version}-linux-x64.pkg.tar.xz +``` + +Alternatively, for AUR maintainers, see `docs/PKGBUILD.example` for a template PKGBUILD. + +### Homebrew +```bash +brew install --cask stravu-crystal +``` +- **Linux**: Download from the [latest release](https://github.com/stravu/crystal/releases/latest) + - **Debian/Ubuntu**: `Crystal-{version}-linux-x64.deb` - Install with `sudo dpkg -i Crystal-{version}-linux-x64.deb` + - **Arch Linux**: `stravu-crystal-{version}-linux-x64.pkg.tar.xz` - Install with `sudo pacman -U stravu-crystal-{version}-linux-x64.pkg.tar.xz` + - **Universal**: `Crystal-{version}-linux-x64.AppImage` (works on any Linux distribution) - Make executable and run directly + +### Arch Linux Installation + +For Arch Linux users, you can install Crystal using the pre-built pacman package: + +```bash +# Download and install the package +wget https://github.com/stravu/crystal/releases/latest/download/stravu-crystal-{version}-linux-x64.pkg.tar.xz +sudo pacman -U stravu-crystal-{version}-linux-x64.pkg.tar.xz +``` + +Alternatively, for AUR maintainers, see `docs/PKGBUILD.example` for a template PKGBUILD. + ## Building from Source @@ -101,8 +139,26 @@ pnpm run electron-dev ```bash # Build for macOS pnpm build:mac + +# Build for Linux (all formats: deb, AppImage, pacman) +pnpm build:linux + +# Build for Arch Linux only (pacman format) +pnpm build:arch ``` +### Build Dependencies + +- **Arch Linux builds**: Requires `bsdtar` package + ```bash + # On Arch Linux + sudo pacman -S bsdtar + + + # On Ubuntu/Debian (for cross-platform builds) + sudo apt-get install bsdtar + ``` + ## 🤝 Contributing @@ -120,7 +176,7 @@ pnpm run setup && pnpm run build:main && CRYSTAL_DIR=~/.crystal_test pnpm electr This ensures: - Your development Crystal instance uses `~/.crystal_test` for its data -- Your main Crystal instance continues using `~/.crystal` +- Your main Crystal instance continues using `~/.crystal` - Worktrees won't conflict between the two instances - You can safely test changes without affecting your primary Crystal setup diff --git a/docs/PKGBUILD.example b/docs/PKGBUILD.example new file mode 100644 index 00000000..950208de --- /dev/null +++ b/docs/PKGBUILD.example @@ -0,0 +1,44 @@ +# Maintainer: Your Name +pkgname=stravu-crystal-bin +pkgver=0.2.0 +pkgrel=1 +pkgdesc="Claude Code Commander for managing multiple Claude Code instances" +arch=('x86_64') +url="https://github.com/stravu/crystal" +license=('MIT') +depends=('gtk3' 'libnotify' 'nss' 'libxss' 'libxtst' 'xdg-utils' 'at-spi2-core' 'util-linux-libs') +provides=('stravu-crystal') +conflicts=('stravu-crystal') +source=("https://github.com/stravu/crystal/releases/download/v${pkgver}/stravu-crystal-${pkgver}-linux-x64.pkg.tar.xz") +sha256sums=('SKIP') # Update with actual checksum + +package() { + # Extract the pre-built electron-builder pacman package + bsdtar -xf "${srcdir}/stravu-crystal-${pkgver}-linux-x64.pkg.tar.xz" -C "${pkgdir}/" +} + +# Alternative implementation using AppImage (if pacman target fails): +# source=("https://github.com/stravu/crystal/releases/download/v${pkgver}/stravu-crystal-${pkgver}-linux-x64.AppImage") +# +# package() { +# # Install AppImage +# install -Dm755 "${srcdir}/stravu-crystal-${pkgver}-linux-x64.AppImage" "${pkgdir}/opt/stravu-crystal/stravu-crystal.AppImage" +# +# # Create launcher script +# install -Dm755 /dev/stdin "${pkgdir}/usr/bin/stravu-crystal" < { + fetchConfig(); + }, [fetchConfig]); + + // CRITICAL PERFORMANCE FIX: Very aggressive cleanup to prevent V8 array iteration issues + useEffect(() => { + // Run cleanup every 30 seconds to prevent array buildup that causes CPU spikes + const cleanupInterval = setInterval(() => { + const store = useSessionStore.getState(); + // Always cleanup when we have multiple sessions to prevent memory issues + if (store.sessions.length > 0) { + console.log('[Performance] Running aggressive periodic session cleanup'); + store.cleanupInactiveSessions(); + } + }, 30 * 1000); // 30 seconds - much more frequent to prevent V8 optimization failures + + // Immediate cleanup when switching sessions + const handleSessionSwitch = () => { + // Immediate cleanup to free memory right away + const store = useSessionStore.getState(); + if (store.sessions.length > 0) { + console.log('[Performance] Immediate cleanup on session switch'); + store.cleanupInactiveSessions(); + } + }; + + window.addEventListener('session-switched', handleSessionSwitch); + + // Also cleanup on visibility change to free memory when app is in background + const handleVisibilityChange = () => { + if (document.hidden) { + const store = useSessionStore.getState(); + if (store.sessions.length > 0) { + console.log('[Performance] Cleanup on app background'); + store.cleanupInactiveSessions(); + } + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + clearInterval(cleanupInterval); + window.removeEventListener('session-switched', handleSessionSwitch); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, []); // Add keyboard shortcut for prompt history useEffect(() => { diff --git a/frontend/src/components/ArchiveProgress.tsx b/frontend/src/components/ArchiveProgress.tsx new file mode 100644 index 00000000..05ce37bb --- /dev/null +++ b/frontend/src/components/ArchiveProgress.tsx @@ -0,0 +1,196 @@ +import { useState, useEffect } from 'react'; +import { Loader2, Archive, CheckCircle, AlertCircle } from 'lucide-react'; + +interface ArchiveTask { + sessionId: string; + sessionName: string; + worktreeName: string; + projectName: string; + status: 'pending' | 'queued' | 'removing-worktree' | 'cleaning-artifacts' | 'completed' | 'failed'; + startTime: string; + endTime?: string; + error?: string; +} + +interface ArchiveProgressData { + tasks: ArchiveTask[]; + activeCount: number; + totalCount: number; +} + +export function ArchiveProgress() { + const [progress, setProgress] = useState(null); + const [isExpanded, setIsExpanded] = useState(false); + + useEffect(() => { + // Initial load + loadProgress(); + + // Listen for progress updates + const handleProgress = (_event: any, data: ArchiveProgressData) => { + setProgress(data); + // Auto-expand when there are active tasks + if (data.activeCount > 0 && !isExpanded) { + setIsExpanded(true); + } + }; + + window.electron?.on('archive:progress', handleProgress); + + // Poll for initial state in case we missed events + const interval = setInterval(loadProgress, 2000); + + return () => { + window.electron?.off('archive:progress', handleProgress); + clearInterval(interval); + }; + }, [isExpanded]); + + const loadProgress = async () => { + if (!window.electron) return; + try { + const response = await window.electron.invoke('archive:get-progress'); + if (response.success) { + setProgress(response.data); + } + } catch (error) { + console.error('Failed to load archive progress:', error); + } + }; + + if (!progress || progress.totalCount === 0) { + return null; + } + + const getStatusIcon = (status: ArchiveTask['status']) => { + switch (status) { + case 'completed': + return ; + case 'failed': + return ; + case 'queued': + return ; + default: + return ; + } + }; + + const getStatusText = (status: ArchiveTask['status']) => { + switch (status) { + case 'queued': + return 'Queued (waiting for other archives to complete)...'; + case 'pending': + return 'Preparing...'; + case 'removing-worktree': + return 'Removing worktree (this may take a while)...'; + case 'cleaning-artifacts': + return 'Cleaning artifacts...'; + case 'completed': + return 'Completed'; + case 'failed': + return 'Failed'; + default: + return status; + } + }; + + const formatElapsedTime = (startTime: string, endTime?: string) => { + const start = new Date(startTime).getTime(); + const end = endTime ? new Date(endTime).getTime() : Date.now(); + const elapsed = Math.floor((end - start) / 1000); + + if (elapsed < 60) { + return `${elapsed}s`; + } + const minutes = Math.floor(elapsed / 60); + const seconds = elapsed % 60; + return `${minutes}m ${seconds}s`; + }; + + return ( +
+ + + {isExpanded && ( +
+ {progress.tasks.length === 0 ? ( +
+ No archive tasks +
+ ) : ( +
+ {progress.tasks.map((task) => ( +
+
+
+ {getStatusIcon(task.status)} +
+
+ {task.sessionName} +
+
+ {task.projectName} / {task.worktreeName} +
+
+
+ + {formatElapsedTime(task.startTime, task.endTime)} + +
+
+ {getStatusText(task.status)} +
+ {task.error && ( +
+ {task.error} +
+ )} + {task.status === 'removing-worktree' && ( +
+ ⚠️ Worktree removal can take several minutes for large repositories +
+ )} +
+ ))} +
+ )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/CreateSessionDialog.tsx b/frontend/src/components/CreateSessionDialog.tsx index 277e28a3..92af2ca6 100644 --- a/frontend/src/components/CreateSessionDialog.tsx +++ b/frontend/src/components/CreateSessionDialog.tsx @@ -25,7 +25,7 @@ export function CreateSessionDialog({ isOpen, onClose, projectName, projectId }: worktreeTemplate: '', count: 1, permissionMode: 'ignore', - model: 'claude-sonnet-4-20250514' // Default to Sonnet + model: 'auto' // Default to auto (Claude Code's default selection) }); const [isSubmitting, setIsSubmitting] = useState(false); const [worktreeError, setWorktreeError] = useState(null); @@ -296,68 +296,89 @@ export function CreateSessionDialog({ isOpen, onClose, projectName, projectId }: -
+
setFormData({ ...formData, model: 'claude-sonnet-4-20250514' })} + onClick={() => setFormData({ ...formData, model: 'auto' })} >
- - Sonnet + + Auto + Default +
+ {formData.model === 'auto' && ( +
+ )} + + + setFormData({ ...formData, model: 'sonnet' })} + > +
+ + Sonnet Balanced
- {formData.model === 'claude-sonnet-4-20250514' && ( + {formData.model === 'sonnet' && (
)} setFormData({ ...formData, model: 'claude-opus-4-20250514' })} + onClick={() => setFormData({ ...formData, model: 'opus' })} >
- - Opus + + Opus Maximum
- {formData.model === 'claude-opus-4-20250514' && ( + {formData.model === 'opus' && (
)} setFormData({ ...formData, model: 'claude-3-5-haiku-20241022' })} + onClick={() => setFormData({ ...formData, model: 'haiku' })} >
- - Haiku + + Haiku Fast
- {formData.model === 'claude-3-5-haiku-20241022' && ( + {formData.model === 'haiku' && (
)}

+ {formData.model === 'auto' && 'Uses Claude Code\'s default model selection'} {formData.model?.includes('opus') && 'Best for complex architecture and challenging problems'} {formData.model?.includes('haiku') && 'Fast and cost-effective for simple tasks'} {formData.model?.includes('sonnet') && 'Excellent balance of speed and capability for most tasks'} diff --git a/frontend/src/components/DraggableProjectTreeView.tsx b/frontend/src/components/DraggableProjectTreeView.tsx index 44769912..157ec032 100644 --- a/frontend/src/components/DraggableProjectTreeView.tsx +++ b/frontend/src/components/DraggableProjectTreeView.tsx @@ -88,9 +88,6 @@ export function DraggableProjectTreeView() { overFolderId: null }); const dragCounter = useRef(0); - - // Count of sessions currently loading git status - const gitStatusLoadingCount = useSessionStore((state) => state.gitStatusLoading.size); // Performance monitoring - track render count const renderCountRef = useRef(0); @@ -105,8 +102,7 @@ export function DraggableProjectTreeView() { if (process.env.NODE_ENV === 'development' && timeSinceLastRender < 100) { console.warn('[DraggableProjectTreeView] Rapid re-render detected:', { renderCount: renderCountRef.current, - timeSinceLastRender, - gitStatusLoadingCount + timeSinceLastRender }); } @@ -115,27 +111,21 @@ export function DraggableProjectTreeView() { // Create debounced save function const saveUIState = useCallback( - debounce(async (projectIds: number[], folderIds: string[], skipDuringGitLoading: boolean) => { - // Skip saving during heavy git status loading to avoid performance issues - if (skipDuringGitLoading && gitStatusLoadingCount > 5) { - return; - } - + debounce(async (projectIds: number[], folderIds: string[]) => { try { await window.electronAPI?.uiState?.saveExpanded(projectIds, folderIds); } catch (error) { console.error('[DraggableProjectTreeView] Failed to save UI state:', error); } }, 500), - [gitStatusLoadingCount] + [] ); // Save UI state whenever expanded state changes useEffect(() => { const projectIds = Array.from(expandedProjects); const folderIds = Array.from(expandedFolders); - // Pass true to skip saving during heavy git loading - saveUIState(projectIds, folderIds, true); + saveUIState(projectIds, folderIds); }, [expandedProjects, expandedFolders, saveUIState]); // Ensure paths are expanded when active session changes (for auto-selection) @@ -775,7 +765,7 @@ export function DraggableProjectTreeView() { setRefreshingProjects(prev => new Set([...prev, project.id])); try { - // Refresh git status for all sessions in this project + // Start git status refresh for all sessions in this project (non-blocking) const response = await window.electronAPI.invoke('projects:refresh-git-status', project.id); if (!response.success) { @@ -784,7 +774,30 @@ export function DraggableProjectTreeView() { // Log summary only if there were sessions to refresh if (response.data.count > 0) { - console.log(`[GitStatus] Refreshed ${response.data.count} sessions in ${project.name}`); + if (response.data.backgroundRefresh) { + console.log(`[GitStatus] Started background refresh for ${response.data.count} sessions in ${project.name}`); + } else { + console.log(`[GitStatus] Refreshed ${response.data.count} sessions in ${project.name}`); + } + } + + // For background refresh, keep the spinner for a bit to show something is happening + if (response.data.backgroundRefresh) { + // Remove spinner after a short delay to indicate background process started + setTimeout(() => { + setRefreshingProjects(prev => { + const newSet = new Set(prev); + newSet.delete(project.id); + return newSet; + }); + }, 1500); // Show spinner for 1.5 seconds + } else { + // Remove immediately if not background + setRefreshingProjects(prev => { + const newSet = new Set(prev); + newSet.delete(project.id); + return newSet; + }); } } catch (error: any) { console.error('Failed to refresh git status:', error); @@ -792,8 +805,7 @@ export function DraggableProjectTreeView() { title: 'Failed to refresh git status', error: error.message || 'Unknown error occurred' }); - } finally { - // Remove from refreshing set + // Remove from refreshing set on error setRefreshingProjects(prev => { const newSet = new Set(prev); newSet.delete(project.id); diff --git a/frontend/src/components/ExecutionList.tsx b/frontend/src/components/ExecutionList.tsx index 83042d00..fd8eaa93 100644 --- a/frontend/src/components/ExecutionList.tsx +++ b/frontend/src/components/ExecutionList.tsx @@ -130,7 +130,7 @@ const ExecutionList: React.FC = memo(({ {isUncommitted ? ( Uncommitted changes ) : ( - {truncateMessage(execution.prompt_text || `Commit ${execution.execution_sequence}`)} + {truncateMessage(execution.commit_message || execution.prompt_text || `Commit ${execution.execution_sequence}`)} )}

{isUncommitted && ( diff --git a/frontend/src/components/ProjectView.tsx b/frontend/src/components/ProjectView.tsx index e92add92..222ab76b 100644 --- a/frontend/src/components/ProjectView.tsx +++ b/frontend/src/components/ProjectView.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect, useCallback } from 'react'; +import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { ProjectDashboard } from './ProjectDashboard'; import { FileEditor } from './FileEditor'; import { SessionInputWithImages } from './session/SessionInputWithImages'; @@ -12,6 +12,7 @@ import { useSessionStore } from '../stores/sessionStore'; import { Session } from '../types/session'; import { useSessionView } from '../hooks/useSessionView'; import { cn } from '../utils/cn'; +import { createVisibilityAwareInterval } from '../utils/performanceUtils'; import { BarChart3, Eye, FolderTree, Terminal as TerminalIcon } from 'lucide-react'; import '@xterm/xterm/css/xterm.css'; @@ -279,13 +280,16 @@ export const ProjectView: React.FC = ({ getMainRepoSession(); }, [projectId, viewMode]); - // Subscribe to session updates + // Subscribe to session updates - optimized to check for actual changes useEffect(() => { if (!mainRepoSessionId) return; + let previousSession = useSessionStore.getState().sessions.find(s => s.id === mainRepoSessionId); const unsubscribe = useSessionStore.subscribe((state) => { const session = state.sessions.find(s => s.id === mainRepoSessionId); - if (session) { + // Only update if session actually changed + if (session && session !== previousSession) { + previousSession = session; setMainRepoSession(session); } }); @@ -311,13 +315,18 @@ export const ProjectView: React.FC = ({ // Output loading is now handled by useSessionView hook - // Subscribe to script output for this session + // Subscribe to script output for this session - optimized to check for actual changes useEffect(() => { if (!mainRepoSessionId) return; + let previousOutput = useSessionStore.getState().terminalOutput[mainRepoSessionId]; const unsubscribe = useSessionStore.subscribe((state) => { const sessionOutput = state.terminalOutput[mainRepoSessionId] || []; - setScriptOutput(sessionOutput); + // Only update if output actually changed + if (sessionOutput !== previousOutput) { + previousOutput = sessionOutput; + setScriptOutput(sessionOutput); + } }); // Get initial state @@ -329,11 +338,29 @@ export const ProjectView: React.FC = ({ // Session output updates are now handled by useSessionView hook + // Performance: Memoize terminal output join + const fullScriptOutputMemo = useMemo(() => { + if (!scriptOutput || scriptOutput.length === 0) return ''; + + // For very large arrays, join in chunks + const CHUNK_SIZE = 500; + if (scriptOutput.length > CHUNK_SIZE) { + let result = ''; + for (let i = 0; i < scriptOutput.length; i += CHUNK_SIZE) { + const chunk = scriptOutput.slice(i, Math.min(i + CHUNK_SIZE, scriptOutput.length)); + result += chunk.join(''); + } + return result; + } + + return scriptOutput.join(''); + }, [scriptOutput]); + // Write script output to terminal useEffect(() => { if (!terminalInstance.current || !mainRepoSessionId) return; - const fullScriptOutput = scriptOutput.join(''); + const fullScriptOutput = fullScriptOutputMemo; // Handle case where output was cleared if (fullScriptOutput.length === 0 && lastProcessedOutputLength.current > 0) { @@ -348,13 +375,13 @@ export const ProjectView: React.FC = ({ terminalInstance.current.write(newOutput); lastProcessedOutputLength.current = fullScriptOutput.length; } - }, [scriptOutput, mainRepoSessionId]); + }, [fullScriptOutputMemo, mainRepoSessionId]); // Output terminal updates are now handled by useSessionView hook // Elapsed time tracking is now handled by useSessionView hook - // Check Stravu connection status + // Check Stravu connection status with visibility-aware updates useEffect(() => { const checkStravuConnection = async () => { try { @@ -365,8 +392,14 @@ export const ProjectView: React.FC = ({ } }; checkStravuConnection(); - const interval = setInterval(checkStravuConnection, 30000); - return () => clearInterval(interval); + + // Use visibility-aware interval to reduce background processing + const cleanup = createVisibilityAwareInterval( + checkStravuConnection, + 30000, // 30 seconds when visible + 120000 // 2 minutes when not visible + ); + return cleanup; }, []); // formatElapsedTime is now provided by useSessionView hook diff --git a/frontend/src/components/PromptNavigation.tsx b/frontend/src/components/PromptNavigation.tsx index aacd4ebb..a8d70d69 100644 --- a/frontend/src/components/PromptNavigation.tsx +++ b/frontend/src/components/PromptNavigation.tsx @@ -2,8 +2,8 @@ import { useEffect, useState } from 'react'; import { formatDistanceToNow } from '../utils/formatters'; import { formatDuration, getTimeDifference, isValidTimestamp, parseTimestamp } from '../utils/timestampUtils'; import { API } from '../utils/api'; -import { useSessionStore } from '../stores/sessionStore'; import { PromptDetailModal } from './PromptDetailModal'; +import type { Session } from '../types/session'; interface PromptMarker { id: number; @@ -25,7 +25,36 @@ export function PromptNavigation({ sessionId, onNavigateToPrompt }: PromptNaviga const [isLoading, setIsLoading] = useState(false); const [selectedPromptId, setSelectedPromptId] = useState(null); const [modalPrompt, setModalPrompt] = useState<{ prompt: PromptMarker; index: number } | null>(null); - const activeSession = useSessionStore((state) => state.sessions.find(s => s.id === sessionId)); + const [activeSession, setActiveSession] = useState(undefined); + + // Fetch session data when sessionId changes + useEffect(() => { + if (!sessionId) return; + + const fetchSession = async () => { + try { + const response = await window.electronAPI.invoke('sessions:get', sessionId); + if (response.success && response.session) { + setActiveSession(response.session); + } + } catch (error) { + console.error('Error fetching session:', error); + } + }; + + fetchSession(); + + // Listen for session updates + const unsubscribe = window.electronAPI?.events?.onSessionUpdated?.((updatedSession: Session) => { + if (updatedSession.id === sessionId) { + setActiveSession(updatedSession); + } + }); + + return () => { + unsubscribe?.(); + }; + }, [sessionId]); const calculateDuration = (currentPrompt: PromptMarker, currentIndex: number): string => { try { diff --git a/frontend/src/components/SessionListItem.tsx b/frontend/src/components/SessionListItem.tsx index 50da858e..f73ac4c0 100644 --- a/frontend/src/components/SessionListItem.tsx +++ b/frontend/src/components/SessionListItem.tsx @@ -30,31 +30,12 @@ export const SessionListItem = memo(function SessionListItem({ session, isNested const [gitStatus, setGitStatus] = useState(session.gitStatus); const { menuState, openMenu, closeMenu, isMenuOpen } = useContextMenu(); const [showArchiveConfirm, setShowArchiveConfirm] = useState(false); + const [gitStatusLoading, setGitStatusLoading] = useState(false); - // Selective subscription for git status loading state - const gitStatusLoading = useSessionStore((state) => state.gitStatusLoading.has(session.id)); - - // Subscribe to session status updates specifically for this session - useEffect(() => { - const unsubscribe = useSessionStore.subscribe((state, prevState) => { - // Check if this session's status changed - const currentSession = state.sessions.find(s => s.id === session.id) || - (state.activeMainRepoSession?.id === session.id ? state.activeMainRepoSession : null); - - const previousSession = prevState.sessions.find(s => s.id === session.id) || - (prevState.activeMainRepoSession?.id === session.id ? prevState.activeMainRepoSession : null); - - // Compare store status with prop status - - // Force component update if status changed - if (currentSession && previousSession && currentSession.status !== previousSession.status) { - // Status changed - component will re-render due to prop change - } - }); - - return unsubscribe; - }, [session.id, session.status]); + // Performance optimization: Remove unnecessary subscription + // The component already receives the session as a prop, which will cause re-render when it changes + // No need for an additional store subscription that runs for every session item on every store change useEffect(() => { // Check if this session's project has a run script @@ -121,16 +102,28 @@ export const SessionListItem = memo(function SessionListItem({ session, isNested }, [session.id]); useEffect(() => { - // Fetch Git status for this session + // Fetch Git status for this session (non-blocking) const fetchGitStatus = async () => { try { - // Don't set loading state here anymore - it's handled by backend events - const response = await window.electronAPI.invoke('sessions:get-git-status', session.id); - if (response.success && response.gitStatus) { - setGitStatus(response.gitStatus); + setGitStatusLoading(true); + // Use non-blocking fetch (true as second parameter) + const response = await window.electronAPI.invoke('sessions:get-git-status', session.id, true); + if (response.success) { + // If we got cached status, use it immediately + if (response.gitStatus) { + setGitStatus(response.gitStatus); + } + // Loading indicator will be cleared when the background refresh completes + // via the git-status-updated event + if (!response.backgroundRefresh) { + setGitStatusLoading(false); + } + } else { + setGitStatusLoading(false); } } catch (error) { console.error('Error fetching git status:', error); + setGitStatusLoading(false); } }; @@ -146,6 +139,7 @@ export const SessionListItem = memo(function SessionListItem({ session, isNested unsubscribeGitStatus = window.electronAPI.events.onGitStatusUpdated((data) => { if (data.sessionId === session.id) { setGitStatus(data.gitStatus); + setGitStatusLoading(false); } }); } diff --git a/frontend/src/components/SessionView.tsx b/frontend/src/components/SessionView.tsx index ce49dd85..1484fc62 100644 --- a/frontend/src/components/SessionView.tsx +++ b/frontend/src/components/SessionView.tsx @@ -18,23 +18,26 @@ import { RichOutputWithSidebar } from './session/RichOutputWithSidebar'; import { RichOutputSettings } from './session/RichOutputView'; import { RichOutputSettingsPanel } from './session/RichOutputSettingsPanel'; import { LogView } from './session/LogView'; +import { MessagesView } from './session/MessagesView'; export const SessionView = memo(() => { - const activeSessionId = useSessionStore((state) => state.activeSessionId); - const sessions = useSessionStore((state) => state.sessions); - const activeMainRepoSession = useSessionStore((state) => state.activeMainRepoSession); const { activeView, activeProjectId } = useNavigationStore(); const [projectData, setProjectData] = useState(null); const [isProjectLoading, setIsProjectLoading] = useState(false); const [isMergingProject, setIsMergingProject] = useState(false); const [sessionProject, setSessionProject] = useState(null); - // Define activeSession early so it can be used in effects - const activeSession = activeSessionId - ? (activeMainRepoSession && activeMainRepoSession.id === activeSessionId - ? activeMainRepoSession - : sessions.find(s => s.id === activeSessionId)) - : undefined; + // Get active session by subscribing directly to store state + // This ensures the component re-renders when git status or other session properties update + const activeSession = useSessionStore((state) => { + if (!state.activeSessionId) return undefined; + // Check main repo session first + if (state.activeMainRepoSession && state.activeMainRepoSession.id === state.activeSessionId) { + return state.activeMainRepoSession; + } + // Otherwise look in regular sessions + return state.sessions.find(session => session.id === state.activeSessionId); + }); // Load project data for active session @@ -224,22 +227,26 @@ export const SessionView = memo(() => { {hook.isLoadingOutput && hook.viewMode !== 'richOutput' && (
Loading output...
)} -
- -
-
- -
+ {hook.viewMode === 'richOutput' && ( +
+ +
+ )} + {hook.viewMode === 'changes' && ( +
+ +
+ )}
@@ -293,12 +300,21 @@ export const SessionView = memo(() => { )}
-
- -
-
- -
+ {hook.viewMode === 'logs' && ( +
+ +
+ )} + {hook.viewMode === 'editor' && ( +
+ +
+ )} + {hook.viewMode === 'messages' && ( +
+ +
+ )}
diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx index eb82300d..4c611216 100644 --- a/frontend/src/components/Settings.tsx +++ b/frontend/src/components/Settings.tsx @@ -4,6 +4,7 @@ import { StravuConnection } from './StravuConnection'; import { useNotifications } from '../hooks/useNotifications'; import { API } from '../utils/api'; import type { AppConfig } from '../types/config'; +import { useConfigStore } from '../stores/configStore'; import { Shield, ShieldOff, @@ -36,6 +37,7 @@ export function Settings({ isOpen, onClose }: SettingsProps) { const [claudeExecutablePath, setClaudeExecutablePath] = useState(''); const [defaultPermissionMode, setDefaultPermissionMode] = useState<'approve' | 'ignore'>('ignore'); const [autoCheckUpdates, setAutoCheckUpdates] = useState(true); + const [devMode, setDevMode] = useState(false); const [notificationSettings, setNotificationSettings] = useState({ enabled: true, playSound: true, @@ -48,6 +50,7 @@ export function Settings({ isOpen, onClose }: SettingsProps) { const [activeTab, setActiveTab] = useState<'general' | 'notifications' | 'stravu'>('general'); const { updateSettings } = useNotifications(); const { theme, toggleTheme } = useTheme(); + const { fetchConfig: refreshConfigStore } = useConfigStore(); useEffect(() => { if (isOpen) { @@ -67,6 +70,7 @@ export function Settings({ isOpen, onClose }: SettingsProps) { setClaudeExecutablePath(data.claudeExecutablePath || ''); setDefaultPermissionMode(data.defaultPermissionMode || 'ignore'); setAutoCheckUpdates(data.autoCheckUpdates !== false); // Default to true + setDevMode(data.devMode || false); // Load notification settings if (data.notifications) { @@ -92,6 +96,7 @@ export function Settings({ isOpen, onClose }: SettingsProps) { claudeExecutablePath, defaultPermissionMode, autoCheckUpdates, + devMode, notifications: notificationSettings }); @@ -104,6 +109,10 @@ export function Settings({ isOpen, onClose }: SettingsProps) { // Refresh config from server await fetchConfig(); + + // Also refresh the global config store + await refreshConfigStore(); + onClose(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to update configuration'); @@ -349,6 +358,17 @@ export function Settings({ isOpen, onClose }: SettingsProps) {

Shows detailed logs for session creation and Claude Code execution. Useful for debugging issues.

+ +
+ setDevMode(e.target.checked)} + /> +

+ Adds a "Messages" tab to each session showing raw JSON responses from Claude Code. Useful for debugging and development. +

+
- {/* Version display at bottom */} - {version && ( -
-
- v{version}{worktreeName && ` • ${worktreeName}`}{gitCommit && ` • ${gitCommit}`} + {/* Bottom section - always visible */} +
+ {/* Archive progress indicator above version */} + + + {/* Version display at bottom */} + {version && ( +
+
+ v{version}{worktreeName && ` • ${worktreeName}`}{gitCommit && ` • ${gitCommit}`} +
-
- )} + )} +
setIsSettingsOpen(false)} /> diff --git a/frontend/src/components/StatusIndicator.tsx b/frontend/src/components/StatusIndicator.tsx index 9c8f395a..fde4aabf 100644 --- a/frontend/src/components/StatusIndicator.tsx +++ b/frontend/src/components/StatusIndicator.tsx @@ -5,7 +5,6 @@ import { isDocumentVisible } from '../utils/performanceUtils'; import { Badge } from './ui/Badge'; import { StatusDot } from './ui/StatusDot'; import { cn } from '../utils/cn'; -import { useSessionStore } from '../stores/sessionStore'; interface StatusIndicatorProps { session: Session; @@ -22,14 +21,8 @@ export const StatusIndicator = React.memo(({ }: StatusIndicatorProps) => { const [animationsEnabled, setAnimationsEnabled] = useState(isDocumentVisible()); - // Get the current session status from the store - this is the source of truth - const storeSession = useSessionStore((state) => - state.sessions.find(s => s.id === session.id) || - (state.activeMainRepoSession?.id === session.id ? state.activeMainRepoSession : null) - ); - - // Use store session status if available, otherwise fall back to prop - const currentStatus = storeSession?.status || session.status; + // Use the session status from the prop - parent component manages the session state + const currentStatus = session.status; useEffect(() => { const handleVisibilityChange = () => { diff --git a/frontend/src/components/session/MessagesView.tsx b/frontend/src/components/session/MessagesView.tsx new file mode 100644 index 00000000..9de96013 --- /dev/null +++ b/frontend/src/components/session/MessagesView.tsx @@ -0,0 +1,385 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { API } from '../../utils/api'; +import { cn } from '../../utils/cn'; +import { ChevronRight, ChevronDown, Copy, Check, Terminal, FileText } from 'lucide-react'; + +interface MessagesViewProps { + sessionId: string; +} + +interface JSONMessage { + type: 'json'; + data: string; + timestamp: string; +} + +interface SessionInfo { + type: 'session_info'; + initial_prompt: string; + claude_command: string; + worktree_path: string; + model: string; + permission_mode: string; + timestamp: string; +} + +export const MessagesView: React.FC = ({ sessionId }) => { + const [messages, setMessages] = useState([]); + const [sessionInfo, setSessionInfo] = useState(null); + const [expandedMessages, setExpandedMessages] = useState>(new Set()); + const [copiedIndex, setCopiedIndex] = useState(null); + const [showSessionInfo, setShowSessionInfo] = useState(true); + const messagesEndRef = useRef(null); + const containerRef = useRef(null); + const autoScrollRef = useRef(true); + + // Load messages for the session + useEffect(() => { + const loadMessages = async () => { + try { + const response = await API.sessions.getJsonMessages(sessionId); + if (response.success && response.data) { + // Filter out session_info messages and handle them separately + const regularMessages: JSONMessage[] = []; + let foundSessionInfo: SessionInfo | null = null; + + response.data.forEach((msg: any) => { + try { + // Try to parse the message data to check its type + let msgData: any; + if (typeof msg === 'string') { + try { + msgData = JSON.parse(msg); + } catch { + // If it's a string but not valid JSON, treat as regular message + regularMessages.push({ + type: 'json' as const, + data: msg, + timestamp: new Date().toISOString() + }); + return; + } + } else { + msgData = msg; + } + + // Check if this is a session_info message + if (msgData && msgData.type === 'session_info') { + foundSessionInfo = msgData as SessionInfo; + } else { + // Regular JSON message + regularMessages.push({ + type: 'json' as const, + data: typeof msg === 'string' ? msg : JSON.stringify(msg), + timestamp: msg.timestamp || new Date().toISOString() + }); + } + } catch (error) { + console.error('Error processing message:', error, msg); + // If there's any error, treat it as a regular message + regularMessages.push({ + type: 'json' as const, + data: typeof msg === 'string' ? msg : JSON.stringify(msg), + timestamp: msg.timestamp || new Date().toISOString() + }); + } + }); + + setSessionInfo(foundSessionInfo); + setMessages(regularMessages); + } + } catch (error) { + console.error('Failed to load messages:', error); + } + }; + + loadMessages(); + }, [sessionId]); + + // Subscribe to new messages + useEffect(() => { + const handleSessionOutput = (data: any) => { + if (data.sessionId === sessionId && data.type === 'json') { + try { + // Check if this is a session_info message + let parsedData: any; + if (typeof data.data === 'string') { + try { + parsedData = JSON.parse(data.data); + } catch { + // If it's not valid JSON, treat as regular message + setMessages(prev => [...prev, { + type: 'json', + data: data.data, + timestamp: new Date().toISOString() + }]); + return; + } + } else { + parsedData = data.data; + } + + if (parsedData && parsedData.type === 'session_info') { + setSessionInfo(parsedData as SessionInfo); + } else { + setMessages(prev => [...prev, { + type: 'json', + data: typeof data.data === 'string' ? data.data : JSON.stringify(data.data), + timestamp: new Date().toISOString() + }]); + + // Auto-scroll to bottom if enabled + if (autoScrollRef.current) { + setTimeout(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, 100); + } + } + } catch (error) { + console.error('Error handling session output:', error, data); + // On error, just add as regular message + setMessages(prev => [...prev, { + type: 'json', + data: typeof data.data === 'string' ? data.data : JSON.stringify(data.data), + timestamp: new Date().toISOString() + }]); + } + } + }; + + window.electron?.on('session:output', handleSessionOutput); + return () => { + window.electron?.off('session:output', handleSessionOutput); + }; + }, [sessionId]); + + // Handle scroll to detect if user is at bottom + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const handleScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = container; + const isAtBottom = scrollHeight - scrollTop - clientHeight < 100; + autoScrollRef.current = isAtBottom; + }; + + container.addEventListener('scroll', handleScroll); + return () => container.removeEventListener('scroll', handleScroll); + }, []); + + const toggleMessage = (index: number) => { + setExpandedMessages(prev => { + const newSet = new Set(prev); + if (newSet.has(index)) { + newSet.delete(index); + } else { + newSet.add(index); + } + return newSet; + }); + }; + + const copyToClipboard = async (text: string, index: number) => { + try { + await navigator.clipboard.writeText(text); + setCopiedIndex(index); + setTimeout(() => setCopiedIndex(null), 2000); + } catch (error) { + console.error('Failed to copy:', error); + } + }; + + const formatJSON = (jsonString: string): string => { + try { + const parsed = JSON.parse(jsonString); + return JSON.stringify(parsed, null, 2); + } catch { + return jsonString; + } + }; + + const getMessagePreview = (jsonString: string): string => { + try { + const parsed = JSON.parse(jsonString); + if (parsed.type) { + return `${parsed.type}${parsed.role ? ` (${parsed.role})` : ''}`; + } + return 'JSON Message'; + } catch { + return 'Invalid JSON'; + } + }; + + if (messages.length === 0 && !sessionInfo) { + return ( +
+
+
No JSON messages yet
+
+ JSON messages from Claude Code will appear here +
+
+
+ ); + } + + return ( +
+
+ {/* Session Info Card */} + {sessionInfo && ( +
+
setShowSessionInfo(!showSessionInfo)} + > +
+ + + + Session Information + + + {new Date(sessionInfo.timestamp).toLocaleTimeString()} + +
+ +
+ + {showSessionInfo && ( +
+
+ {/* User Prompt */} +
+
+ + User Prompt +
+
+ {sessionInfo.initial_prompt} +
+
+ + {/* Claude Command */} +
+
+ + Claude Command +
+
+ {sessionInfo.claude_command} +
+
+ + {/* Additional Info */} +
+
+ Worktree Path: +
+ {sessionInfo.worktree_path} +
+
+
+ Model: +
+ {sessionInfo.model} +
+
+
+
+
+ )} +
+ )} + + {/* Regular Messages */} + {messages.map((message, index) => { + const isExpanded = expandedMessages.has(index); + const preview = getMessagePreview(message.data); + const formatted = formatJSON(message.data); + + return ( +
+
toggleMessage(index)} + > +
+ + {preview} + + {new Date(message.timestamp).toLocaleTimeString()} + +
+ +
+ + {isExpanded && ( +
+
+                    {formatted}
+                  
+
+ )} +
+ ); + })} +
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/session/RichOutputView.tsx b/frontend/src/components/session/RichOutputView.tsx index cf7ae663..6832f8f6 100644 --- a/frontend/src/components/session/RichOutputView.tsx +++ b/frontend/src/components/session/RichOutputView.tsx @@ -16,6 +16,8 @@ interface RawMessage { name?: string; input?: any; tool_use_id?: string; + parent_tool_use_id?: string; // For sub-agent tool calls + session_id?: string; // Sub-agent session ID [key: string]: any; } @@ -49,6 +51,10 @@ interface ToolCall { input?: any; result?: ToolResult; status: 'pending' | 'success' | 'error'; + isSubAgent?: boolean; // Is this a Task sub-agent call + subAgentType?: string; // Type of sub-agent (e.g., 'test-runner') + parentToolId?: string; // Parent Task tool call ID + childToolCalls?: ToolCall[]; // Child tool calls for Task agents } @@ -102,6 +108,7 @@ export const RichOutputView = React.forwardRef<{ scrollToPrompt: (promptIndex: n const isLoadingRef = useRef(false); const userMessageRefs = useRef>(new Map()); const wasAtBottomRef = useRef(false); + const loadMessagesRef = useRef<(() => Promise) | null>(null); // Save local settings to localStorage when they change useEffect(() => { @@ -177,22 +184,79 @@ export const RichOutputView = React.forwardRef<{ scrollToPrompt: (promptIndex: n const transformMessages = (rawMessages: RawMessage[]): ConversationMessage[] => { const transformed: ConversationMessage[] = []; - // First pass: Build tool result map + // Performance optimization: Use traditional for loops instead of forEach for large arrays + // First pass: Build tool result map and identify sub-agent relationships const toolResults = new Map(); - rawMessages.forEach(msg => { + const parentToolMap = new Map(); // Map tool ID to parent tool ID + + // Identify all tool calls and their parent relationships first + for (let i = 0; i < rawMessages.length; i++) { + const msg = rawMessages[i]; + // Check for parent_tool_use_id to identify sub-agent tool calls + if (msg.parent_tool_use_id && msg.message?.content && Array.isArray(msg.message.content)) { + const content = msg.message.content; + for (let j = 0; j < content.length; j++) { + const block = content[j]; + if (block.type === 'tool_use' && block.id) { + parentToolMap.set(block.id, msg.parent_tool_use_id!); + } + } + } + if (msg.type === 'user' && msg.message?.content && Array.isArray(msg.message.content)) { - msg.message.content.forEach((block: any) => { + const content = msg.message.content; + for (let j = 0; j < content.length; j++) { + const block = content[j]; if (block.type === 'tool_result' && block.tool_use_id) { toolResults.set(block.tool_use_id, { content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content), isError: block.is_error || false }); } - }); + } } - }); + } - // Second pass: Build conversation messages + // Second pass: Build all tool calls to prepare for hierarchy + const allToolCalls = new Map(); + for (let i = 0; i < rawMessages.length; i++) { + const msg = rawMessages[i]; + if (msg.type === 'assistant' && msg.message?.content && Array.isArray(msg.message.content)) { + const content = msg.message.content; + for (let j = 0; j < content.length; j++) { + const block = content[j]; + if (block.type === 'tool_use') { + const isTaskAgent = block.name === 'Task'; + const toolCall: ToolCall = { + id: block.id, + name: block.name, + input: block.input, + status: toolResults.has(block.id) ? 'success' : 'pending', + result: toolResults.get(block.id), + isSubAgent: isTaskAgent, + subAgentType: isTaskAgent ? block.input?.subagent_type : undefined, + parentToolId: parentToolMap.get(block.id), + childToolCalls: [] + }; + allToolCalls.set(block.id, toolCall); + } + } + } + } + + // Build parent-child relationships + const toolCallsArray = Array.from(allToolCalls.values()); + for (let i = 0; i < toolCallsArray.length; i++) { + const toolCall = toolCallsArray[i]; + if (toolCall.parentToolId) { + const parentTool = allToolCalls.get(toolCall.parentToolId); + if (parentTool && parentTool.childToolCalls) { + parentTool.childToolCalls.push(toolCall); + } + } + } + + // Third pass: Build conversation messages for (let i = 0; i < rawMessages.length; i++) { const msg = rawMessages[i]; @@ -202,8 +266,18 @@ export const RichOutputView = React.forwardRef<{ scrollToPrompt: (promptIndex: n let hasOnlyText = true; if (msg.message?.content && Array.isArray(msg.message.content)) { - hasToolResult = msg.message.content.some((block: any) => block.type === 'tool_result'); - hasOnlyText = msg.message.content.every((block: any) => block.type === 'text'); + // Performance optimization: Use for loops instead of array methods + const content = msg.message.content; + for (let j = 0; j < content.length; j++) { + if (content[j].type === 'tool_result') { + hasToolResult = true; + hasOnlyText = false; + break; + } + if (content[j].type !== 'text') { + hasOnlyText = false; + } + } } // Only show real user prompts (text-only messages without tool results) @@ -234,8 +308,10 @@ export const RichOutputView = React.forwardRef<{ scrollToPrompt: (promptIndex: n if (msg.text && typeof msg.text === 'string') { segments.push({ type: 'text', content: msg.text.trim() }); } else if (msg.message?.content && Array.isArray(msg.message.content)) { - // Process each content block - msg.message.content.forEach((block: any) => { + // Process each content block - use for loop for better performance + const content = msg.message.content; + for (let j = 0; j < content.length; j++) { + const block = content[j]; if (block.type === 'text' && block.text?.trim()) { segments.push({ type: 'text', content: block.text.trim() }); } else if (block.type === 'thinking') { @@ -244,16 +320,13 @@ export const RichOutputView = React.forwardRef<{ scrollToPrompt: (promptIndex: n segments.push({ type: 'thinking', content: thinkingContent.trim() }); } } else if (block.type === 'tool_use') { - const toolCall: ToolCall = { - id: block.id, - name: block.name, - input: block.input, - status: toolResults.has(block.id) ? 'success' : 'pending', - result: toolResults.get(block.id) - }; - segments.push({ type: 'tool_call', tool: toolCall }); + const toolCall = allToolCalls.get(block.id); + // Only add top-level tools (those without parents) + if (toolCall && !toolCall.parentToolId) { + segments.push({ type: 'tool_call', tool: toolCall }); + } } - }); + } } else { // Fallback for other formats const textContent = extractTextContent(msg); @@ -465,6 +538,11 @@ export const RichOutputView = React.forwardRef<{ scrollToPrompt: (promptIndex: n } }, [sessionId]); + // Store loadMessages in ref to avoid dependency cycles + useEffect(() => { + loadMessagesRef.current = loadMessages; + }, [loadMessages]); + // Listen for real-time output updates - debounced to prevent performance issues useEffect(() => { let debounceTimer: NodeJS.Timeout; @@ -474,7 +552,7 @@ export const RichOutputView = React.forwardRef<{ scrollToPrompt: (promptIndex: n // Debounce message reloading to prevent excessive re-renders clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { - loadMessages(); + loadMessagesRef.current?.(); }, 500); // Wait 500ms after last event } }; @@ -485,7 +563,7 @@ export const RichOutputView = React.forwardRef<{ scrollToPrompt: (promptIndex: n clearTimeout(debounceTimer); window.removeEventListener('session-output-available', handleOutputAvailable as any); }; - }, [sessionId, loadMessages]); + }, [sessionId]); // Only depend on sessionId, not loadMessages // Initial load useEffect(() => { @@ -590,21 +668,47 @@ export const RichOutputView = React.forwardRef<{ scrollToPrompt: (promptIndex: n // Render a tool call segment - const renderToolCall = (tool: ToolCall) => { + const renderToolCall = (tool: ToolCall, depth: number = 0) => { const isExpanded = !settings.collapseTools || expandedTools.has(tool.id); + const isTaskAgent = tool.isSubAgent && tool.name === 'Task'; + const hasChildTools = tool.childToolCalls && tool.childToolCalls.length > 0; + + // Different styling for Task sub-agents + const bgColor = isTaskAgent + ? 'bg-interactive/10' + : depth > 0 + ? 'bg-surface-tertiary/30' + : 'bg-surface-tertiary/50'; + + const borderColor = isTaskAgent + ? 'border-interactive/30' + : 'border-border-primary/50'; return ( -
+
0 ? 'ml-4' : ''}`}> @@ -619,9 +723,28 @@ export const RichOutputView = React.forwardRef<{ scrollToPrompt: (promptIndex: n
)} + {/* Child tool calls for Task agents */} + {hasChildTools && ( +
+
+ + + + Sub-agent Actions: +
+
+ {tool.childToolCalls!.map((childTool, idx) => ( +
+ {renderToolCall(childTool, depth + 1)} +
+ ))} +
+
+ )} + {/* Tool Result */} {tool.result && ( -
+
{tool.result.isError ? 'Error:' : 'Result:'}
@@ -632,7 +755,7 @@ export const RichOutputView = React.forwardRef<{ scrollToPrompt: (promptIndex: n )} {/* Pending state */} - {tool.status === 'pending' && ( + {tool.status === 'pending' && !hasChildTools && (
Waiting for result...
)}
@@ -694,6 +817,34 @@ export const RichOutputView = React.forwardRef<{ scrollToPrompt: (promptIndex: n
); + case 'Task': + return ( +
+ {input.description && ( +
+ Task: + {input.description} +
+ )} + {input.subagent_type && ( +
+ Agent Type: + {input.subagent_type} +
+ )} + {input.prompt && ( +
+ + View Prompt + +
+ {input.prompt} +
+
+ )} +
+ ); + case 'TodoWrite': return (
@@ -722,7 +873,7 @@ export const RichOutputView = React.forwardRef<{ scrollToPrompt: (promptIndex: n }; // Format tool result for display - const formatToolResult = (_toolName: string, result: string): React.ReactNode => { + const formatToolResult = (toolName: string, result: string): React.ReactNode => { if (!result) { return
No result
; } @@ -731,6 +882,23 @@ export const RichOutputView = React.forwardRef<{ scrollToPrompt: (promptIndex: n // Check if result is JSON const parsed = JSON.parse(result); + // Handle Task tool results (array with text objects) + if (toolName === 'Task' && Array.isArray(parsed)) { + // Extract text content from Task results + const textContent = parsed + .filter(item => item.type === 'text' && item.text) + .map(item => item.text) + .join('\n\n'); + + if (textContent) { + return ( +
+ {textContent} +
+ ); + } + } + // Handle image reads if (Array.isArray(parsed) && parsed[0]?.type === 'image') { return ( diff --git a/frontend/src/components/session/SessionInput.tsx b/frontend/src/components/session/SessionInput.tsx index 33640066..209c5164 100644 --- a/frontend/src/components/session/SessionInput.tsx +++ b/frontend/src/components/session/SessionInput.tsx @@ -35,7 +35,8 @@ export const SessionInput: React.FC = ({ setUltrathink, handleToggleAutoCommit, }) => { - const [selectedModel, setSelectedModel] = useState(activeSession.model || 'claude-sonnet-4-20250514'); + const [selectedModel, setSelectedModel] = useState(activeSession.model || 'auto'); + const [isSubmitting, setIsSubmitting] = useState(false); useEffect(() => { // Update selected model when switching to a different session @@ -44,30 +45,57 @@ export const SessionInput: React.FC = ({ model: activeSession.model, name: activeSession.name }); - setSelectedModel(activeSession.model || 'claude-sonnet-4-20250514'); + setSelectedModel(activeSession.model || 'auto'); }, [activeSession.id]); // Only reset when session ID changes, not when model updates - const onKeyDown = (e: React.KeyboardEvent) => { + const onKeyDown = async (e: React.KeyboardEvent) => { const shouldSend = e.key === 'Enter' && (e.metaKey || e.ctrlKey); if (shouldSend) { e.preventDefault(); - if (viewMode === 'terminal' && !activeSession.isRunning && activeSession.status !== 'waiting') { - handleTerminalCommand(); - } else if (activeSession.status === 'waiting') { - handleSendInput(); - } else { - handleContinueConversation(selectedModel); + + // Prevent duplicate submissions + if (isSubmitting) { + console.log('[SessionInput] Ignoring duplicate submission attempt'); + return; + } + + setIsSubmitting(true); + + try { + if (viewMode === 'terminal' && !activeSession.isRunning && activeSession.status !== 'waiting') { + await handleTerminalCommand(); + } else if (activeSession.status === 'waiting') { + await handleSendInput(); + } else { + await handleContinueConversation(selectedModel); + } + } finally { + // Reset submission state after a short delay to prevent rapid resubmissions + setTimeout(() => setIsSubmitting(false), 500); } } }; - const onClickSend = () => { - if (viewMode === 'terminal' && !activeSession.isRunning && activeSession.status !== 'waiting') { - handleTerminalCommand(); - } else if (activeSession.status === 'waiting') { - handleSendInput(); - } else { - handleContinueConversation(selectedModel); + const onClickSend = async () => { + // Prevent duplicate submissions + if (isSubmitting) { + console.log('[SessionInput] Ignoring duplicate submission attempt'); + return; + } + + setIsSubmitting(true); + + try { + if (viewMode === 'terminal' && !activeSession.isRunning && activeSession.status !== 'waiting') { + await handleTerminalCommand(); + } else if (activeSession.status === 'waiting') { + await handleSendInput(); + } else { + await handleContinueConversation(selectedModel); + } + } finally { + // Reset submission state after a short delay to prevent rapid resubmissions + setTimeout(() => setIsSubmitting(false), 500); } }; @@ -102,10 +130,15 @@ export const SessionInput: React.FC = ({
@@ -140,9 +173,10 @@ export const SessionInput: React.FC = ({ className="text-sm px-2 py-1 border border-border-primary rounded focus:outline-none focus:ring-1 focus:ring-interactive text-text-primary bg-surface-secondary" title="AI model to use for continuing the conversation" > - - - + + + +
)} diff --git a/frontend/src/components/session/SessionInputWithImages.tsx b/frontend/src/components/session/SessionInputWithImages.tsx index 5344b8ac..93cc39cb 100644 --- a/frontend/src/components/session/SessionInputWithImages.tsx +++ b/frontend/src/components/session/SessionInputWithImages.tsx @@ -64,8 +64,9 @@ export const SessionInputWithImages: React.FC = mem const [isDragging, setIsDragging] = useState(false); const [isFocused, setIsFocused] = useState(false); const [isToolbarActive, setIsToolbarActive] = useState(false); - const [selectedModel, setSelectedModel] = useState(activeSession.model || 'claude-sonnet-4-20250514'); + const [selectedModel, setSelectedModel] = useState(activeSession.model || 'auto'); const [textareaHeight, setTextareaHeight] = useState(52); + const [isSubmitting, setIsSubmitting] = useState(false); const fileInputRef = useRef(null); // Calculate auto-commit enabled state @@ -74,7 +75,7 @@ export const SessionInputWithImages: React.FC = mem useEffect(() => { // Update selected model when switching to a different session - setSelectedModel(activeSession.model || 'claude-sonnet-4-20250514'); + setSelectedModel(activeSession.model || 'auto'); }, [activeSession.id]); // Only reset when session ID changes, not when model updates const generateImageId = () => `img_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; @@ -164,7 +165,7 @@ export const SessionInputWithImages: React.FC = mem setAttachedImages(prev => prev.filter(img => img.id !== id)); }, []); - const onKeyDown = (e: React.KeyboardEvent) => { + const onKeyDown = async (e: React.KeyboardEvent) => { const shouldSend = e.key === 'Enter' && (e.metaKey || e.ctrlKey); const shouldCancel = e.key === 'Escape' && activeSession.status === 'running' && handleCancelRequest; @@ -173,27 +174,54 @@ export const SessionInputWithImages: React.FC = mem handleCancelRequest(); } else if (shouldSend) { e.preventDefault(); + + // Prevent duplicate submissions + if (isSubmitting) { + console.log('[SessionInputWithImages] Ignoring duplicate submission attempt'); + return; + } + + setIsSubmitting(true); + + try { + if (viewMode === 'terminal' && !activeSession.isRunning && activeSession.status !== 'waiting') { + await handleTerminalCommand(); + } else if (activeSession.status === 'waiting') { + await handleSendInput(attachedImages); + setAttachedImages([]); + } else { + await handleContinueConversation(attachedImages, selectedModel); + setAttachedImages([]); + } + } finally { + // Reset submission state after a short delay to prevent rapid resubmissions + setTimeout(() => setIsSubmitting(false), 500); + } + } + }; + + const onClickSend = async () => { + // Prevent duplicate submissions + if (isSubmitting) { + console.log('[SessionInputWithImages] Ignoring duplicate submission attempt'); + return; + } + + setIsSubmitting(true); + + try { if (viewMode === 'terminal' && !activeSession.isRunning && activeSession.status !== 'waiting') { - handleTerminalCommand(); + await handleTerminalCommand(); } else if (activeSession.status === 'waiting') { - handleSendInput(attachedImages); + await handleSendInput(attachedImages); setAttachedImages([]); } else { - handleContinueConversation(attachedImages, selectedModel); + await handleContinueConversation(attachedImages, selectedModel); setAttachedImages([]); } - } - }; - - const onClickSend = () => { - if (viewMode === 'terminal' && !activeSession.isRunning && activeSession.status !== 'waiting') { - handleTerminalCommand(); - } else if (activeSession.status === 'waiting') { - handleSendInput(attachedImages); - setAttachedImages([]); - } else { - handleContinueConversation(attachedImages, selectedModel); - setAttachedImages([]); + } finally { + // Reset submission state after a short delay to prevent rapid resubmissions + setTimeout(() => setIsSubmitting(false), 500); } }; @@ -578,24 +606,27 @@ export const SessionInputWithImages: React.FC = mem ) : (