From 58dd6dd3cb532574fdc06c9caafb6c613738b36f Mon Sep 17 00:00:00 2001 From: Parminder Klair Date: Fri, 15 Aug 2025 14:51:42 +0100 Subject: [PATCH 01/30] Add Arch Linux build support with pacman target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add pacman target to electron-builder Linux configuration - Configure Arch-specific dependencies (gtk3, libnotify, nss, etc.) - Add dedicated build scripts: build:arch, release:arch, canary:arch - Update documentation with bsdtar dependency requirements - Add installation instructions for Arch Linux users - Create PKGBUILD.example template for AUR maintainers - Successfully tested pacman package generation πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CLAUDE.md | 254 +++++++++++++++++++++++++++++++++++------- NOTICES | 211 ++++++++++++++++++++++++++++------- README.md | 34 ++++++ docs/PKGBUILD.example | 44 ++++++++ package.json | 22 ++++ 5 files changed, 487 insertions(+), 78 deletions(-) create mode 100644 docs/PKGBUILD.example 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..20af337c 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.0 Author: [object Object] License: MIT diff --git a/README.md b/README.md index ca0e90db..4d593beb 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,23 @@ 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. + ## Building from Source @@ -101,8 +118,25 @@ 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 diff --git a/docs/PKGBUILD.example b/docs/PKGBUILD.example new file mode 100644 index 00000000..f37ed6e8 --- /dev/null +++ b/docs/PKGBUILD.example @@ -0,0 +1,44 @@ +# Maintainer: Your Name +pkgname=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=('crystal') +conflicts=('crystal') +source=("https://github.com/stravu/crystal/releases/download/v${pkgver}/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}/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}/Crystal-${pkgver}-linux-x64.AppImage") +# +# package() { +# # Install AppImage +# install -Dm755 "${srcdir}/Crystal-${pkgver}-linux-x64.AppImage" "${pkgdir}/opt/crystal/crystal.AppImage" +# +# # Create launcher script +# install -Dm755 /dev/stdin "${pkgdir}/usr/bin/crystal" < Date: Mon, 18 Aug 2025 13:31:41 -0400 Subject: [PATCH 02/30] add dev mode to see raw messages --- frontend/src/App.tsx | 7 + frontend/src/components/SessionView.tsx | 4 + frontend/src/components/Settings.tsx | 20 ++ .../src/components/session/MessagesView.tsx | 209 ++++++++++++++++++ frontend/src/components/session/ViewTabs.tsx | 24 +- frontend/src/hooks/useSessionView.ts | 6 +- frontend/src/stores/configStore.ts | 45 ++++ frontend/src/types/config.ts | 1 + main/src/types/config.ts | 3 + 9 files changed, 315 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/session/MessagesView.tsx create mode 100644 frontend/src/stores/configStore.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a6efcdb6..587e16ca 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,7 @@ import { PermissionDialog } from './components/PermissionDialog'; import { DiscordPopup } from './components/DiscordPopup'; import { useErrorStore } from './stores/errorStore'; import { useSessionStore } from './stores/sessionStore'; +import { useConfigStore } from './stores/configStore'; import { API } from './utils/api'; import { ContextMenuProvider } from './contexts/ContextMenuContext'; import { TokenTest } from './components/TokenTest'; @@ -40,6 +41,7 @@ function App() { const [isTokenTestOpen, setIsTokenTestOpen] = useState(false); const { currentError, clearError } = useErrorStore(); const { sessions, isLoaded } = useSessionStore(); + const { fetchConfig } = useConfigStore(); const { width: sidebarWidth, startResize } = useResizable({ defaultWidth: 500, // Increased to show git status labels without truncation @@ -50,6 +52,11 @@ function App() { useIPCEvents(); const { showNotification } = useNotifications(); + + // Load config on app startup + useEffect(() => { + fetchConfig(); + }, [fetchConfig]); // Add keyboard shortcut for prompt history useEffect(() => { diff --git a/frontend/src/components/SessionView.tsx b/frontend/src/components/SessionView.tsx index ce49dd85..a50010c0 100644 --- a/frontend/src/components/SessionView.tsx +++ b/frontend/src/components/SessionView.tsx @@ -18,6 +18,7 @@ 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); @@ -299,6 +300,9 @@ export const SessionView = memo(() => {
+
+ +
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. +

+
= ({ sessionId }) => { + const [messages, setMessages] = useState([]); + const [expandedMessages, setExpandedMessages] = useState>(new Set()); + const [copiedIndex, setCopiedIndex] = useState(null); + 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) { + // The messages are already in the correct format from getJsonMessages + const jsonMessages = response.data.map((msg: any) => ({ + type: 'json' as const, + data: typeof msg === 'string' ? msg : JSON.stringify(msg), + timestamp: msg.timestamp || new Date().toISOString() + })); + setMessages(jsonMessages); + } + } 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') { + setMessages(prev => [...prev, { + type: 'json', + data: data.data, + timestamp: new Date().toISOString() + }]); + + // Auto-scroll to bottom if enabled + if (autoScrollRef.current) { + setTimeout(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, 100); + } + } + }; + + 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) { + return ( +
+
+
No JSON messages yet
+
+ JSON messages from Claude Code will appear here +
+
+
+ ); + } + + return ( +
+
+ {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/ViewTabs.tsx b/frontend/src/components/session/ViewTabs.tsx index 2a2fdf8f..13064294 100644 --- a/frontend/src/components/session/ViewTabs.tsx +++ b/frontend/src/components/session/ViewTabs.tsx @@ -1,9 +1,10 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { ViewMode } from '../../hooks/useSessionView'; import { cn } from '../../utils/cn'; -import { GitCompare, Terminal, FileEdit, Eye, Settings, GitBranch, MoreVertical, ScrollText } from 'lucide-react'; +import { GitCompare, Terminal, FileEdit, Eye, Settings, GitBranch, MoreVertical, ScrollText, Code } from 'lucide-react'; import { Button } from '../ui/Button'; import { Dropdown } from '../ui/Dropdown'; +import { useConfigStore } from '../../stores/configStore'; interface ViewTabsProps { viewMode: ViewMode; @@ -14,6 +15,7 @@ interface ViewTabsProps { logs: boolean; editor: boolean; richOutput: boolean; + messages?: boolean; }; setUnreadActivity: (activity: any) => void; isTerminalRunning: boolean; @@ -42,6 +44,14 @@ export const ViewTabs: React.FC = ({ branchActions, isMerging, }) => { + const { config, fetchConfig } = useConfigStore(); + + // Fetch config on mount if not loaded + useEffect(() => { + if (!config) { + fetchConfig(); + } + }, [config, fetchConfig]); const tabs: { mode: ViewMode; label: string; @@ -82,6 +92,16 @@ export const ViewTabs: React.FC = ({ activity: unreadActivity.editor }, ]; + + // Add Messages tab if dev mode is enabled + if (config?.devMode) { + tabs.push({ + mode: 'messages' as ViewMode, + label: 'Messages', + icon: , + activity: unreadActivity.messages + }); + } return (
diff --git a/frontend/src/hooks/useSessionView.ts b/frontend/src/hooks/useSessionView.ts index bcff1434..4c610474 100644 --- a/frontend/src/hooks/useSessionView.ts +++ b/frontend/src/hooks/useSessionView.ts @@ -9,7 +9,7 @@ import { Session, GitCommands, GitErrorDetails } from '../types/session'; import { getTerminalTheme, getScriptTerminalTheme } from '../utils/terminalTheme'; import { createVisibilityAwareInterval } from '../utils/performanceUtils'; -export type ViewMode = 'richOutput' | 'changes' | 'terminal' | 'logs' | 'editor'; +export type ViewMode = 'richOutput' | 'changes' | 'terminal' | 'logs' | 'editor' | 'messages'; export const useSessionView = ( activeSession: Session | undefined, @@ -33,6 +33,7 @@ export const useSessionView = ( logs: false, editor: false, richOutput: false, + messages: false, }); const [isEditingName, setIsEditingName] = useState(false); const [editName, setEditName] = useState(''); @@ -334,6 +335,7 @@ export const useSessionView = ( logs: false, editor: false, richOutput: false, + messages: false, }); // Reset context compaction state when switching sessions @@ -977,7 +979,7 @@ export const useSessionView = ( }, [activeSession?.status, activeSession?.runStartedAt, activeSessionId]); useEffect(() => { - setUnreadActivity({ changes: false, terminal: false, logs: false, editor: false, richOutput: false }); + setUnreadActivity({ changes: false, terminal: false, logs: false, editor: false, richOutput: false, messages: false }); }, [activeSessionId]); diff --git a/frontend/src/stores/configStore.ts b/frontend/src/stores/configStore.ts new file mode 100644 index 00000000..57d0a486 --- /dev/null +++ b/frontend/src/stores/configStore.ts @@ -0,0 +1,45 @@ +import { create } from 'zustand'; +import { API } from '../utils/api'; +import type { AppConfig } from '../types/config'; + +interface ConfigStore { + config: AppConfig | null; + isLoading: boolean; + error: string | null; + fetchConfig: () => Promise; + updateConfig: (updates: Partial) => Promise; +} + +export const useConfigStore = create((set, get) => ({ + config: null, + isLoading: false, + error: null, + + fetchConfig: async () => { + set({ isLoading: true, error: null }); + try { + const response = await API.config.get(); + if (response.success && response.data) { + set({ config: response.data, isLoading: false }); + } else { + set({ error: response.error || 'Failed to fetch config', isLoading: false }); + } + } catch (error) { + set({ error: 'Failed to fetch config', isLoading: false }); + } + }, + + updateConfig: async (updates: Partial) => { + try { + const response = await API.config.update(updates); + if (response.success) { + // Refetch to ensure we have the latest config + await get().fetchConfig(); + } else { + set({ error: response.error || 'Failed to update config' }); + } + } catch (error) { + set({ error: 'Failed to update config' }); + } + }, +})); \ No newline at end of file diff --git a/frontend/src/types/config.ts b/frontend/src/types/config.ts index d4516128..b013beed 100644 --- a/frontend/src/types/config.ts +++ b/frontend/src/types/config.ts @@ -17,4 +17,5 @@ export interface AppConfig { notifyOnWaiting: boolean; notifyOnComplete: boolean; }; + devMode?: boolean; } \ No newline at end of file diff --git a/main/src/types/config.ts b/main/src/types/config.ts index 515e151b..7d81395b 100644 --- a/main/src/types/config.ts +++ b/main/src/types/config.ts @@ -26,6 +26,8 @@ export interface AppConfig { notifyOnWaiting: boolean; notifyOnComplete: boolean; }; + // Dev mode for debugging + devMode?: boolean; } export interface UpdateConfigRequest { @@ -46,4 +48,5 @@ export interface UpdateConfigRequest { notifyOnWaiting: boolean; notifyOnComplete: boolean; }; + devMode?: boolean; } \ No newline at end of file From 369bc07f37a11f430d04f81e6294ae525ba7acb6 Mon Sep 17 00:00:00 2001 From: Jordan Bentley Date: Mon, 18 Aug 2025 15:31:00 -0400 Subject: [PATCH 03/30] add UX for sub-agent output --- .../src/components/session/RichOutputView.tsx | 175 ++++++++++++++++-- 1 file changed, 157 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/session/RichOutputView.tsx b/frontend/src/components/session/RichOutputView.tsx index cf7ae663..5f6f697a 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 } @@ -177,9 +183,21 @@ export const RichOutputView = React.forwardRef<{ scrollToPrompt: (promptIndex: n const transformMessages = (rawMessages: RawMessage[]): ConversationMessage[] => { const transformed: ConversationMessage[] = []; - // First pass: Build tool result map + // First pass: Build tool result map and identify sub-agent relationships const toolResults = new Map(); + const parentToolMap = new Map(); // Map tool ID to parent tool ID + + // Identify all tool calls and their parent relationships first rawMessages.forEach(msg => { + // 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)) { + msg.message.content.forEach((block: any) => { + 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) => { if (block.type === 'tool_result' && block.tool_use_id) { @@ -192,7 +210,41 @@ export const RichOutputView = React.forwardRef<{ scrollToPrompt: (promptIndex: n } }); - // Second pass: Build conversation messages + // Second pass: Build all tool calls to prepare for hierarchy + const allToolCalls = new Map(); + rawMessages.forEach(msg => { + if (msg.type === 'assistant' && msg.message?.content && Array.isArray(msg.message.content)) { + msg.message.content.forEach((block: any) => { + 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 + allToolCalls.forEach((toolCall) => { + 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]; @@ -244,14 +296,11 @@ 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 { @@ -590,21 +639,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 +694,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 +726,7 @@ export const RichOutputView = React.forwardRef<{ scrollToPrompt: (promptIndex: n )} {/* Pending state */} - {tool.status === 'pending' && ( + {tool.status === 'pending' && !hasChildTools && (
Waiting for result...
)}
@@ -694,6 +788,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 +844,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 +853,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 ( From ae83b40f698434744ad07c604f0db9e7f8ff2b4c Mon Sep 17 00:00:00 2001 From: Jordan Bentley Date: Wed, 20 Aug 2025 15:38:25 -0400 Subject: [PATCH 04/30] performance improvement --- frontend/src/components/session/RichOutputView.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/session/RichOutputView.tsx b/frontend/src/components/session/RichOutputView.tsx index 5f6f697a..e3855636 100644 --- a/frontend/src/components/session/RichOutputView.tsx +++ b/frontend/src/components/session/RichOutputView.tsx @@ -108,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(() => { @@ -514,6 +515,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; @@ -523,7 +529,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 } }; @@ -534,7 +540,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(() => { From 38c6c0b831def6accf2581f1c2ca8c246c8b5b80 Mon Sep 17 00:00:00 2001 From: Jordan Bentley Date: Thu, 21 Aug 2025 12:53:08 -0400 Subject: [PATCH 05/30] Don't show worktree files when using @ to reference files --- main/src/ipc/file.ts | 67 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/main/src/ipc/file.ts b/main/src/ipc/file.ts index 5e3435b0..01b4e153 100644 --- a/main/src/ipc/file.ts +++ b/main/src/ipc/file.ts @@ -563,11 +563,58 @@ EOF return { success: true, files: [] }; } + // Get list of tracked files (not gitignored) using git + const { exec } = require('child_process'); + const { promisify } = require('util'); + const execAsync = promisify(exec); + + let gitTrackedFiles = new Set(); + let isGitRepo = true; + try { + // Get list of all tracked files in the repository + const { stdout: trackedStdout } = await execAsync('git ls-files', { + cwd: searchDirectory, + maxBuffer: 10 * 1024 * 1024 // 10MB buffer + }); + + if (trackedStdout) { + trackedStdout.split('\n').forEach((file: string) => { + if (file.trim()) { + gitTrackedFiles.add(file.trim()); + } + }); + } + + // Also get untracked files that are not ignored + const { stdout: untrackedStdout } = await execAsync('git ls-files --others --exclude-standard', { + cwd: searchDirectory, + maxBuffer: 10 * 1024 * 1024 // 10MB buffer + }); + + if (untrackedStdout) { + untrackedStdout.split('\n').forEach((file: string) => { + if (file.trim()) { + gitTrackedFiles.add(file.trim()); + } + }); + } + } catch (err) { + // Git command failed, likely not a git repo + isGitRepo = false; + console.log('Could not get git tracked files:', err); + } + // Use glob to find matching files const globPattern = filePattern ? `**/*${filePattern}*` : '**/*'; const files = await glob(globPattern, { cwd: searchDir, - ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'], + ignore: [ + '**/node_modules/**', + '**/.git/**', + '**/dist/**', + '**/build/**', + '**/worktrees/**' // Exclude worktree folders + ], nodir: false, dot: true, absolute: false, @@ -580,6 +627,24 @@ EOF const fullPath = path.join(searchDir, file); const relativePath = path.relative(searchDirectory, fullPath); + // Skip worktree directories + if (relativePath.includes('worktrees/') || relativePath.startsWith('worktrees/')) { + return null; + } + + // If we're in a git repo, only include tracked/untracked-but-not-ignored files + if (isGitRepo && gitTrackedFiles.size > 0 && !gitTrackedFiles.has(relativePath)) { + // Check if it's a directory - directories might not be in git ls-files + try { + const stats = await fs.stat(fullPath); + if (!stats.isDirectory()) { + return null; // Skip non-directory files that aren't tracked + } + } catch { + return null; + } + } + try { const stats = await fs.stat(fullPath); return { From 4e47f778b7e6c1dfff64366f11e8f5769b24a451 Mon Sep 17 00:00:00 2001 From: Jordan Bentley Date: Thu, 21 Aug 2025 13:48:44 -0400 Subject: [PATCH 06/30] fix showing commit message on diff viewer --- frontend/src/components/ExecutionList.tsx | 2 +- frontend/src/types/diff.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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/types/diff.ts b/frontend/src/types/diff.ts index 90dec1ea..b1c95742 100644 --- a/frontend/src/types/diff.ts +++ b/frontend/src/types/diff.ts @@ -11,6 +11,7 @@ export interface ExecutionDiff { stats_files_changed: number; before_commit_hash?: string; after_commit_hash?: string; + commit_message?: string; timestamp: string; } From cf919298d2693f648665c148211b727f8b4691c3 Mon Sep 17 00:00:00 2001 From: Jordan Bentley Date: Thu, 21 Aug 2025 14:19:56 -0400 Subject: [PATCH 07/30] don't allow double submission of prompts --- .../src/components/session/SessionInput.tsx | 65 +++++++++++---- .../session/SessionInputWithImages.tsx | 79 +++++++++++++------ main/src/ipc/session.ts | 6 ++ main/src/services/claudeCodeManager.ts | 9 +++ test-duplicate-submission.md | 73 +++++++++++++++++ 5 files changed, 192 insertions(+), 40 deletions(-) create mode 100644 test-duplicate-submission.md diff --git a/frontend/src/components/session/SessionInput.tsx b/frontend/src/components/session/SessionInput.tsx index 33640066..e410132c 100644 --- a/frontend/src/components/session/SessionInput.tsx +++ b/frontend/src/components/session/SessionInput.tsx @@ -36,6 +36,7 @@ export const SessionInput: React.FC = ({ handleToggleAutoCommit, }) => { const [selectedModel, setSelectedModel] = useState(activeSession.model || 'claude-sonnet-4-20250514'); + const [isSubmitting, setIsSubmitting] = useState(false); useEffect(() => { // Update selected model when switching to a different session @@ -47,27 +48,54 @@ export const SessionInput: React.FC = ({ setSelectedModel(activeSession.model || 'claude-sonnet-4-20250514'); }, [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 = ({
diff --git a/frontend/src/components/session/SessionInputWithImages.tsx b/frontend/src/components/session/SessionInputWithImages.tsx index 5344b8ac..79294201 100644 --- a/frontend/src/components/session/SessionInputWithImages.tsx +++ b/frontend/src/components/session/SessionInputWithImages.tsx @@ -66,6 +66,7 @@ export const SessionInputWithImages: React.FC = mem const [isToolbarActive, setIsToolbarActive] = useState(false); const [selectedModel, setSelectedModel] = useState(activeSession.model || 'claude-sonnet-4-20250514'); const [textareaHeight, setTextareaHeight] = useState(52); + const [isSubmitting, setIsSubmitting] = useState(false); const fileInputRef = useRef(null); // Calculate auto-commit enabled state @@ -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 ) : ( + + + 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); diff --git a/main/src/services/claudeCodeManager.ts b/main/src/services/claudeCodeManager.ts index 3cb2f5a2..df2329db 100644 --- a/main/src/services/claudeCodeManager.ts +++ b/main/src/services/claudeCodeManager.ts @@ -743,6 +743,24 @@ export class ClaudeCodeManager extends EventEmitter { // Emit spawned event to update session status this.emit('spawned', { sessionId }); + // Emit initial session info message with prompt and command + const sessionInfoMessage = { + type: 'session_info', + initial_prompt: prompt, + claude_command: `${claudeCommand || 'claude'} ${args.join(' ')}`, + worktree_path: worktreePath, + model: model || 'default', + permission_mode: permissionMode || 'default', + timestamp: new Date().toISOString() + }; + + this.emit('output', { + sessionId, + type: 'json', + data: sessionInfoMessage, + timestamp: new Date() + }); + let hasReceivedOutput = false; let lastOutput = ''; let buffer = ''; From 75ea6bb854ed6d7229c182d0e187a24ccda9f672 Mon Sep 17 00:00:00 2001 From: Jordan Bentley Date: Thu, 21 Aug 2025 15:03:06 -0400 Subject: [PATCH 09/30] add 'auto' as a model selection --- .../src/components/CreateSessionDialog.tsx | 25 +++++++++++++++++-- .../src/components/session/SessionInput.tsx | 5 ++-- .../session/SessionInputWithImages.tsx | 10 ++++++-- main/src/services/claudeCodeManager.ts | 6 +++-- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/CreateSessionDialog.tsx b/frontend/src/components/CreateSessionDialog.tsx index 277e28a3..217a406d 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,7 +296,27 @@ export function CreateSessionDialog({ isOpen, onClose, projectName, projectId }: -
+
+ setFormData({ ...formData, model: 'auto' })} + > +
+ + Auto + Default +
+ {formData.model === 'auto' && ( +
+ )} + +

+ {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/session/SessionInput.tsx b/frontend/src/components/session/SessionInput.tsx index e410132c..05a177bc 100644 --- a/frontend/src/components/session/SessionInput.tsx +++ b/frontend/src/components/session/SessionInput.tsx @@ -35,7 +35,7 @@ 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(() => { @@ -45,7 +45,7 @@ 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 = async (e: React.KeyboardEvent) => { @@ -173,6 +173,7 @@ 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 79294201..b2f8358f 100644 --- a/frontend/src/components/session/SessionInputWithImages.tsx +++ b/frontend/src/components/session/SessionInputWithImages.tsx @@ -64,7 +64,7 @@ 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); @@ -75,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)}`; @@ -671,6 +671,12 @@ const ModelSelector: React.FC = ({ // Model configurations const modelConfigs = { + 'auto': { + label: 'Auto', + icon: CheckCircle, + iconColor: 'text-interactive', + description: 'Default', + }, 'claude-sonnet-4-20250514': { label: 'Sonnet', icon: Target, diff --git a/main/src/services/claudeCodeManager.ts b/main/src/services/claudeCodeManager.ts index df2329db..a8f4dd16 100644 --- a/main/src/services/claudeCodeManager.ts +++ b/main/src/services/claudeCodeManager.ts @@ -164,8 +164,8 @@ export class ClaudeCodeManager extends EventEmitter { // Build the command arguments const args = ['--verbose', '--output-format', 'stream-json']; - // Add model argument if specified - if (model) { + // Add model argument if specified and not 'auto' + if (model && model !== 'auto') { // Map full model identifiers to shorthand for Bedrock compatibility // Only opus and sonnet have shorthand versions, haiku passes through let modelOrAlias = model; @@ -177,6 +177,8 @@ export class ClaudeCodeManager extends EventEmitter { args.push('--model', modelOrAlias); this.logger?.verbose(`Using model: ${model}`); + } else if (model === 'auto') { + this.logger?.verbose(`Using auto model selection (Claude Code's default)`); } // Log commit mode for debugging (but don't pass to Claude Code) From a484f01a8b264967a3817682bbf886f1e3f8781b Mon Sep 17 00:00:00 2001 From: Jordan Bentley Date: Thu, 21 Aug 2025 15:14:59 -0400 Subject: [PATCH 10/30] scroll bar for messages tab --- frontend/src/components/SessionView.tsx | 2 +- frontend/src/components/session/MessagesView.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/SessionView.tsx b/frontend/src/components/SessionView.tsx index a50010c0..7c241819 100644 --- a/frontend/src/components/SessionView.tsx +++ b/frontend/src/components/SessionView.tsx @@ -300,7 +300,7 @@ export const SessionView = memo(() => {

-
+
diff --git a/frontend/src/components/session/MessagesView.tsx b/frontend/src/components/session/MessagesView.tsx index a5fb52b1..9de96013 100644 --- a/frontend/src/components/session/MessagesView.tsx +++ b/frontend/src/components/session/MessagesView.tsx @@ -227,7 +227,7 @@ export const MessagesView: React.FC = ({ sessionId }) => { return (
{/* Session Info Card */} From 057e73bfeb1ebed5ca3f7381f1d71bfe5ccc0148 Mon Sep 17 00:00:00 2001 From: Jordan Bentley Date: Thu, 21 Aug 2025 15:53:54 -0400 Subject: [PATCH 11/30] update claude code version --- main/package.json | 4 ++-- package.json | 4 ++-- pnpm-lock.yaml | 28 ++++++++++++++-------------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/main/package.json b/main/package.json index af21eeb9..a3ec63c1 100644 --- a/main/package.json +++ b/main/package.json @@ -16,8 +16,8 @@ "test:coverage": "vitest --coverage" }, "dependencies": { - "@anthropic-ai/claude-code": "^1.0.33", - "@anthropic-ai/sdk": "^0.53.0", + "@anthropic-ai/claude-code": "^1.0.86", + "@anthropic-ai/sdk": "^0.60.0", "@homebridge/node-pty-prebuilt-multiarch": "^0.12.0", "@modelcontextprotocol/sdk": "^1.12.1", "better-sqlite3": "^11.7.0", diff --git a/package.json b/package.json index 6f8b0c72..1553b5a5 100644 --- a/package.json +++ b/package.json @@ -56,8 +56,8 @@ "test:headed": "playwright test --headed" }, "dependencies": { - "@anthropic-ai/claude-code": "^1.0.33", - "@anthropic-ai/sdk": "^0.53.0", + "@anthropic-ai/claude-code": "^1.0.86", + "@anthropic-ai/sdk": "^0.60.0", "@homebridge/node-pty-prebuilt-multiarch": "^0.12.0", "@modelcontextprotocol/sdk": "^1.12.1", "better-sqlite3": "^11.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 828d4a11..3668b0e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,11 +9,11 @@ importers: .: dependencies: '@anthropic-ai/claude-code': - specifier: ^1.0.33 - version: 1.0.56 + specifier: ^1.0.86 + version: 1.0.86 '@anthropic-ai/sdk': - specifier: ^0.53.0 - version: 0.53.0 + specifier: ^0.60.0 + version: 0.60.0 '@homebridge/node-pty-prebuilt-multiarch': specifier: ^0.12.0 version: 0.12.0 @@ -221,11 +221,11 @@ importers: main: dependencies: '@anthropic-ai/claude-code': - specifier: ^1.0.33 - version: 1.0.56 + specifier: ^1.0.86 + version: 1.0.86 '@anthropic-ai/sdk': - specifier: ^0.53.0 - version: 0.53.0 + specifier: ^0.60.0 + version: 0.60.0 '@homebridge/node-pty-prebuilt-multiarch': specifier: ^0.12.0 version: 0.12.0 @@ -318,13 +318,13 @@ packages: '@antfu/utils@8.1.1': resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==} - '@anthropic-ai/claude-code@1.0.56': - resolution: {integrity: sha512-LYOlv9uXtLrJcJqSLvQlhy7shhC6MHEXuSGZ/+BazM4LY36ng3cmKjTCDny0kZQxa+u/+MYOXUrkmkJm2qR75Q==} + '@anthropic-ai/claude-code@1.0.86': + resolution: {integrity: sha512-js1h6JUnFJ1dHvFPBiCxwFChaWjh28XOFamrwebmhOIUBVhQZwMfDJYsNfRyv0qEwpxKxYedvK4nv4WqMCwu9Q==} engines: {node: '>=18.0.0'} hasBin: true - '@anthropic-ai/sdk@0.53.0': - resolution: {integrity: sha512-bl2frVo0cgHCXTzQEIHXPH329uQXC4YKBaLBkZmUc59tNiR3GgcGpBZU7mwfyv5tPuU/yT7tGHyoe5AnjN02QA==} + '@anthropic-ai/sdk@0.60.0': + resolution: {integrity: sha512-9zu/TXaUy8BZhXedDtt1wT3H4LOlpKDO1/ftiFpeR3N1PCr3KJFKkxxlQWWt1NNp08xSwUNJ3JNY8yhl8av6eQ==} hasBin: true '@babel/code-frame@7.27.1': @@ -5056,7 +5056,7 @@ snapshots: '@antfu/utils@8.1.1': {} - '@anthropic-ai/claude-code@1.0.56': + '@anthropic-ai/claude-code@1.0.86': optionalDependencies: '@img/sharp-darwin-arm64': 0.33.5 '@img/sharp-darwin-x64': 0.33.5 @@ -5065,7 +5065,7 @@ snapshots: '@img/sharp-linux-x64': 0.33.5 '@img/sharp-win32-x64': 0.33.5 - '@anthropic-ai/sdk@0.53.0': {} + '@anthropic-ai/sdk@0.60.0': {} '@babel/code-frame@7.27.1': dependencies: From d72a7ab658d3ca373760aebc956798bc5929fb2d Mon Sep 17 00:00:00 2001 From: Jordan Bentley Date: Fri, 22 Aug 2025 10:11:20 -0400 Subject: [PATCH 12/30] totally remove full model identifiers from code base --- .../src/components/CreateSessionDialog.tsx | 36 +++++++++---------- .../src/components/session/SessionInput.tsx | 6 ++-- .../session/SessionInputWithImages.tsx | 6 ++-- main/src/database/database.ts | 6 ++-- main/src/ipc/session.ts | 4 +-- main/src/services/claudeCodeManager.ts | 12 ++----- main/src/services/configManager.ts | 4 +-- main/src/services/sessionManager.ts | 2 +- 8 files changed, 34 insertions(+), 42 deletions(-) diff --git a/frontend/src/components/CreateSessionDialog.tsx b/frontend/src/components/CreateSessionDialog.tsx index 217a406d..92af2ca6 100644 --- a/frontend/src/components/CreateSessionDialog.tsx +++ b/frontend/src/components/CreateSessionDialog.tsx @@ -318,61 +318,61 @@ export function CreateSessionDialog({ isOpen, onClose, projectName, projectId }: setFormData({ ...formData, model: 'claude-sonnet-4-20250514' })} + onClick={() => setFormData({ ...formData, model: 'sonnet' })} >
- - 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' && (
)} diff --git a/frontend/src/components/session/SessionInput.tsx b/frontend/src/components/session/SessionInput.tsx index 05a177bc..209c5164 100644 --- a/frontend/src/components/session/SessionInput.tsx +++ b/frontend/src/components/session/SessionInput.tsx @@ -174,9 +174,9 @@ export const SessionInput: React.FC = ({ 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 b2f8358f..93cc39cb 100644 --- a/frontend/src/components/session/SessionInputWithImages.tsx +++ b/frontend/src/components/session/SessionInputWithImages.tsx @@ -677,19 +677,19 @@ const ModelSelector: React.FC = ({ iconColor: 'text-interactive', description: 'Default', }, - 'claude-sonnet-4-20250514': { + 'sonnet': { label: 'Sonnet', icon: Target, iconColor: 'text-interactive', description: 'Balanced', }, - 'claude-opus-4-20250514': { + 'opus': { label: 'Opus', icon: Brain, iconColor: 'text-interactive', description: 'Maximum', }, - 'claude-3-5-haiku-20241022': { + 'haiku': { label: 'Haiku', icon: Zap, iconColor: 'text-status-success', diff --git a/main/src/database/database.ts b/main/src/database/database.ts index c863cc8d..89908257 100644 --- a/main/src/database/database.ts +++ b/main/src/database/database.ts @@ -632,7 +632,7 @@ export class DatabaseService { const hasModelColumn = sessionTableInfoModel.some((col: any) => col.name === 'model'); if (!hasModelColumn) { - this.db.prepare("ALTER TABLE sessions ADD COLUMN model TEXT DEFAULT 'claude-sonnet-4-20250514'").run(); + this.db.prepare("ALTER TABLE sessions ADD COLUMN model TEXT DEFAULT 'sonnet'").run(); console.log('[Database] Added model column to sessions table'); } @@ -689,7 +689,7 @@ export class DatabaseService { const hasLastUsedModelColumn = projectsTableInfoModel.some((col: any) => col.name === 'lastUsedModel'); if (!hasLastUsedModelColumn) { - this.db.prepare("ALTER TABLE projects ADD COLUMN lastUsedModel TEXT DEFAULT 'claude-sonnet-4-20250514'").run(); + this.db.prepare("ALTER TABLE projects ADD COLUMN lastUsedModel TEXT DEFAULT 'sonnet'").run(); console.log('[Database] Added lastUsedModel column to projects table'); } @@ -1174,7 +1174,7 @@ export class DatabaseService { this.db.prepare(` INSERT INTO sessions (id, name, initial_prompt, worktree_name, worktree_path, status, project_id, folder_id, permission_mode, is_main_repo, display_order, auto_commit, model, base_commit, base_branch, commit_mode, commit_mode_settings) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(data.id, data.name, data.initial_prompt, data.worktree_name, data.worktree_path, data.project_id, data.folder_id || null, data.permission_mode || 'ignore', data.is_main_repo ? 1 : 0, displayOrder, data.auto_commit !== undefined ? (data.auto_commit ? 1 : 0) : 1, data.model || 'claude-sonnet-4-20250514', data.base_commit || null, data.base_branch || null, data.commit_mode || null, data.commit_mode_settings || null); + `).run(data.id, data.name, data.initial_prompt, data.worktree_name, data.worktree_path, data.project_id, data.folder_id || null, data.permission_mode || 'ignore', data.is_main_repo ? 1 : 0, displayOrder, data.auto_commit !== undefined ? (data.auto_commit ? 1 : 0) : 1, data.model || 'sonnet', data.base_commit || null, data.base_branch || null, data.commit_mode || null, data.commit_mode_settings || null); const session = this.getSession(data.id); if (!session) { diff --git a/main/src/ipc/session.ts b/main/src/ipc/session.ts index f020ca17..a08d9e2b 100644 --- a/main/src/ipc/session.ts +++ b/main/src/ipc/session.ts @@ -433,7 +433,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) // Start Claude Code with the user's prompt // Use the provided model if specified, otherwise fall back to the session's original model - const modelToUse = model || dbSession?.model || 'claude-sonnet-4-20250514'; + const modelToUse = model || dbSession?.model || 'sonnet'; await claudeCodeManager.startSession(sessionId, session.worktreePath, continuePrompt, dbSession?.permission_mode, modelToUse); } else { // Normal continue for existing sessions @@ -443,7 +443,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) // Continue the session with the existing conversation // Use the provided model if specified, otherwise fall back to the session's original model - const modelToUse = model || dbSession?.model || 'claude-sonnet-4-20250514'; + const modelToUse = model || dbSession?.model || 'sonnet'; console.log(`[IPC] Continue session ${sessionId} - provided model: ${model}, current model: ${dbSession?.model}, modelToUse: ${modelToUse}`); diff --git a/main/src/services/claudeCodeManager.ts b/main/src/services/claudeCodeManager.ts index a8f4dd16..f0c63910 100644 --- a/main/src/services/claudeCodeManager.ts +++ b/main/src/services/claudeCodeManager.ts @@ -166,16 +166,8 @@ export class ClaudeCodeManager extends EventEmitter { // Add model argument if specified and not 'auto' if (model && model !== 'auto') { - // Map full model identifiers to shorthand for Bedrock compatibility - // Only opus and sonnet have shorthand versions, haiku passes through - let modelOrAlias = model; - if (model.includes('opus')) { - modelOrAlias = 'opus'; - } else if (model.includes('sonnet')) { - modelOrAlias = 'sonnet'; - } - - args.push('--model', modelOrAlias); + // Pass the model shorthand directly (opus, sonnet, or haiku) + args.push('--model', model); this.logger?.verbose(`Using model: ${model}`); } else if (model === 'auto') { this.logger?.verbose(`Using auto model selection (Claude Code's default)`); diff --git a/main/src/services/configManager.ts b/main/src/services/configManager.ts index 543f1792..89d32210 100644 --- a/main/src/services/configManager.ts +++ b/main/src/services/configManager.ts @@ -21,7 +21,7 @@ export class ConfigManager extends EventEmitter { systemPromptAppend: undefined, runScript: undefined, defaultPermissionMode: 'ignore', - defaultModel: 'claude-sonnet-4-20250514', + defaultModel: 'sonnet', stravuApiKey: undefined, stravuServerUrl: 'https://api.stravu.com', notifications: { @@ -109,6 +109,6 @@ export class ConfigManager extends EventEmitter { } getDefaultModel(): string { - return this.config.defaultModel || 'claude-sonnet-4-20250514'; + return this.config.defaultModel || 'sonnet'; } } \ No newline at end of file diff --git a/main/src/services/sessionManager.ts b/main/src/services/sessionManager.ts index 98476b0e..2bd5cc0a 100644 --- a/main/src/services/sessionManager.ts +++ b/main/src/services/sessionManager.ts @@ -266,7 +266,7 @@ export class SessionManager extends EventEmitter { true, // isMainRepo = true true, // autoCommit = true (default for main repo sessions) undefined, // folderId - 'claude-sonnet-4-20250514', // default model for main repo sessions + 'sonnet', // default model for main repo sessions project.commit_mode, // Use project's commit mode undefined // commit_mode_settings - let it use project defaults ); From 647bd5500ca1356e440d53590c785781b5de6408 Mon Sep 17 00:00:00 2001 From: Jordan Bentley Date: Fri, 22 Aug 2025 10:11:46 -0400 Subject: [PATCH 13/30] 0.2.1 RC --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ frontend/package.json | 2 +- main/package.json | 2 +- package.json | 2 +- shared/package.json | 2 +- 5 files changed, 32 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a74faf2f..8836fbdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,34 @@ All notable changes to Crystal will be documented in this file. +## [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/frontend/package.json b/frontend/package.json index ef2db7b2..8121095d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.2.0", + "version": "0.2.1", "private": true, "type": "module", "scripts": { diff --git a/main/package.json b/main/package.json index a3ec63c1..9ddd7c16 100644 --- a/main/package.json +++ b/main/package.json @@ -1,6 +1,6 @@ { "name": "main", - "version": "0.2.0", + "version": "0.2.1", "description": "Electron main process for Claude Code Commander", "main": "dist/main/src/index.js", "type": "commonjs", diff --git a/package.json b/package.json index 1553b5a5..d358d2a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "crystal", - "version": "0.2.0", + "version": "0.2.1", "private": true, "description": "Crystal - Claude Code Commander for managing multiple Claude Code instances", "author": { diff --git a/shared/package.json b/shared/package.json index 0289da03..f015e33f 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "shared", - "version": "0.2.0", + "version": "0.2.1", "private": true, "main": "index.js", "types": "index.d.ts", From e978e3841ee25e6fc0b2d4704f37673ea1f78f03 Mon Sep 17 00:00:00 2001 From: Jordan Bentley Date: Fri, 22 Aug 2025 17:30:34 -0400 Subject: [PATCH 14/30] performance improvements --- frontend/src/components/ProjectView.tsx | 13 +++- frontend/src/components/SessionListItem.tsx | 23 +------ frontend/src/components/SessionView.tsx | 73 ++++++++++++--------- frontend/src/hooks/useSessionView.ts | 23 +++++-- frontend/src/stores/sessionStore.ts | 46 ++++++++++--- frontend/src/types/electron.d.ts | 2 +- frontend/src/utils/api.ts | 4 +- main/src/ipc/session.ts | 55 ++++++++++------ main/src/preload.ts | 2 +- 9 files changed, 149 insertions(+), 92 deletions(-) diff --git a/frontend/src/components/ProjectView.tsx b/frontend/src/components/ProjectView.tsx index e92add92..2158c0f7 100644 --- a/frontend/src/components/ProjectView.tsx +++ b/frontend/src/components/ProjectView.tsx @@ -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'; @@ -354,7 +355,7 @@ export const ProjectView: React.FC = ({ // 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 +366,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/SessionListItem.tsx b/frontend/src/components/SessionListItem.tsx index 50da858e..6785ebda 100644 --- a/frontend/src/components/SessionListItem.tsx +++ b/frontend/src/components/SessionListItem.tsx @@ -35,26 +35,9 @@ export const SessionListItem = memo(function SessionListItem({ session, isNested 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 diff --git a/frontend/src/components/SessionView.tsx b/frontend/src/components/SessionView.tsx index 7c241819..23676034 100644 --- a/frontend/src/components/SessionView.tsx +++ b/frontend/src/components/SessionView.tsx @@ -31,11 +31,14 @@ export const SessionView = memo(() => { 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; + // Memoize to avoid recomputing on every render + const activeSession = useMemo(() => { + if (!activeSessionId) return undefined; + if (activeMainRepoSession && activeMainRepoSession.id === activeSessionId) { + return activeMainRepoSession; + } + return sessions.find(s => s.id === activeSessionId); + }, [activeSessionId, activeMainRepoSession, sessions]); // Load project data for active session @@ -225,22 +228,26 @@ export const SessionView = memo(() => { {hook.isLoadingOutput && hook.viewMode !== 'richOutput' && (
Loading output...
)} -
- -
-
- -
+ {hook.viewMode === 'richOutput' && ( +
+ +
+ )} + {hook.viewMode === 'changes' && ( +
+ +
+ )}
@@ -294,15 +301,21 @@ export const SessionView = memo(() => { )}
-
- -
-
- -
-
- -
+ {hook.viewMode === 'logs' && ( +
+ +
+ )} + {hook.viewMode === 'editor' && ( +
+ +
+ )} + {hook.viewMode === 'messages' && ( +
+ +
+ )}
diff --git a/frontend/src/hooks/useSessionView.ts b/frontend/src/hooks/useSessionView.ts index 4c610474..e42c5425 100644 --- a/frontend/src/hooks/useSessionView.ts +++ b/frontend/src/hooks/useSessionView.ts @@ -524,8 +524,10 @@ export const useSessionView = ( forceResetLoadingState ]); - // Listen for output available events + // Listen for output available events with debouncing for performance useEffect(() => { + let reloadDebounceTimer: NodeJS.Timeout | null = null; + const handleOutputAvailable = (event: CustomEvent) => { const { sessionId } = event.detail; @@ -534,13 +536,26 @@ export const useSessionView = ( // Trigger reload if we're loaded or if we're continuing a conversation if (outputLoadState === 'loaded' || isContinuingConversationRef.current) { console.log(`[Output Available] New output for active session ${sessionId}, requesting reload (state: ${outputLoadState}, continuing: ${isContinuingConversationRef.current})`); - setShouldReloadOutput(true); + + // Debounce rapid output updates to avoid excessive re-renders + if (reloadDebounceTimer) { + clearTimeout(reloadDebounceTimer); + } + reloadDebounceTimer = setTimeout(() => { + setShouldReloadOutput(true); + reloadDebounceTimer = null; + }, 100); } } }; window.addEventListener('session-output-available', handleOutputAvailable as EventListener); - return () => window.removeEventListener('session-output-available', handleOutputAvailable as EventListener); + return () => { + window.removeEventListener('session-output-available', handleOutputAvailable as EventListener); + if (reloadDebounceTimer) { + clearTimeout(reloadDebounceTimer); + } + }; }, [activeSession?.id, outputLoadState]); const initTerminal = useCallback((termRef: React.RefObject | undefined, instanceRef: React.MutableRefObject, fitAddonRef: React.MutableRefObject, isScript: boolean) => { @@ -561,7 +576,7 @@ export const useSessionView = ( convertEol: true, rows: 30, cols: 80, - scrollback: 100000, // Unlimited terminal output support + scrollback: 50000, // Reduced from 100000 for better performance fastScrollModifier: 'ctrl', fastScrollSensitivity: 5, scrollSensitivity: 1, diff --git a/frontend/src/stores/sessionStore.ts b/frontend/src/stores/sessionStore.ts index 55e0abec..e5726a89 100644 --- a/frontend/src/stores/sessionStore.ts +++ b/frontend/src/stores/sessionStore.ts @@ -132,10 +132,15 @@ export const useSessionStore = create((set, get) => ({ ? null : state.activeMainRepoSession; + // Clean up terminal output for deleted session to free memory + const newTerminalOutput = { ...state.terminalOutput }; + delete newTerminalOutput[deletedSession.id]; + return { sessions: state.sessions.filter(session => session.id !== deletedSession.id), activeSessionId: state.activeSessionId === deletedSession.id ? null : state.activeSessionId, - activeMainRepoSession: newActiveMainRepoSession + activeMainRepoSession: newActiveMainRepoSession, + terminalOutput: newTerminalOutput }; }), @@ -306,23 +311,44 @@ export const useSessionStore = create((set, get) => ({ setSessionOutputs: (sessionId, outputs) => set((state) => { console.log(`[SessionStore] Setting ${outputs.length} outputs for session ${sessionId}`); - // Separate outputs and JSON messages + // Performance optimization: Process outputs in chunks for large datasets + const MAX_CHUNK_SIZE = 1000; const stdOutputs: string[] = []; const jsonMessages: any[] = []; - outputs.forEach(output => { - if (output.type === 'json') { - jsonMessages.push({ ...output.data, timestamp: output.timestamp }); - } else if (output.type === 'stdout' || output.type === 'stderr') { - stdOutputs.push(output.data); + // Process outputs in chunks to avoid blocking the main thread + for (let i = 0; i < outputs.length; i += MAX_CHUNK_SIZE) { + const chunk = outputs.slice(i, Math.min(i + MAX_CHUNK_SIZE, outputs.length)); + + for (const output of chunk) { + if (output.type === 'json') { + jsonMessages.push({ ...output.data, timestamp: output.timestamp }); + } else if (output.type === 'stdout' || output.type === 'stderr') { + stdOutputs.push(output.data); + } } - }); + } + + // Memory optimization: Limit stored outputs to prevent unbounded growth + const MAX_STORED_OUTPUTS = 10000; + const MAX_STORED_MESSAGES = 5000; + + const trimmedOutputs = stdOutputs.length > MAX_STORED_OUTPUTS + ? stdOutputs.slice(-MAX_STORED_OUTPUTS) + : stdOutputs; + const trimmedMessages = jsonMessages.length > MAX_STORED_MESSAGES + ? jsonMessages.slice(-MAX_STORED_MESSAGES) + : jsonMessages; + + if (stdOutputs.length > MAX_STORED_OUTPUTS) { + console.warn(`[SessionStore] Trimmed outputs from ${stdOutputs.length} to ${MAX_STORED_OUTPUTS} for performance`); + } // Always update the sessions array const updatedSessions = state.sessions.map(session => { if (session.id === sessionId) { - return { ...session, output: stdOutputs, jsonMessages }; + return { ...session, output: trimmedOutputs, jsonMessages: trimmedMessages }; } return session; }); @@ -331,7 +357,7 @@ export const useSessionStore = create((set, get) => ({ let updatedActiveMainRepoSession = state.activeMainRepoSession; if (state.activeMainRepoSession && state.activeMainRepoSession.id === sessionId) { console.log(`[SessionStore] Also updating activeMainRepoSession`); - updatedActiveMainRepoSession = { ...state.activeMainRepoSession, output: stdOutputs, jsonMessages }; + updatedActiveMainRepoSession = { ...state.activeMainRepoSession, output: trimmedOutputs, jsonMessages: trimmedMessages }; } return { diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index 04d71189..1f130552 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -41,7 +41,7 @@ interface ElectronAPI { delete: (sessionId: string) => Promise; sendInput: (sessionId: string, input: string) => Promise; continue: (sessionId: string, prompt?: string, model?: string) => Promise; - getOutput: (sessionId: string) => Promise; + getOutput: (sessionId: string, limit?: number) => Promise; getJsonMessages: (sessionId: string) => Promise; getConversation: (sessionId: string) => Promise; getConversationMessages: (sessionId: string) => Promise; diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts index a8d62de1..8905c175 100644 --- a/frontend/src/utils/api.ts +++ b/frontend/src/utils/api.ts @@ -58,9 +58,9 @@ export class API { return window.electronAPI.sessions.continue(sessionId, prompt, model); }, - async getOutput(sessionId: string) { + async getOutput(sessionId: string, limit?: number) { if (!isElectron()) throw new Error('Electron API not available'); - return window.electronAPI.sessions.getOutput(sessionId); + return window.electronAPI.sessions.getOutput(sessionId, limit); }, async getJsonMessages(sessionId: string) { if (!isElectron()) throw new Error('Electron API not available'); diff --git a/main/src/ipc/session.ts b/main/src/ipc/session.ts index a08d9e2b..6622af65 100644 --- a/main/src/ipc/session.ts +++ b/main/src/ipc/session.ts @@ -458,10 +458,14 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('sessions:get-output', async (_event, sessionId: string) => { + ipcMain.handle('sessions:get-output', async (_event, sessionId: string, limit?: number) => { try { - console.log(`[IPC] sessions:get-output called for session: ${sessionId}`); - const outputs = await sessionManager.getSessionOutputs(sessionId); + // Performance optimization: Default to loading only recent outputs + const DEFAULT_OUTPUT_LIMIT = 5000; + const outputLimit = limit || DEFAULT_OUTPUT_LIMIT; + + console.log(`[IPC] sessions:get-output called for session: ${sessionId} with limit: ${outputLimit}`); + const outputs = await sessionManager.getSessionOutputs(sessionId, outputLimit); console.log(`[IPC] Retrieved ${outputs.length} outputs for session ${sessionId}`); // Refresh git status when session is loaded/viewed @@ -472,26 +476,35 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) }); } - // Transform JSON messages to formatted stdout on the fly + // Performance optimization: Process outputs in batches to avoid blocking const { formatJsonForOutputEnhanced } = await import('../utils/toolFormatter'); - const transformedOutputs = outputs.map(output => { - if (output.type === 'json') { - // Generate formatted output from JSON - const outputText = formatJsonForOutputEnhanced(output.data); - if (outputText) { - // Return as stdout for the Output view - return { - ...output, - type: 'stdout' as const, - data: outputText - }; + const BATCH_SIZE = 100; + const transformedOutputs = []; + + for (let i = 0; i < outputs.length; i += BATCH_SIZE) { + const batch = outputs.slice(i, Math.min(i + BATCH_SIZE, outputs.length)); + + const transformedBatch = batch.map(output => { + if (output.type === 'json') { + // Generate formatted output from JSON + const outputText = formatJsonForOutputEnhanced(output.data); + if (outputText) { + // Return as stdout for the Output view + return { + ...output, + type: 'stdout' as const, + data: outputText + }; + } + // If no output format can be generated, skip this JSON message + return null; } - // If no output format can be generated, skip this JSON message - return null; - } - // Pass through all other output types including 'error' - return output; - }).filter(Boolean); // Remove any null entries + // Pass through all other output types including 'error' + return output; + }).filter(Boolean); + + transformedOutputs.push(...transformedBatch); + } // Remove any null entries return { success: true, data: transformedOutputs }; } catch (error) { console.error('Failed to get session outputs:', error); diff --git a/main/src/preload.ts b/main/src/preload.ts index 30c36390..48890a79 100644 --- a/main/src/preload.ts +++ b/main/src/preload.ts @@ -84,7 +84,7 @@ contextBridge.exposeInMainWorld('electronAPI', { delete: (sessionId: string): Promise => ipcRenderer.invoke('sessions:delete', sessionId), sendInput: (sessionId: string, input: string): Promise => ipcRenderer.invoke('sessions:input', sessionId, input), continue: (sessionId: string, prompt?: string, model?: string): Promise => ipcRenderer.invoke('sessions:continue', sessionId, prompt, model), - getOutput: (sessionId: string): Promise => ipcRenderer.invoke('sessions:get-output', sessionId), + getOutput: (sessionId: string, limit?: number): Promise => ipcRenderer.invoke('sessions:get-output', sessionId, limit), getJsonMessages: (sessionId: string): Promise => ipcRenderer.invoke('sessions:get-json-messages', sessionId), getConversation: (sessionId: string): Promise => ipcRenderer.invoke('sessions:get-conversation', sessionId), getConversationMessages: (sessionId: string): Promise => ipcRenderer.invoke('sessions:get-conversation-messages', sessionId), From c4d1550b1319844a563b4f11006ea53afedf7b84 Mon Sep 17 00:00:00 2001 From: Jordan Bentley Date: Fri, 22 Aug 2025 18:40:22 -0400 Subject: [PATCH 15/30] 0.2.2 RC --- CHANGELOG.md | 5 +++++ frontend/package.json | 2 +- main/package.json | 2 +- package.json | 2 +- shared/package.json | 2 +- 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8836fbdb..c5d852a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ All notable changes to Crystal will be documented in this file. +## [0.2.2] - 2025-08-22 + +### Changed +- **Performance improvements** - Optimized application performance for faster response times and smoother operation + ## [0.2.1] - 2025-08-21 ### Added diff --git a/frontend/package.json b/frontend/package.json index 8121095d..579a756b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.2.1", + "version": "0.2.2", "private": true, "type": "module", "scripts": { diff --git a/main/package.json b/main/package.json index 9ddd7c16..698ff77d 100644 --- a/main/package.json +++ b/main/package.json @@ -1,6 +1,6 @@ { "name": "main", - "version": "0.2.1", + "version": "0.2.2", "description": "Electron main process for Claude Code Commander", "main": "dist/main/src/index.js", "type": "commonjs", diff --git a/package.json b/package.json index d358d2a3..c8b544a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "crystal", - "version": "0.2.1", + "version": "0.2.2", "private": true, "description": "Crystal - Claude Code Commander for managing multiple Claude Code instances", "author": { diff --git a/shared/package.json b/shared/package.json index f015e33f..60367a30 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "shared", - "version": "0.2.1", + "version": "0.2.2", "private": true, "main": "index.js", "types": "index.d.ts", From 84fd1cd94cd0152a1d832fa4e9a9d084b49b11d2 Mon Sep 17 00:00:00 2001 From: Jordan Bentley Date: Fri, 22 Aug 2025 19:49:37 -0400 Subject: [PATCH 16/30] more performance improvements --- frontend/src/components/ProjectView.tsx | 16 +++-- .../src/components/session/RichOutputView.tsx | 57 ++++++++++++----- frontend/src/hooks/useSessionView.ts | 18 ++++-- frontend/src/stores/sessionStore.ts | 64 +++++++++++-------- 4 files changed, 103 insertions(+), 52 deletions(-) diff --git a/frontend/src/components/ProjectView.tsx b/frontend/src/components/ProjectView.tsx index 2158c0f7..416022d5 100644 --- a/frontend/src/components/ProjectView.tsx +++ b/frontend/src/components/ProjectView.tsx @@ -280,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); } }); @@ -312,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 diff --git a/frontend/src/components/session/RichOutputView.tsx b/frontend/src/components/session/RichOutputView.tsx index e3855636..6832f8f6 100644 --- a/frontend/src/components/session/RichOutputView.tsx +++ b/frontend/src/components/session/RichOutputView.tsx @@ -184,38 +184,47 @@ export const RichOutputView = React.forwardRef<{ scrollToPrompt: (promptIndex: n const transformMessages = (rawMessages: RawMessage[]): ConversationMessage[] => { const transformed: ConversationMessage[] = []; + // 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(); const parentToolMap = new Map(); // Map tool ID to parent tool ID // Identify all tool calls and their parent relationships first - rawMessages.forEach(msg => { + 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)) { - 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_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 all tool calls to prepare for hierarchy const allToolCalls = new Map(); - rawMessages.forEach(msg => { + for (let i = 0; i < rawMessages.length; i++) { + const msg = rawMessages[i]; if (msg.type === 'assistant' && 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_use') { const isTaskAgent = block.name === 'Task'; const toolCall: ToolCall = { @@ -231,19 +240,21 @@ export const RichOutputView = React.forwardRef<{ scrollToPrompt: (promptIndex: n }; allToolCalls.set(block.id, toolCall); } - }); + } } - }); + } // Build parent-child relationships - allToolCalls.forEach((toolCall) => { + 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++) { @@ -255,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) @@ -287,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') { @@ -303,7 +326,7 @@ export const RichOutputView = React.forwardRef<{ scrollToPrompt: (promptIndex: n segments.push({ type: 'tool_call', tool: toolCall }); } } - }); + } } else { // Fallback for other formats const textContent = extractTextContent(msg); diff --git a/frontend/src/hooks/useSessionView.ts b/frontend/src/hooks/useSessionView.ts index e42c5425..8a66b9b4 100644 --- a/frontend/src/hooks/useSessionView.ts +++ b/frontend/src/hooks/useSessionView.ts @@ -273,12 +273,16 @@ export const useSessionView = ( useEffect(() => { if (!activeSessionId) return; + // Performance optimization: Check session status only, not entire state + let previousStatus = activeSession?.status; const unsubscribe = useSessionStore.subscribe((state) => { const updatedSession = state.activeMainRepoSession?.id === activeSessionId ? state.activeMainRepoSession : state.sessions.find(s => s.id === activeSessionId); - if (updatedSession && updatedSession.status !== activeSession?.status) { + // Only trigger update if status actually changed + if (updatedSession && updatedSession.status !== previousStatus) { + previousStatus = updatedSession.status; if (activeSession?.status === 'initializing' && updatedSession.status === 'running') { // Only clear terminal and reload for new sessions, not when continuing conversations const hasExistingOutput = activeSession.output && activeSession.output.length > 0; @@ -305,11 +309,17 @@ export const useSessionView = ( setScriptOutput([]); return; } + // Performance optimization: Track previous terminal output to avoid unnecessary updates + let previousOutput = useSessionStore.getState().terminalOutput[activeSession.id]; const unsubscribe = useSessionStore.subscribe((state) => { const sessionTerminalOutput = state.terminalOutput[activeSession.id] || []; - setScriptOutput(sessionTerminalOutput); - // Terminal is now independent - no automatic unread indicators - // Users explicitly interact with the terminal, so they know when there's output + // Only update if output actually changed + if (sessionTerminalOutput !== previousOutput) { + previousOutput = sessionTerminalOutput; + setScriptOutput(sessionTerminalOutput); + // Terminal is now independent - no automatic unread indicators + // Users explicitly interact with the terminal, so they know when there's output + } }); setScriptOutput(useSessionStore.getState().terminalOutput[activeSession.id] || []); return unsubscribe; diff --git a/frontend/src/stores/sessionStore.ts b/frontend/src/stores/sessionStore.ts index e5726a89..04fbea97 100644 --- a/frontend/src/stores/sessionStore.ts +++ b/frontend/src/stores/sessionStore.ts @@ -311,21 +311,17 @@ export const useSessionStore = create((set, get) => ({ setSessionOutputs: (sessionId, outputs) => set((state) => { console.log(`[SessionStore] Setting ${outputs.length} outputs for session ${sessionId}`); - // Performance optimization: Process outputs in chunks for large datasets - const MAX_CHUNK_SIZE = 1000; + // Performance optimization: Use simple loops instead of array methods for large datasets const stdOutputs: string[] = []; const jsonMessages: any[] = []; - // Process outputs in chunks to avoid blocking the main thread - for (let i = 0; i < outputs.length; i += MAX_CHUNK_SIZE) { - const chunk = outputs.slice(i, Math.min(i + MAX_CHUNK_SIZE, outputs.length)); - - for (const output of chunk) { - if (output.type === 'json') { - jsonMessages.push({ ...output.data, timestamp: output.timestamp }); - } else if (output.type === 'stdout' || output.type === 'stderr') { - stdOutputs.push(output.data); - } + // Use a simple for loop to avoid iterator overhead + for (let i = 0; i < outputs.length; i++) { + const output = outputs[i]; + if (output.type === 'json') { + jsonMessages.push({ ...output.data, timestamp: output.timestamp }); + } else if (output.type === 'stdout' || output.type === 'stderr') { + stdOutputs.push(output.data); } } @@ -345,13 +341,18 @@ export const useSessionStore = create((set, get) => ({ console.warn(`[SessionStore] Trimmed outputs from ${stdOutputs.length} to ${MAX_STORED_OUTPUTS} for performance`); } - // Always update the sessions array - const updatedSessions = state.sessions.map(session => { - if (session.id === sessionId) { - return { ...session, output: trimmedOutputs, jsonMessages: trimmedMessages }; + // Performance optimization: Only update the specific session instead of mapping all sessions + let updatedSessions = state.sessions; + + // Use a for loop for better performance with large arrays + for (let i = 0; i < state.sessions.length; i++) { + if (state.sessions[i].id === sessionId) { + const newSession = { ...state.sessions[i], output: trimmedOutputs, jsonMessages: trimmedMessages }; + updatedSessions = [...state.sessions]; + updatedSessions[i] = newSession; + break; } - return session; - }); + } // Also update activeMainRepoSession if it matches let updatedActiveMainRepoSession = state.activeMainRepoSession; @@ -403,15 +404,24 @@ export const useSessionStore = create((set, get) => ({ } }, - addTerminalOutput: (output) => set((state) => ({ - terminalOutput: { - ...state.terminalOutput, - [output.sessionId]: [ - ...(state.terminalOutput[output.sessionId] || []), - output.data - ] - } - })), + addTerminalOutput: (output) => set((state) => { + // Performance optimization: Use direct array mutation for terminal output + const existingOutput = state.terminalOutput[output.sessionId] || []; + const newOutput = [...existingOutput, output.data]; + + // Limit terminal output to prevent memory issues + const MAX_TERMINAL_LINES = 10000; + const trimmedOutput = newOutput.length > MAX_TERMINAL_LINES + ? newOutput.slice(-MAX_TERMINAL_LINES) + : newOutput; + + return { + terminalOutput: { + ...state.terminalOutput, + [output.sessionId]: trimmedOutput + } + }; + }), clearTerminalOutput: (sessionId: string) => set((state) => ({ terminalOutput: { From 69c7bb08d83c5a106435f64fa894a3a86ea4dbc6 Mon Sep 17 00:00:00 2001 From: Parminder Klair Date: Wed, 27 Aug 2025 11:36:30 +0100 Subject: [PATCH 17/30] Add Homebrew installation instructions --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4d593beb..d643405e 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,10 @@ 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 +``` ## Building from Source @@ -132,7 +136,7 @@ pnpm build:arch ```bash # On Arch Linux sudo pacman -S bsdtar - + # On Ubuntu/Debian (for cross-platform builds) sudo apt-get install bsdtar ``` @@ -154,7 +158,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 From b8bbada023e5e8fcf1315079288acad7b5ee4fa2 Mon Sep 17 00:00:00 2001 From: Jordan Bentley Date: Fri, 22 Aug 2025 21:08:39 -0400 Subject: [PATCH 18/30] even more performance improvements --- CHANGELOG.md | 3 ++ frontend/src/stores/sessionStore.ts | 83 ++++++++++++++++++----------- 2 files changed, 56 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5d852a2..831c39c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ All notable changes to Crystal will be documented in this file. ## [0.2.2] - 2025-08-22 +### 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 diff --git a/frontend/src/stores/sessionStore.ts b/frontend/src/stores/sessionStore.ts index 04fbea97..90262606 100644 --- a/frontend/src/stores/sessionStore.ts +++ b/frontend/src/stores/sessionStore.ts @@ -106,19 +106,22 @@ export const useSessionStore = create((set, get) => ({ } // Otherwise update in regular sessions - const newSessions = state.sessions.map(session => { - if (session.id === updatedSession.id) { + // Performance: Only clone array if session exists + let newSessions = state.sessions; + for (let i = 0; i < state.sessions.length; i++) { + if (state.sessions[i].id === updatedSession.id) { + newSessions = state.sessions.slice(); const updatedSessionWithOutput = { - ...session, + ...state.sessions[i], ...updatedSession, - output: session.output, - jsonMessages: session.jsonMessages + output: state.sessions[i].output, + jsonMessages: state.sessions[i].jsonMessages }; - console.log(`[SessionStore] Updated session ${updatedSession.id} model: ${session.model} -> ${updatedSessionWithOutput.model}`); - return updatedSessionWithOutput; + console.log(`[SessionStore] Updated session ${updatedSession.id} model: ${state.sessions[i].model} -> ${updatedSessionWithOutput.model}`); + newSessions[i] = updatedSessionWithOutput; + break; } - return session; - }); + } return { ...state, @@ -250,8 +253,8 @@ export const useSessionStore = create((set, get) => ({ return state; } - // Update sessions array - const sessions = [...state.sessions]; + // Performance: Only clone sessions array once + const sessions = state.sessions.slice(); const session = sessions[sessionIndex]; if (output.type === 'json') { @@ -288,12 +291,15 @@ export const useSessionStore = create((set, get) => ({ }), setSessionOutput: (sessionId, output) => set((state) => { - // Update sessions array - const updatedSessions = state.sessions.map(session => - session.id === sessionId - ? { ...session, output: [output] } - : session - ); + // Performance: Only clone array if session exists + let updatedSessions = state.sessions; + for (let i = 0; i < state.sessions.length; i++) { + if (state.sessions[i].id === sessionId) { + updatedSessions = state.sessions.slice(); + updatedSessions[i] = { ...state.sessions[i], output: [output] }; + break; + } + } // Update activeMainRepoSession if it matches let updatedActiveMainRepoSession = state.activeMainRepoSession; @@ -341,14 +347,19 @@ export const useSessionStore = create((set, get) => ({ console.warn(`[SessionStore] Trimmed outputs from ${stdOutputs.length} to ${MAX_STORED_OUTPUTS} for performance`); } - // Performance optimization: Only update the specific session instead of mapping all sessions + // Performance optimization: Only create new array if session is found let updatedSessions = state.sessions; + let sessionFound = false; // Use a for loop for better performance with large arrays for (let i = 0; i < state.sessions.length; i++) { if (state.sessions[i].id === sessionId) { const newSession = { ...state.sessions[i], output: trimmedOutputs, jsonMessages: trimmedMessages }; - updatedSessions = [...state.sessions]; + // Only create new array when we actually find the session to update + if (!sessionFound) { + updatedSessions = state.sessions.slice(); // Shallow copy is more efficient than spread + sessionFound = true; + } updatedSessions[i] = newSession; break; } @@ -369,12 +380,15 @@ export const useSessionStore = create((set, get) => ({ }), clearSessionOutput: (sessionId) => set((state) => { - // Update sessions array - const updatedSessions = state.sessions.map(session => - session.id === sessionId - ? { ...session, output: [], jsonMessages: [] } - : session - ); + // Performance: Only clone array if session exists + let updatedSessions = state.sessions; + for (let i = 0; i < state.sessions.length; i++) { + if (state.sessions[i].id === sessionId) { + updatedSessions = state.sessions.slice(); + updatedSessions[i] = { ...state.sessions[i], output: [], jsonMessages: [] }; + break; + } + } // Update activeMainRepoSession if it matches let updatedActiveMainRepoSession = state.activeMainRepoSession; @@ -551,11 +565,20 @@ export const useSessionStore = create((set, get) => ({ newLoadingSet.delete(sessionId); }); - // Update sessions in one pass - const sessions = state.sessions.map(session => { - const newStatus = statusUpdates.get(session.id); - return newStatus ? { ...session, gitStatus: newStatus } : session; - }); + // Performance: Only clone sessions array if updates affect sessions + let sessions = state.sessions; + let sessionsModified = false; + + for (let i = 0; i < state.sessions.length; i++) { + const newStatus = statusUpdates.get(state.sessions[i].id); + if (newStatus) { + if (!sessionsModified) { + sessions = state.sessions.slice(); + sessionsModified = true; + } + sessions[i] = { ...state.sessions[i], gitStatus: newStatus }; + } + } // Update main repo session if needed let activeMainRepoSession = state.activeMainRepoSession; From 134c812bb4774345c4d3443f8485aba70ae1362c Mon Sep 17 00:00:00 2001 From: Jordan Bentley Date: Sat, 23 Aug 2025 13:38:09 -0400 Subject: [PATCH 19/30] fix zustrand subscription cascade --- frontend/src/App.tsx | 44 +++++ .../components/DraggableProjectTreeView.tsx | 18 +-- frontend/src/components/ProjectView.tsx | 24 ++- frontend/src/components/PromptNavigation.tsx | 33 +++- frontend/src/components/SessionListItem.tsx | 9 +- frontend/src/components/SessionView.tsx | 13 +- frontend/src/components/StatusIndicator.tsx | 11 +- frontend/src/hooks/useSessionView.ts | 138 ++++++++++------ frontend/src/stores/sessionStore.ts | 150 ++++++++++++++---- 9 files changed, 323 insertions(+), 117 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 587e16ca..4e2bacd1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -57,6 +57,50 @@ function App() { useEffect(() => { 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/DraggableProjectTreeView.tsx b/frontend/src/components/DraggableProjectTreeView.tsx index 44769912..23ba1378 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) diff --git a/frontend/src/components/ProjectView.tsx b/frontend/src/components/ProjectView.tsx index 416022d5..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'; @@ -338,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) { @@ -357,7 +375,7 @@ 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 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 6785ebda..5004e7c1 100644 --- a/frontend/src/components/SessionListItem.tsx +++ b/frontend/src/components/SessionListItem.tsx @@ -30,9 +30,7 @@ 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); - - // Selective subscription for git status loading state - const gitStatusLoading = useSessionStore((state) => state.gitStatusLoading.has(session.id)); + const [gitStatusLoading, setGitStatusLoading] = useState(false); // Performance optimization: Remove unnecessary subscription @@ -107,13 +105,15 @@ export const SessionListItem = memo(function SessionListItem({ session, isNested // Fetch Git status for this session const fetchGitStatus = async () => { try { - // Don't set loading state here anymore - it's handled by backend events + setGitStatusLoading(true); const response = await window.electronAPI.invoke('sessions:get-git-status', session.id); if (response.success && response.gitStatus) { setGitStatus(response.gitStatus); } } catch (error) { console.error('Error fetching git status:', error); + } finally { + setGitStatusLoading(false); } }; @@ -129,6 +129,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 23676034..ecec600a 100644 --- a/frontend/src/components/SessionView.tsx +++ b/frontend/src/components/SessionView.tsx @@ -22,23 +22,18 @@ 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 getActiveSession = useSessionStore((state) => state.getActiveSession); 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 - // Memoize to avoid recomputing on every render + // Get active session using the store's method - avoids subscribing to entire sessions array const activeSession = useMemo(() => { if (!activeSessionId) return undefined; - if (activeMainRepoSession && activeMainRepoSession.id === activeSessionId) { - return activeMainRepoSession; - } - return sessions.find(s => s.id === activeSessionId); - }, [activeSessionId, activeMainRepoSession, sessions]); + return getActiveSession(); + }, [activeSessionId, getActiveSession]); // Load project data for active session 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/hooks/useSessionView.ts b/frontend/src/hooks/useSessionView.ts index 8a66b9b4..b132a18f 100644 --- a/frontend/src/hooks/useSessionView.ts +++ b/frontend/src/hooks/useSessionView.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useSessionStore } from '../stores/sessionStore'; import { useTheme } from '../contexts/ThemeContext'; import { useErrorStore } from '../stores/errorStore'; @@ -411,6 +411,40 @@ export const useSessionView = ( const messageCount = activeSession?.jsonMessages?.length || 0; const outputCount = activeSession?.output?.length || 0; + // Performance optimization: Use useMemo to cache the expensive join operation + const formattedOutputMemo = useMemo(() => { + if (!activeSession || currentSessionIdForOutput !== activeSession.id) { + return ''; + } + + const outputArray = activeSession.output || []; + if (outputArray.length === 0) { + return ''; + } + + // CRITICAL PERFORMANCE FIX: Much more aggressive limiting + // Process only recent output to avoid massive array operations that lock up V8 + const MAX_OUTPUT_TO_PROCESS = 500; // Drastically reduced from 2000 + const outputToProcess = outputArray.length > MAX_OUTPUT_TO_PROCESS + ? outputArray.slice(-MAX_OUTPUT_TO_PROCESS) + : outputArray; + + // PERFORMANCE: Build string in chunks to avoid V8 string concatenation issues + if (outputToProcess.length > 100) { + // For large arrays, build in chunks to avoid V8 optimization bailouts + const chunks: string[] = []; + const chunkSize = 50; + for (let i = 0; i < outputToProcess.length; i += chunkSize) { + const chunk = outputToProcess.slice(i, Math.min(i + chunkSize, outputToProcess.length)); + chunks.push(chunk.join('')); + } + return chunks.join(''); + } else { + // For small arrays, direct join is fine + return outputToProcess.join(''); + } + }, [activeSession?.id, currentSessionIdForOutput, outputCount]); + useEffect(() => { if (!activeSession) return; @@ -432,38 +466,14 @@ export const useSessionView = ( setIsWaitingForFirstOutput(false); } - const formatOutput = () => { - const currentActiveSession = useSessionStore.getState().getActiveSession(); - if (!currentActiveSession || currentActiveSession.id !== activeSession.id) { - console.log(`[formatOutput] Session changed during formatting, aborting`); - return; - } - - // The output array should already contain formatted strings from the IPC handler - const outputArray = currentActiveSession.output || []; - console.log(`[formatOutput] Session ${activeSession.id} has ${outputArray.length} output items`); - - if (outputArray.length > 0) { - console.log(`[formatOutput] First output item preview: ${outputArray[0].substring(0, 200)}`); - } - - const formatted = outputArray.join(''); - - // Double-check we're still on the same session before updating - const finalActiveSession = useSessionStore.getState().getActiveSession(); - if (finalActiveSession && finalActiveSession.id === activeSession.id && currentSessionIdForOutput === activeSession.id) { - console.log(`[formatOutput] Setting formatted output for session ${activeSession.id}, length: ${formatted.length}, first 100 chars: ${formatted.substring(0, 100)}`); - setFormattedOutput(formatted); - } else { - console.log(`[formatOutput] Session mismatch during final check - finalActiveSession.id: ${finalActiveSession?.id}, activeSession.id: ${activeSession.id}, currentSessionIdForOutput: ${currentSessionIdForOutput}`); - } - }; + // PERFORMANCE FIX: More aggressive debouncing for large outputs + const delay = outputCount > 100 ? 200 : 50; // Longer delay for large outputs + const timeoutId = setTimeout(() => { + setFormattedOutput(formattedOutputMemo); + }, delay); - // Use requestAnimationFrame to ensure state is settled - requestAnimationFrame(() => { - formatOutput(); - }); - }, [activeSession?.id, messageCount, outputCount, currentSessionIdForOutput, isWaitingForFirstOutput]); + return () => clearTimeout(timeoutId); + }, [activeSession?.id, messageCount, outputCount, currentSessionIdForOutput, isWaitingForFirstOutput, formattedOutputMemo]); // Consolidated effect for loading output useEffect(() => { @@ -534,9 +544,11 @@ export const useSessionView = ( forceResetLoadingState ]); - // Listen for output available events with debouncing for performance + // Listen for output available events with aggressive throttling for performance useEffect(() => { let reloadDebounceTimer: NodeJS.Timeout | null = null; + let lastReloadTime = 0; + const MIN_RELOAD_INTERVAL = 1000; // Increased to 1 second to prevent rapid reloads const handleOutputAvailable = (event: CustomEvent) => { const { sessionId } = event.detail; @@ -545,16 +557,26 @@ export const useSessionView = ( if (activeSession?.id === sessionId) { // Trigger reload if we're loaded or if we're continuing a conversation if (outputLoadState === 'loaded' || isContinuingConversationRef.current) { - console.log(`[Output Available] New output for active session ${sessionId}, requesting reload (state: ${outputLoadState}, continuing: ${isContinuingConversationRef.current})`); + const now = Date.now(); + const timeSinceLastReload = now - lastReloadTime; - // Debounce rapid output updates to avoid excessive re-renders - if (reloadDebounceTimer) { - clearTimeout(reloadDebounceTimer); - } - reloadDebounceTimer = setTimeout(() => { + // PERFORMANCE FIX: Throttle reloads to prevent CPU overload + if (timeSinceLastReload < MIN_RELOAD_INTERVAL) { + // Schedule for later if too soon + if (reloadDebounceTimer) { + clearTimeout(reloadDebounceTimer); + } + reloadDebounceTimer = setTimeout(() => { + setShouldReloadOutput(true); + lastReloadTime = Date.now(); + reloadDebounceTimer = null; + }, MIN_RELOAD_INTERVAL - timeSinceLastReload); + } else { + // Can reload immediately + console.log(`[Output Available] Reloading output for session ${sessionId}`); setShouldReloadOutput(true); - reloadDebounceTimer = null; - }, 100); + lastReloadTime = now; + } } } }; @@ -586,7 +608,7 @@ export const useSessionView = ( convertEol: true, rows: 30, cols: 80, - scrollback: 50000, // Reduced from 100000 for better performance + scrollback: 10000, // Further reduced to prevent memory issues fastScrollModifier: 'ctrl', fastScrollSensitivity: 5, scrollSensitivity: 1, @@ -706,14 +728,38 @@ export const useSessionView = ( lastProcessedScriptOutputLength.current = 0; }, [activeSessionId]); + // Performance: Memoize terminal output join operation + const fullScriptOutputMemo = useMemo(() => { + if (!scriptOutput || scriptOutput.length === 0) return ''; + + // CRITICAL PERFORMANCE FIX: Much more aggressive limit + const MAX_TERMINAL_OUTPUT = 300; // Drastically reduced from 1000 + const outputToProcess = scriptOutput.length > MAX_TERMINAL_OUTPUT + ? scriptOutput.slice(-MAX_TERMINAL_OUTPUT) + : scriptOutput; + + // Build in chunks for better performance + if (outputToProcess.length > 50) { + const chunks: string[] = []; + const chunkSize = 25; + for (let i = 0; i < outputToProcess.length; i += chunkSize) { + const chunk = outputToProcess.slice(i, Math.min(i + chunkSize, outputToProcess.length)); + chunks.push(chunk.join('')); + } + return chunks.join(''); + } else { + return outputToProcess.join(''); + } + }, [scriptOutput]); + useEffect(() => { if (!scriptTerminalInstance.current || !activeSession) return; - const existingOutput = scriptOutput.join(''); + const existingOutput = fullScriptOutputMemo; if (existingOutput && lastProcessedScriptOutputLength.current === 0) { scriptTerminalInstance.current.write(existingOutput); lastProcessedScriptOutputLength.current = existingOutput.length; } - }, [activeSessionId, scriptOutput]); + }, [activeSessionId, fullScriptOutputMemo, activeSession]); useEffect(() => { if (!scriptTerminalInstance.current || viewMode !== 'terminal' || !activeSession) return; @@ -802,7 +848,7 @@ export const useSessionView = ( useEffect(() => { if (!scriptTerminalInstance.current || !activeSession) return; - const fullScriptOutput = scriptOutput.join(''); + const fullScriptOutput = fullScriptOutputMemo; // Handle case where output was cleared (e.g., user clicked clear button) if (fullScriptOutput.length === 0 && lastProcessedScriptOutputLength.current > 0) { @@ -826,7 +872,7 @@ export const useSessionView = ( scriptTerminalInstance.current.scrollToBottom(); } } - }, [scriptOutput, activeSessionId]); + }, [fullScriptOutputMemo, activeSessionId, activeSession]); useEffect(() => { // Listen for session deletion events diff --git a/frontend/src/stores/sessionStore.ts b/frontend/src/stores/sessionStore.ts index 90262606..fb8ba8df 100644 --- a/frontend/src/stores/sessionStore.ts +++ b/frontend/src/stores/sessionStore.ts @@ -52,6 +52,9 @@ interface SessionStore { setGitStatusLoadingBatch: (updates: Array<{ sessionId: string; loading: boolean }>) => void; updateSessionGitStatusBatch: (updates: Array<{ sessionId: string; status: GitStatus }>) => void; processPendingGitStatusUpdates: () => void; + + // Performance cleanup methods + cleanupInactiveSessions: () => void; } export const useSessionStore = create((set, get) => ({ @@ -155,6 +158,11 @@ export const useSessionStore = create((set, get) => ({ return; } + // Emit session-switched event for cleanup + if (get().activeSessionId !== sessionId) { + window.dispatchEvent(new CustomEvent('session-switched', { detail: { sessionId } })); + } + // First check if the session is already in our local store const state = get(); const existingSession = state.sessions.find(s => s.id === sessionId); @@ -257,15 +265,24 @@ export const useSessionStore = create((set, get) => ({ const sessions = state.sessions.slice(); const session = sessions[sessionIndex]; + // CRITICAL PERFORMANCE FIX: Much stricter limits to prevent V8 array iteration issues + const MAX_OUTPUTS = 300; // Drastically reduced from 1000 + const MAX_MESSAGES = 100; // Drastically reduced from 500 + if (output.type === 'json') { - // Update jsonMessages array - const newJsonMessages = [...(session.jsonMessages || [])]; - newJsonMessages.push({...output.data, timestamp: output.timestamp}); + // Update jsonMessages array with limit + const currentMessages = session.jsonMessages || []; + const newMessage = {...output.data, timestamp: output.timestamp}; + const newJsonMessages = currentMessages.length >= MAX_MESSAGES + ? [...currentMessages.slice(1), newMessage] // Remove oldest when at limit + : [...currentMessages, newMessage]; sessions[sessionIndex] = { ...session, jsonMessages: newJsonMessages }; } else { - // Add stdout/stderr to output array - const newOutput = [...(session.output || [])]; - newOutput.push(output.data); + // Add stdout/stderr to output array with limit + const currentOutput = session.output || []; + const newOutput = currentOutput.length >= MAX_OUTPUTS + ? [...currentOutput.slice(1), output.data] // Remove oldest when at limit + : [...currentOutput, output.data]; sessions[sessionIndex] = { ...session, output: newOutput }; } @@ -273,12 +290,17 @@ export const useSessionStore = create((set, get) => ({ let updatedActiveMainRepoSession = state.activeMainRepoSession; if (state.activeMainRepoSession && state.activeMainRepoSession.id === output.sessionId) { if (output.type === 'json') { - const newJsonMessages = [...(state.activeMainRepoSession.jsonMessages || [])]; - newJsonMessages.push({...output.data, timestamp: output.timestamp}); + const currentMessages = state.activeMainRepoSession.jsonMessages || []; + const newMessage = {...output.data, timestamp: output.timestamp}; + const newJsonMessages = currentMessages.length >= MAX_MESSAGES + ? [...currentMessages.slice(1), newMessage] + : [...currentMessages, newMessage]; updatedActiveMainRepoSession = { ...state.activeMainRepoSession, jsonMessages: newJsonMessages }; } else { - const newOutput = [...(state.activeMainRepoSession.output || [])]; - newOutput.push(output.data); + const currentOutput = state.activeMainRepoSession.output || []; + const newOutput = currentOutput.length >= MAX_OUTPUTS + ? [...currentOutput.slice(1), output.data] + : [...currentOutput, output.data]; updatedActiveMainRepoSession = { ...state.activeMainRepoSession, output: newOutput }; } } @@ -317,23 +339,40 @@ export const useSessionStore = create((set, get) => ({ setSessionOutputs: (sessionId, outputs) => set((state) => { console.log(`[SessionStore] Setting ${outputs.length} outputs for session ${sessionId}`); - // Performance optimization: Use simple loops instead of array methods for large datasets + // PERFORMANCE: Process arrays in chunks to avoid V8 optimization bailouts const stdOutputs: string[] = []; const jsonMessages: any[] = []; - // Use a simple for loop to avoid iterator overhead - for (let i = 0; i < outputs.length; i++) { - const output = outputs[i]; - if (output.type === 'json') { - jsonMessages.push({ ...output.data, timestamp: output.timestamp }); - } else if (output.type === 'stdout' || output.type === 'stderr') { - stdOutputs.push(output.data); + // Process in smaller batches to avoid long-running loops that trigger V8 deoptimization + const BATCH_SIZE = 100; + for (let batch = 0; batch < outputs.length; batch += BATCH_SIZE) { + const batchEnd = Math.min(batch + BATCH_SIZE, outputs.length); + + for (let i = batch; i < batchEnd; i++) { + const output = outputs[i]; + if (output.type === 'json') { + jsonMessages.push({ ...output.data, timestamp: output.timestamp }); + } else if (output.type === 'stdout' || output.type === 'stderr') { + stdOutputs.push(output.data); + } + } + + // Allow event loop to breathe between batches for very large arrays + if (batchEnd < outputs.length && outputs.length > 500) { + // This is a synchronous operation, so we can't truly yield, + // but we can at least break up the work + if (stdOutputs.length > 300 || jsonMessages.length > 100) { + // Stop early if we already have enough data + console.warn(`[SessionStore] Stopping early at ${batchEnd} of ${outputs.length} outputs due to limits`); + break; + } } } - // Memory optimization: Limit stored outputs to prevent unbounded growth - const MAX_STORED_OUTPUTS = 10000; - const MAX_STORED_MESSAGES = 5000; + // CRITICAL PERFORMANCE FIX: Even more aggressive limits to prevent V8 optimization failures + // V8 was getting stuck in recursive array iterations with large arrays + const MAX_STORED_OUTPUTS = 300; // Further reduced to prevent CPU spikes + const MAX_STORED_MESSAGES = 100; // Further reduced to prevent memory pressure const trimmedOutputs = stdOutputs.length > MAX_STORED_OUTPUTS ? stdOutputs.slice(-MAX_STORED_OUTPUTS) @@ -419,20 +458,25 @@ export const useSessionStore = create((set, get) => ({ }, addTerminalOutput: (output) => set((state) => { - // Performance optimization: Use direct array mutation for terminal output + // Performance optimization: Much stricter limit to prevent memory issues + const MAX_TERMINAL_LINES = 1000; // Drastically reduced from 5000 + const existingOutput = state.terminalOutput[output.sessionId] || []; - const newOutput = [...existingOutput, output.data]; - // Limit terminal output to prevent memory issues - const MAX_TERMINAL_LINES = 10000; - const trimmedOutput = newOutput.length > MAX_TERMINAL_LINES - ? newOutput.slice(-MAX_TERMINAL_LINES) - : newOutput; + // If already at max, remove oldest before adding new + let updatedOutput: string[]; + if (existingOutput.length >= MAX_TERMINAL_LINES) { + // Shift array instead of creating new one for better performance + updatedOutput = existingOutput.slice(-(MAX_TERMINAL_LINES - 1)); + updatedOutput.push(output.data); + } else { + updatedOutput = [...existingOutput, output.data]; + } return { terminalOutput: { ...state.terminalOutput, - [output.sessionId]: trimmedOutput + [output.sessionId]: updatedOutput } }; }), @@ -619,5 +663,51 @@ export const useSessionStore = create((set, get) => ({ get().updateSessionGitStatusBatch(statusUpdates); state.pendingGitStatusUpdates.clear(); } - } + }, + + cleanupInactiveSessions: () => set((state) => { + // Performance: Clear output data for inactive sessions to free memory + const activeId = state.activeSessionId; + const MAX_INACTIVE_OUTPUTS = 50; // Even less for inactive sessions + + // Create new sessions array with trimmed outputs for inactive sessions + const cleanedSessions = state.sessions.map(session => { + if (session.id === activeId) { + // Don't touch active session + return session; + } + + // For inactive sessions, aggressively trim outputs + if (session.output && session.output.length > MAX_INACTIVE_OUTPUTS) { + return { + ...session, + output: session.output.slice(-MAX_INACTIVE_OUTPUTS), + jsonMessages: session.jsonMessages ? session.jsonMessages.slice(-25) : [] + }; + } + + return session; + }); + + // Also cleanup terminal outputs for inactive sessions + const cleanedTerminalOutput: Record = {}; + Object.keys(state.terminalOutput).forEach(sessionId => { + if (sessionId === activeId) { + // Keep active session's terminal output + cleanedTerminalOutput[sessionId] = state.terminalOutput[sessionId]; + } else if (state.terminalOutput[sessionId].length > 50) { + // Trim inactive session's terminal output more aggressively + cleanedTerminalOutput[sessionId] = state.terminalOutput[sessionId].slice(-50); + } else { + cleanedTerminalOutput[sessionId] = state.terminalOutput[sessionId]; + } + }); + + console.log('[SessionStore] Cleaned up inactive session data'); + + return { + sessions: cleanedSessions, + terminalOutput: cleanedTerminalOutput + }; + }) })); \ No newline at end of file From 4ea64a625e9c66d68b408c8829944577efbb9cf9 Mon Sep 17 00:00:00 2001 From: Jordan Bentley <151466993+jordan-BAIC@users.noreply.github.com> Date: Sat, 23 Aug 2025 16:37:43 -0400 Subject: [PATCH 20/30] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 831c39c2..557f213c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ All notable changes to Crystal will be documented in this file. -## [0.2.2] - 2025-08-22 +## [0.2.2] - 2025-08-23 ### Added - **Homebrew install** - Crystal can now be installed with `brew install --cask stravu-crystal` on OSX From b146af3f7aa129d3d4d7665b38a3f9490d1c918b Mon Sep 17 00:00:00 2001 From: Jordan Bentley Date: Sun, 24 Aug 2025 09:24:35 -0400 Subject: [PATCH 21/30] less blocking on git status refresh --- .../components/DraggableProjectTreeView.tsx | 30 ++++++++++++-- frontend/src/components/SessionListItem.tsx | 20 +++++++--- main/src/ipc/git.ts | 29 +++++++++++--- main/src/ipc/project.ts | 40 ++++++++++++------- main/src/preload.ts | 2 +- 5 files changed, 90 insertions(+), 31 deletions(-) diff --git a/frontend/src/components/DraggableProjectTreeView.tsx b/frontend/src/components/DraggableProjectTreeView.tsx index 23ba1378..157ec032 100644 --- a/frontend/src/components/DraggableProjectTreeView.tsx +++ b/frontend/src/components/DraggableProjectTreeView.tsx @@ -765,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) { @@ -774,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); @@ -782,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/SessionListItem.tsx b/frontend/src/components/SessionListItem.tsx index 5004e7c1..f73ac4c0 100644 --- a/frontend/src/components/SessionListItem.tsx +++ b/frontend/src/components/SessionListItem.tsx @@ -102,17 +102,27 @@ 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 { setGitStatusLoading(true); - const response = await window.electronAPI.invoke('sessions:get-git-status', session.id); - if (response.success && response.gitStatus) { - setGitStatus(response.gitStatus); + // 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); - } finally { setGitStatusLoading(false); } }; diff --git a/main/src/ipc/git.ts b/main/src/ipc/git.ts index 6d97dc40..1e33f847 100644 --- a/main/src/ipc/git.ts +++ b/main/src/ipc/git.ts @@ -1068,7 +1068,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo } }); - ipcMain.handle('sessions:get-git-status', async (_event, sessionId: string) => { + ipcMain.handle('sessions:get-git-status', async (_event, sessionId: string, nonBlocking?: boolean) => { try { const session = await sessionManager.getSession(sessionId); if (!session || !session.worktreePath) { @@ -1079,11 +1079,28 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo return { success: false, error: 'Cannot get git status for archived session' }; } - // Use refreshSessionGitStatus with user-initiated flag - // This is called when user clicks on a session, so show loading state - const gitStatus = await gitStatusManager.refreshSessionGitStatus(sessionId, true); - - return { success: true, gitStatus }; + // If nonBlocking is true, start refresh in background and return immediately + if (nonBlocking) { + // Start the refresh in background + setImmediate(() => { + gitStatusManager.refreshSessionGitStatus(sessionId, true).catch(error => { + console.error(`[Git] Background git status refresh failed for session ${sessionId}:`, error); + }); + }); + + // Return the cached status if available, or indicate background refresh started + const cachedStatus = await gitStatusManager.getGitStatus(sessionId); + return { + success: true, + gitStatus: cachedStatus, + backgroundRefresh: true + }; + } else { + // Use refreshSessionGitStatus with user-initiated flag + // This is called when user clicks on a session, so show loading state + const gitStatus = await gitStatusManager.refreshSessionGitStatus(sessionId, true); + return { success: true, gitStatus }; + } } catch (error) { console.error('Error getting git status:', error); return { success: false, error: (error as Error).message }; diff --git a/main/src/ipc/project.ts b/main/src/ipc/project.ts index d672990d..f5dfd0ea 100644 --- a/main/src/ipc/project.ts +++ b/main/src/ipc/project.ts @@ -283,25 +283,35 @@ export function registerProjectHandlers(ipcMain: IpcMain, services: AppServices) // Use gitStatusManager from services const { gitStatusManager } = services; - // Refresh git status for each session (parallel for better performance) - const refreshPromises = projectSessions - .filter(session => session.worktreePath) - .map(session => - gitStatusManager.refreshSessionGitStatus(session.id, true) // true = user initiated - .catch(error => { - console.error(`[Main] Failed to refresh git status for session ${session.id}:`, error); - return null; - }) - ); + // Count the sessions that will be refreshed + const sessionsToRefresh = projectSessions.filter(session => session.worktreePath); + const sessionCount = sessionsToRefresh.length; - const results = await Promise.allSettled(refreshPromises); - const refreshedCount = results.filter(result => result.status === 'fulfilled').length; + // Start the refresh in background (non-blocking) + // Don't await this - let it run asynchronously + setImmediate(() => { + const refreshPromises = sessionsToRefresh + .map(session => + gitStatusManager.refreshSessionGitStatus(session.id, true) // true = user initiated + .catch(error => { + console.error(`[Main] Failed to refresh git status for session ${session.id}:`, error); + return null; + }) + ); + + // Log when all refreshes complete (in background) + Promise.allSettled(refreshPromises).then(results => { + const refreshedCount = results.filter(result => result.status === 'fulfilled').length; + console.log(`[Main] Background refresh completed: ${refreshedCount}/${sessionCount} sessions`); + }); + }); - console.log(`[Main] Refreshed git status for ${refreshedCount} sessions`); + // Return immediately with the count of sessions that will be refreshed + console.log(`[Main] Starting background refresh for ${sessionCount} sessions`); - return { success: true, data: { count: refreshedCount } }; + return { success: true, data: { count: sessionCount, backgroundRefresh: true } }; } catch (error) { - console.error('[Main] Failed to refresh project git status:', error); + console.error('[Main] Failed to start project git status refresh:', error); return { success: false, error: 'Failed to refresh git status' }; } }); diff --git a/main/src/preload.ts b/main/src/preload.ts index 48890a79..e0955cdf 100644 --- a/main/src/preload.ts +++ b/main/src/preload.ts @@ -124,7 +124,7 @@ contextBridge.exposeInMainWorld('electronAPI', { // Git pull/push operations gitPull: (sessionId: string): Promise => ipcRenderer.invoke('sessions:git-pull', sessionId), gitPush: (sessionId: string): Promise => ipcRenderer.invoke('sessions:git-push', sessionId), - getGitStatus: (sessionId: string): Promise => ipcRenderer.invoke('sessions:get-git-status', sessionId), + getGitStatus: (sessionId: string, nonBlocking?: boolean): Promise => ipcRenderer.invoke('sessions:get-git-status', sessionId, nonBlocking), getLastCommits: (sessionId: string, count: number): Promise => ipcRenderer.invoke('sessions:get-last-commits', sessionId, count), // Git operation helpers From 8eca2d91ea625c9aebe2396e47a493c604d01bd0 Mon Sep 17 00:00:00 2001 From: Jordan Bentley Date: Sun, 24 Aug 2025 14:32:07 -0400 Subject: [PATCH 22/30] quicker session archive --- main/src/ipc/session.ts | 93 ++++++++++++++++++++++------------------- 1 file changed, 51 insertions(+), 42 deletions(-) diff --git a/main/src/ipc/session.ts b/main/src/ipc/session.ts index 6622af65..64aa9068 100644 --- a/main/src/ipc/session.ts +++ b/main/src/ipc/session.ts @@ -198,47 +198,10 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) // Add a message to session output about archiving const timestamp = new Date().toLocaleTimeString(); let archiveMessage = `\r\n\x1b[36m[${timestamp}]\x1b[0m \x1b[1m\x1b[44m\x1b[37m πŸ“¦ ARCHIVING SESSION \x1b[0m\r\n`; + archiveMessage += `\x1b[90mSession will be archived and removed from the active sessions list.\x1b[0m\r\n`; - // Clean up the worktree if session has one (but not for main repo sessions) - if (dbSession.worktree_name && dbSession.project_id && !dbSession.is_main_repo) { - const project = databaseService.getProject(dbSession.project_id); - if (project) { - try { - console.log(`[Main] Removing worktree ${dbSession.worktree_name} for session ${sessionId}`); - archiveMessage += `\x1b[90mRemoving git worktree: ${dbSession.worktree_name}\x1b[0m\r\n`; - - await worktreeManager.removeWorktree(project.path, dbSession.worktree_name, project.worktree_folder); - - console.log(`[Main] Successfully removed worktree ${dbSession.worktree_name}`); - archiveMessage += `\x1b[32mβœ“ Worktree removed successfully\x1b[0m\r\n`; - } catch (worktreeError) { - // Log the error but don't fail the session deletion - console.error(`[Main] Failed to remove worktree ${dbSession.worktree_name}:`, worktreeError); - archiveMessage += `\x1b[33m⚠ Failed to remove worktree (manual cleanup may be needed)\x1b[0m\r\n`; - // Continue with session deletion even if worktree removal fails - } - } - } - - // Clean up session artifacts (images) - const artifactsDir = getCrystalSubdirectory('artifacts', sessionId); - if (existsSync(artifactsDir)) { - try { - console.log(`[Main] Removing artifacts directory for session ${sessionId}`); - archiveMessage += `\x1b[90mRemoving session artifacts...\x1b[0m\r\n`; - - await fs.rm(artifactsDir, { recursive: true, force: true }); - - console.log(`[Main] Successfully removed artifacts for session ${sessionId}`); - archiveMessage += `\x1b[32mβœ“ Artifacts removed successfully\x1b[0m\r\n`; - } catch (artifactsError) { - console.error(`[Main] Failed to remove artifacts for session ${sessionId}:`, artifactsError); - archiveMessage += `\x1b[33m⚠ Failed to remove artifacts (manual cleanup may be needed)\x1b[0m\r\n`; - // Continue with session deletion even if artifacts removal fails - } - } - - archiveMessage += `\x1b[90mSession archived. It will no longer appear in the active sessions list.\x1b[0m\r\n\r\n`; + // Archive the session immediately to provide fast feedback to the user + await sessionManager.archiveSession(sessionId); // Add the archive message to session output sessionManager.addSessionOutput(sessionId, { @@ -247,8 +210,54 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) timestamp: new Date() }); - // Archive the session - await sessionManager.archiveSession(sessionId); + // Clean up resources in the background after archiving + setImmediate(async () => { + let cleanupMessage = ''; + + // Clean up the worktree if session has one (but not for main repo sessions) + if (dbSession.worktree_name && dbSession.project_id && !dbSession.is_main_repo) { + const project = databaseService.getProject(dbSession.project_id); + if (project) { + try { + console.log(`[Main] Removing worktree ${dbSession.worktree_name} for session ${sessionId} (background)`); + + await worktreeManager.removeWorktree(project.path, dbSession.worktree_name, project.worktree_folder); + + console.log(`[Main] Successfully removed worktree ${dbSession.worktree_name}`); + cleanupMessage += `\x1b[32mβœ“ Worktree removed successfully\x1b[0m\r\n`; + } catch (worktreeError) { + // Log the error but don't fail + console.error(`[Main] Failed to remove worktree ${dbSession.worktree_name}:`, worktreeError); + cleanupMessage += `\x1b[33m⚠ Failed to remove worktree (manual cleanup may be needed)\x1b[0m\r\n`; + } + } + } + + // Clean up session artifacts (images) + const artifactsDir = getCrystalSubdirectory('artifacts', sessionId); + if (existsSync(artifactsDir)) { + try { + console.log(`[Main] Removing artifacts directory for session ${sessionId} (background)`); + + await fs.rm(artifactsDir, { recursive: true, force: true }); + + console.log(`[Main] Successfully removed artifacts for session ${sessionId}`); + cleanupMessage += `\x1b[32mβœ“ Artifacts removed successfully\x1b[0m\r\n`; + } catch (artifactsError) { + console.error(`[Main] Failed to remove artifacts for session ${sessionId}:`, artifactsError); + cleanupMessage += `\x1b[33m⚠ Failed to remove artifacts (manual cleanup may be needed)\x1b[0m\r\n`; + } + } + + // If there were any cleanup messages, add them to the session output + if (cleanupMessage) { + sessionManager.addSessionOutput(sessionId, { + type: 'stdout', + data: cleanupMessage, + timestamp: new Date() + }); + } + }); return { success: true }; } catch (error) { From a09f5b6ae71342a80333cb229600b1323837fe29 Mon Sep 17 00:00:00 2001 From: Jordan Bentley Date: Sun, 24 Aug 2025 22:28:52 -0400 Subject: [PATCH 23/30] bugfix: "Rebase to main" was not updating when a session completed --- frontend/src/components/SessionView.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/SessionView.tsx b/frontend/src/components/SessionView.tsx index ecec600a..1484fc62 100644 --- a/frontend/src/components/SessionView.tsx +++ b/frontend/src/components/SessionView.tsx @@ -21,19 +21,23 @@ import { LogView } from './session/LogView'; import { MessagesView } from './session/MessagesView'; export const SessionView = memo(() => { - const activeSessionId = useSessionStore((state) => state.activeSessionId); - const getActiveSession = useSessionStore((state) => state.getActiveSession); const { activeView, activeProjectId } = useNavigationStore(); const [projectData, setProjectData] = useState(null); const [isProjectLoading, setIsProjectLoading] = useState(false); const [isMergingProject, setIsMergingProject] = useState(false); const [sessionProject, setSessionProject] = useState(null); - // Get active session using the store's method - avoids subscribing to entire sessions array - const activeSession = useMemo(() => { - if (!activeSessionId) return undefined; - return getActiveSession(); - }, [activeSessionId, getActiveSession]); + // 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 From 0583c5a97def4d1adad88e02c8fd25d14d81d180 Mon Sep 17 00:00:00 2001 From: Jordan Bentley Date: Sun, 24 Aug 2025 22:32:52 -0400 Subject: [PATCH 24/30] 0.2.3 RC --- CHANGELOG.md | 6 ++++++ frontend/package.json | 2 +- main/package.json | 2 +- package.json | 2 +- shared/package.json | 2 +- 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 557f213c..46d005d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ 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 diff --git a/frontend/package.json b/frontend/package.json index 579a756b..0102f6c5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.2.2", + "version": "0.2.3", "private": true, "type": "module", "scripts": { diff --git a/main/package.json b/main/package.json index 698ff77d..2128fc16 100644 --- a/main/package.json +++ b/main/package.json @@ -1,6 +1,6 @@ { "name": "main", - "version": "0.2.2", + "version": "0.2.3", "description": "Electron main process for Claude Code Commander", "main": "dist/main/src/index.js", "type": "commonjs", diff --git a/package.json b/package.json index c8b544a4..3942a4cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "crystal", - "version": "0.2.2", + "version": "0.2.3", "private": true, "description": "Crystal - Claude Code Commander for managing multiple Claude Code instances", "author": { diff --git a/shared/package.json b/shared/package.json index 60367a30..402468b7 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "shared", - "version": "0.2.2", + "version": "0.2.3", "private": true, "main": "index.js", "types": "index.d.ts", From d5032ed1056e272543487c4ee184d0d258f9c5b5 Mon Sep 17 00:00:00 2001 From: Jordan Bentley Date: Sun, 24 Aug 2025 22:35:52 -0400 Subject: [PATCH 25/30] checkpoint: pull from origin and rebase if needed --- .claude/agents/file-finder.md | 65 +++++++++++++++++++++++++++++++++ .claude/agents/test-ui-agent.md | 15 ++++++++ 2 files changed, 80 insertions(+) create mode 100644 .claude/agents/file-finder.md create mode 100644 .claude/agents/test-ui-agent.md diff --git a/.claude/agents/file-finder.md b/.claude/agents/file-finder.md new file mode 100644 index 00000000..a94a98ce --- /dev/null +++ b/.claude/agents/file-finder.md @@ -0,0 +1,65 @@ +--- +name: file-finder +description: Use this agent when you need to identify and analyze relevant files in a codebase for a specific task or feature. The agent will search through the project structure, examine file contents, and return a prioritized list of files with specific sections highlighted and explanations of their relevance. \nContext: The user needs to find files related to session management in the Crystal application.\nuser: "I need to understand how session management works in this codebase"\nassistant: "I'll use the file-finder agent to locate and analyze all files related to session management"\n\nSince the user needs to understand a specific aspect of the codebase, use the Task tool to launch the file-finder agent to research and identify relevant files.\n\n\n\nContext: The user wants to implement a new feature and needs to know which files to modify.\nuser: "I want to add a new notification system to the app"\nassistant: "Let me use the file-finder agent to identify which files would be most relevant for implementing a notification system"\n\nThe user needs to know which files to work with for a new feature, so use the file-finder agent to research the codebase structure.\n\n +tools: Glob, Grep, LS, ExitPlanMode, Read, NotebookRead, WebFetch, TodoWrite, WebSearch +color: pink +--- + +You are an expert code archaeologist and file analysis specialist. Your primary responsibility is to efficiently navigate codebases, identify relevant files for specific tasks, and provide detailed insights about their contents and relationships. + +When given a task or feature description, you will: + +1. **Analyze the Request**: Understand the core functionality, feature, or concept the user is interested in. Break down the request into key technical terms and patterns to search for. + +2. **Search Strategy**: Develop a systematic approach to find relevant files: + - Start with obvious naming patterns (e.g., 'session' for session management) + - Look for common architectural patterns (controllers, services, models, utilities) + - Check configuration files and entry points + - Follow import/dependency chains + - Examine test files for usage examples + +3. **File Analysis**: For each potentially relevant file: + - Read the file content to understand its purpose + - Identify the most relevant sections (specific functions, classes, or configuration blocks) + - Note relationships with other files (imports, exports, dependencies) + - Assess the relevance level (critical, important, or supplementary) + +4. **Prioritization**: Rank files by relevance: + - Critical: Core implementation files directly related to the task + - Important: Supporting files that provide context or utilities + - Supplementary: Files that might be helpful but aren't essential + +5. **Output Format**: Provide a structured response with: + - **File Path**: Full path from project root + - **Relevance**: Critical/Important/Supplementary + - **Key Sections**: Specific line ranges or function/class names + - **Description**: 2-3 sentences explaining what the file does and why it's relevant + - **Relationships**: Other files this one connects to + +Example output structure: +``` +1. [CRITICAL] frontend/src/components/SessionView.tsx + - Key sections: Lines 145-289 (SessionView component), Lines 412-456 (handleSessionCreate function) + - Description: Main component for displaying and managing sessions. Contains the UI logic for creating, viewing, and interacting with Claude Code sessions. + - Related to: stores/sessionStore.ts, hooks/useSessionView.ts + +2. [IMPORTANT] main/src/services/sessionManager.ts + - Key sections: Lines 23-67 (createSession method), Lines 89-134 (SessionManager class) + - Description: Backend service that handles session lifecycle management. Responsible for creating worktrees and spawning Claude Code processes. + - Related to: database/models/Session.ts, services/worktreeManager.ts +``` + +**Search Techniques**: +- Use grep-like searches for keywords and patterns +- Follow the code flow from entry points +- Check for common naming conventions in the project +- Look for TODO comments related to the feature +- Examine package.json or similar files for relevant dependencies + +**Quality Checks**: +- Ensure you're not just pattern matching on file names +- Verify the actual relevance by reading file contents +- Don't include files just because they mention a keyword in passing +- Focus on files that would actually need to be read or modified for the task + +Remember: Your goal is to save the user time by providing a curated, prioritized list of files with specific pointers to the most relevant sections. Be thorough but concise, and always explain why each file matters for the specific task at hand. diff --git a/.claude/agents/test-ui-agent.md b/.claude/agents/test-ui-agent.md new file mode 100644 index 00000000..70345adb --- /dev/null +++ b/.claude/agents/test-ui-agent.md @@ -0,0 +1,15 @@ +--- +name: test-ui-agent +description: Test agent for verifying the UI configuration screen works correctly. This agent is used for testing the sub-agent configuration functionality. +tools: Read, Grep +color: blue +--- + +You are a test agent used for verifying that the Crystal sub-agent configuration UI works correctly. This is a placeholder system prompt for testing purposes. + +Your main responsibilities: +1. Exist as a test case +2. Verify the UI can load and display agents +3. Confirm that all fields are properly rendered + +This agent should appear in the Crystal sub-agent configuration screen when viewing the project settings. \ No newline at end of file From 15af6d76be518c9ac349878f0963cc51f21cb0d9 Mon Sep 17 00:00:00 2001 From: Jordan Bentley Date: Sun, 24 Aug 2025 22:43:24 -0400 Subject: [PATCH 26/30] delete accidently added agent files --- .claude/agents/file-finder.md | 65 --------------------------------- .claude/agents/test-ui-agent.md | 15 -------- 2 files changed, 80 deletions(-) delete mode 100644 .claude/agents/file-finder.md delete mode 100644 .claude/agents/test-ui-agent.md diff --git a/.claude/agents/file-finder.md b/.claude/agents/file-finder.md deleted file mode 100644 index a94a98ce..00000000 --- a/.claude/agents/file-finder.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -name: file-finder -description: Use this agent when you need to identify and analyze relevant files in a codebase for a specific task or feature. The agent will search through the project structure, examine file contents, and return a prioritized list of files with specific sections highlighted and explanations of their relevance. \nContext: The user needs to find files related to session management in the Crystal application.\nuser: "I need to understand how session management works in this codebase"\nassistant: "I'll use the file-finder agent to locate and analyze all files related to session management"\n\nSince the user needs to understand a specific aspect of the codebase, use the Task tool to launch the file-finder agent to research and identify relevant files.\n\n\n\nContext: The user wants to implement a new feature and needs to know which files to modify.\nuser: "I want to add a new notification system to the app"\nassistant: "Let me use the file-finder agent to identify which files would be most relevant for implementing a notification system"\n\nThe user needs to know which files to work with for a new feature, so use the file-finder agent to research the codebase structure.\n\n -tools: Glob, Grep, LS, ExitPlanMode, Read, NotebookRead, WebFetch, TodoWrite, WebSearch -color: pink ---- - -You are an expert code archaeologist and file analysis specialist. Your primary responsibility is to efficiently navigate codebases, identify relevant files for specific tasks, and provide detailed insights about their contents and relationships. - -When given a task or feature description, you will: - -1. **Analyze the Request**: Understand the core functionality, feature, or concept the user is interested in. Break down the request into key technical terms and patterns to search for. - -2. **Search Strategy**: Develop a systematic approach to find relevant files: - - Start with obvious naming patterns (e.g., 'session' for session management) - - Look for common architectural patterns (controllers, services, models, utilities) - - Check configuration files and entry points - - Follow import/dependency chains - - Examine test files for usage examples - -3. **File Analysis**: For each potentially relevant file: - - Read the file content to understand its purpose - - Identify the most relevant sections (specific functions, classes, or configuration blocks) - - Note relationships with other files (imports, exports, dependencies) - - Assess the relevance level (critical, important, or supplementary) - -4. **Prioritization**: Rank files by relevance: - - Critical: Core implementation files directly related to the task - - Important: Supporting files that provide context or utilities - - Supplementary: Files that might be helpful but aren't essential - -5. **Output Format**: Provide a structured response with: - - **File Path**: Full path from project root - - **Relevance**: Critical/Important/Supplementary - - **Key Sections**: Specific line ranges or function/class names - - **Description**: 2-3 sentences explaining what the file does and why it's relevant - - **Relationships**: Other files this one connects to - -Example output structure: -``` -1. [CRITICAL] frontend/src/components/SessionView.tsx - - Key sections: Lines 145-289 (SessionView component), Lines 412-456 (handleSessionCreate function) - - Description: Main component for displaying and managing sessions. Contains the UI logic for creating, viewing, and interacting with Claude Code sessions. - - Related to: stores/sessionStore.ts, hooks/useSessionView.ts - -2. [IMPORTANT] main/src/services/sessionManager.ts - - Key sections: Lines 23-67 (createSession method), Lines 89-134 (SessionManager class) - - Description: Backend service that handles session lifecycle management. Responsible for creating worktrees and spawning Claude Code processes. - - Related to: database/models/Session.ts, services/worktreeManager.ts -``` - -**Search Techniques**: -- Use grep-like searches for keywords and patterns -- Follow the code flow from entry points -- Check for common naming conventions in the project -- Look for TODO comments related to the feature -- Examine package.json or similar files for relevant dependencies - -**Quality Checks**: -- Ensure you're not just pattern matching on file names -- Verify the actual relevance by reading file contents -- Don't include files just because they mention a keyword in passing -- Focus on files that would actually need to be read or modified for the task - -Remember: Your goal is to save the user time by providing a curated, prioritized list of files with specific pointers to the most relevant sections. Be thorough but concise, and always explain why each file matters for the specific task at hand. diff --git a/.claude/agents/test-ui-agent.md b/.claude/agents/test-ui-agent.md deleted file mode 100644 index 70345adb..00000000 --- a/.claude/agents/test-ui-agent.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -name: test-ui-agent -description: Test agent for verifying the UI configuration screen works correctly. This agent is used for testing the sub-agent configuration functionality. -tools: Read, Grep -color: blue ---- - -You are a test agent used for verifying that the Crystal sub-agent configuration UI works correctly. This is a placeholder system prompt for testing purposes. - -Your main responsibilities: -1. Exist as a test case -2. Verify the UI can load and display agents -3. Confirm that all fields are properly rendered - -This agent should appear in the Crystal sub-agent configuration screen when viewing the project settings. \ No newline at end of file From e1fdc84a2649f9a1edfd6acba71c15b943837081 Mon Sep 17 00:00:00 2001 From: Jordan Bentley Date: Tue, 26 Aug 2025 12:20:50 -0400 Subject: [PATCH 27/30] better queuing for deleting worktrees --- frontend/src/components/ArchiveProgress.tsx | 196 ++++++++++++++++++++ frontend/src/components/Sidebar.tsx | 29 +-- main/src/events.ts | 17 +- main/src/index.ts | 44 ++++- main/src/ipc/session.ts | 79 +++++++- main/src/ipc/types.ts | 2 + main/src/services/archiveProgressManager.ts | 172 +++++++++++++++++ 7 files changed, 519 insertions(+), 20 deletions(-) create mode 100644 frontend/src/components/ArchiveProgress.tsx create mode 100644 main/src/services/archiveProgressManager.ts 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/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index d7ad4c1c..94ba80f9 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { Settings } from './Settings'; import { DraggableProjectTreeView } from './DraggableProjectTreeView'; +import { ArchiveProgress } from './ArchiveProgress'; import { Info, Clock, Check, Edit, CircleArrowDown, AlertTriangle, GitMerge } from 'lucide-react'; import crystalLogo from '../assets/crystal-logo.svg'; import { IconButton } from './ui/Button'; @@ -131,18 +132,24 @@ export function Sidebar({ onHelpClick, onAboutClick, onPromptHistoryClick, width
- {/* 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/main/src/events.ts b/main/src/events.ts index 54c5c60d..143f7fb9 100644 --- a/main/src/events.ts +++ b/main/src/events.ts @@ -12,7 +12,8 @@ export function setupEventListeners(services: AppServices, getMainWindow: () => runCommandManager, gitDiffManager, gitStatusManager, - worktreeManager + worktreeManager, + archiveProgressManager } = services; // Listen to sessionManager events and broadcast to renderer @@ -490,4 +491,18 @@ export function setupEventListeners(services: AppServices, getMainWindow: () => } } }); + + // Listen for archive progress events + if (archiveProgressManager) { + archiveProgressManager.on('archive-progress', (progress) => { + const mw = getMainWindow(); + if (mw && !mw.isDestroyed()) { + try { + mw.webContents.send('archive:progress', progress); + } catch (error) { + console.error('[Main] Failed to send archive:progress event:', error); + } + } + }); + } } \ No newline at end of file diff --git a/main/src/index.ts b/main/src/index.ts index 2ca663ce..8f0411a5 100644 --- a/main/src/index.ts +++ b/main/src/index.ts @@ -9,7 +9,7 @@ if (process.platform === 'linux') { } // Now import the rest of electron -import { BrowserWindow, ipcMain, shell } from 'electron'; +import { BrowserWindow, ipcMain, shell, dialog } from 'electron'; import * as path from 'path'; import { TaskQueue } from './services/taskQueue'; import { SessionManager } from './services/sessionManager'; @@ -26,6 +26,7 @@ import { VersionChecker } from './services/versionChecker'; import { StravuAuthManager } from './services/stravuAuthManager'; import { StravuNotebookService } from './services/stravuNotebookService'; import { Logger } from './utils/logger'; +import { ArchiveProgressManager } from './services/archiveProgressManager'; import { setCrystalDirectory } from './utils/crystalDirectory'; import { getCurrentWorktreeName } from './utils/worktreeUtils'; import { registerIpcHandlers } from './ipc'; @@ -79,6 +80,7 @@ let permissionIpcServer: PermissionIpcServer | null; let versionChecker: VersionChecker; let stravuAuthManager: StravuAuthManager; let stravuNotebookService: StravuNotebookService; +let archiveProgressManager: ArchiveProgressManager; // Store original console methods before overriding // These must be captured immediately when the module loads @@ -407,6 +409,8 @@ async function initializeServices() { sessionManager = new SessionManager(databaseService); sessionManager.initializeFromDatabase(); + + archiveProgressManager = new ArchiveProgressManager(); // Start permission IPC server console.log('[Main] Initializing Permission IPC server...'); @@ -474,6 +478,7 @@ async function initializeServices() { taskQueue, getMainWindow: () => mainWindow, logger, + archiveProgressManager, }; // Set up IPC event listeners for real-time updates @@ -537,7 +542,42 @@ app.on('window-all-closed', () => { } }); -app.on('before-quit', async () => { +app.on('before-quit', async (event) => { + // Check if there are active archive tasks + if (archiveProgressManager && archiveProgressManager.hasActiveTasks()) { + event.preventDefault(); + + console.log('[Main] Archive tasks in progress, showing warning dialog...'); + const activeCount = archiveProgressManager.getActiveTaskCount(); + const choice = mainWindow + ? dialog.showMessageBoxSync(mainWindow, { + type: 'warning', + title: 'Archive Tasks In Progress', + message: `Crystal is removing ${activeCount} worktree${activeCount > 1 ? 's' : ''} in the background.`, + detail: 'Git worktree removal can take time, especially for large repositories with many files. If you quit now, the worktree directories may not be fully cleaned up and you may need to remove them manually.\n\nDo you want to quit anyway?', + buttons: ['Wait', 'Quit Anyway'], + defaultId: 0, + cancelId: 0 + }) + : dialog.showMessageBoxSync({ + type: 'warning', + title: 'Archive Tasks In Progress', + message: `Crystal is removing ${activeCount} worktree${activeCount > 1 ? 's' : ''} in the background.`, + detail: 'Git worktree removal can take time, especially for large repositories with many files. If you quit now, the worktree directories may not be fully cleaned up and you may need to remove them manually.\n\nDo you want to quit anyway?', + buttons: ['Wait', 'Quit Anyway'], + defaultId: 0, + cancelId: 0 + }); + + if (choice === 1) { + // User chose to quit anyway + archiveProgressManager.clearAll(); + app.exit(0); + } + // Otherwise, the quit is cancelled and app continues + return; + } + // Cleanup all sessions and terminate child processes if (sessionManager) { console.log('[Main] Cleaning up sessions and terminating child processes...'); diff --git a/main/src/ipc/session.ts b/main/src/ipc/session.ts index 64aa9068..d709631e 100644 --- a/main/src/ipc/session.ts +++ b/main/src/ipc/session.ts @@ -15,7 +15,8 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) worktreeManager, claudeCodeManager, worktreeNameGenerator, - gitStatusManager + gitStatusManager, + archiveProgressManager } = services; // Session management handlers @@ -210,8 +211,8 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) timestamp: new Date() }); - // Clean up resources in the background after archiving - setImmediate(async () => { + // Create cleanup callback for background operations + const cleanupCallback = async () => { let cleanupMessage = ''; // Clean up the worktree if session has one (but not for main repo sessions) @@ -219,7 +220,12 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) const project = databaseService.getProject(dbSession.project_id); if (project) { try { - console.log(`[Main] Removing worktree ${dbSession.worktree_name} for session ${sessionId} (background)`); + console.log(`[Main] Removing worktree ${dbSession.worktree_name} for session ${sessionId} (queued)`); + + // Update progress: removing worktree + if (archiveProgressManager) { + archiveProgressManager.updateTaskStatus(sessionId, 'removing-worktree'); + } await worktreeManager.removeWorktree(project.path, dbSession.worktree_name, project.worktree_folder); @@ -229,6 +235,11 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) // Log the error but don't fail console.error(`[Main] Failed to remove worktree ${dbSession.worktree_name}:`, worktreeError); cleanupMessage += `\x1b[33m⚠ Failed to remove worktree (manual cleanup may be needed)\x1b[0m\r\n`; + + // Update progress: failed + if (archiveProgressManager) { + archiveProgressManager.updateTaskStatus(sessionId, 'failed', 'Failed to remove worktree'); + } } } } @@ -237,7 +248,12 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) const artifactsDir = getCrystalSubdirectory('artifacts', sessionId); if (existsSync(artifactsDir)) { try { - console.log(`[Main] Removing artifacts directory for session ${sessionId} (background)`); + console.log(`[Main] Removing artifacts directory for session ${sessionId} (queued)`); + + // Update progress: cleaning artifacts + if (archiveProgressManager) { + archiveProgressManager.updateTaskStatus(sessionId, 'cleaning-artifacts'); + } await fs.rm(artifactsDir, { recursive: true, force: true }); @@ -257,7 +273,32 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) timestamp: new Date() }); } - }); + }; + + // Queue the cleanup task if we have worktree cleanup to do + if (dbSession.worktree_name && dbSession.project_id && !dbSession.is_main_repo) { + const project = databaseService.getProject(dbSession.project_id); + console.log('[Main] Archive progress tracking:', { + hasWorktree: !!dbSession.worktree_name, + projectId: dbSession.project_id, + isMainRepo: dbSession.is_main_repo, + project: !!project, + archiveProgressManager: !!archiveProgressManager + }); + if (project && archiveProgressManager) { + console.log('[Main] Adding archive task to queue for session:', sessionId); + archiveProgressManager.addTask( + sessionId, + dbSession.name, + dbSession.worktree_name, + project.name, + cleanupCallback + ); + } + } else { + // No worktree cleanup needed, just run artifact cleanup immediately + setImmediate(() => cleanupCallback()); + } return { success: true }; } catch (error) { @@ -883,4 +924,30 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); + // Archive progress handler + ipcMain.handle('archive:get-progress', async () => { + try { + if (!archiveProgressManager) { + return { success: true, data: { tasks: [], activeCount: 0, totalCount: 0 } }; + } + + const tasks = archiveProgressManager.getActiveTasks(); + const activeCount = tasks.filter((t: any) => + t.status !== 'completed' && t.status !== 'failed' + ).length; + + return { + success: true, + data: { + tasks, + activeCount, + totalCount: tasks.length + } + }; + } catch (error) { + console.error('Failed to get archive progress:', error); + return { success: false, error: 'Failed to get archive progress' }; + } + }); + } \ No newline at end of file diff --git a/main/src/ipc/types.ts b/main/src/ipc/types.ts index dd2c13e9..47203dd4 100644 --- a/main/src/ipc/types.ts +++ b/main/src/ipc/types.ts @@ -14,6 +14,7 @@ import type { StravuAuthManager } from '../services/stravuAuthManager'; import type { StravuNotebookService } from '../services/stravuNotebookService'; import type { ClaudeCodeManager } from '../services/claudeCodeManager'; import type { Logger } from '../utils/logger'; +import type { ArchiveProgressManager } from '../services/archiveProgressManager'; export interface AppServices { app: App; @@ -33,4 +34,5 @@ export interface AppServices { taskQueue: TaskQueue | null; getMainWindow: () => BrowserWindow | null; logger?: Logger; + archiveProgressManager?: ArchiveProgressManager; } \ No newline at end of file diff --git a/main/src/services/archiveProgressManager.ts b/main/src/services/archiveProgressManager.ts new file mode 100644 index 00000000..06c960f8 --- /dev/null +++ b/main/src/services/archiveProgressManager.ts @@ -0,0 +1,172 @@ +import { EventEmitter } from 'events'; + +export interface ArchiveTask { + sessionId: string; + sessionName: string; + worktreeName: string; + projectName: string; + status: 'pending' | 'queued' | 'removing-worktree' | 'cleaning-artifacts' | 'completed' | 'failed'; + startTime: Date; + endTime?: Date; + error?: string; + executeCallback?: () => Promise; +} + +export interface SerializedArchiveTask { + sessionId: string; + sessionName: string; + worktreeName: string; + projectName: string; + status: 'pending' | 'queued' | 'removing-worktree' | 'cleaning-artifacts' | 'completed' | 'failed'; + startTime: string; + endTime?: string; + error?: string; +} + +export class ArchiveProgressManager extends EventEmitter { + private activeTasks: Map = new Map(); + private taskQueue: ArchiveTask[] = []; + private isProcessing: boolean = false; + + addTask( + sessionId: string, + sessionName: string, + worktreeName: string, + projectName: string, + executeCallback?: () => Promise + ): void { + const task: ArchiveTask = { + sessionId, + sessionName, + worktreeName, + projectName, + status: 'queued', + startTime: new Date(), + executeCallback + }; + + this.activeTasks.set(sessionId, task); + this.taskQueue.push(task); + this.emitProgress(); + + // Start processing if not already processing + if (!this.isProcessing) { + this.processQueue(); + } + } + + private async processQueue(): Promise { + if (this.isProcessing || this.taskQueue.length === 0) { + return; + } + + this.isProcessing = true; + + while (this.taskQueue.length > 0) { + const task = this.taskQueue.shift(); + if (!task) continue; + + // Update status to pending (actively processing) + task.status = 'pending'; + this.emitProgress(); + + if (task.executeCallback) { + try { + console.log(`[ArchiveProgressManager] Starting archive for session ${task.sessionId}`); + await task.executeCallback(); + + // If the task wasn't explicitly marked as failed, mark it as completed + const currentTask = this.activeTasks.get(task.sessionId); + if (currentTask && currentTask.status !== 'failed') { + this.updateTaskStatus(task.sessionId, 'completed'); + } + } catch (error) { + console.error(`[ArchiveProgressManager] Error processing archive for session ${task.sessionId}:`, error); + this.updateTaskStatus(task.sessionId, 'failed', error instanceof Error ? error.message : 'Unknown error'); + } + } + } + + this.isProcessing = false; + console.log('[ArchiveProgressManager] Queue processing completed'); + } + + updateTaskStatus(sessionId: string, status: ArchiveTask['status'], error?: string): void { + const task = this.activeTasks.get(sessionId); + if (!task) return; + + task.status = status; + + if (error) { + task.error = error; + } + + if (status === 'completed' || status === 'failed') { + task.endTime = new Date(); + } + + this.emitProgress(); + + // Remove completed/failed tasks after a delay to show completion + if (status === 'completed' || status === 'failed') { + setTimeout(() => { + this.activeTasks.delete(sessionId); + this.emitProgress(); + }, 3000); // Keep visible for 3 seconds + } + } + + getActiveTasks(): SerializedArchiveTask[] { + // Return a serializable version without the executeCallback + return Array.from(this.activeTasks.values()).map(task => ({ + sessionId: task.sessionId, + sessionName: task.sessionName, + worktreeName: task.worktreeName, + projectName: task.projectName, + status: task.status, + startTime: task.startTime.toISOString(), + endTime: task.endTime?.toISOString(), + error: task.error + })); + } + + hasActiveTasks(): boolean { + // Consider both active tasks and queued tasks + const hasActive = Array.from(this.activeTasks.values()).some( + task => task.status !== 'completed' && task.status !== 'failed' + ); + return hasActive || this.taskQueue.length > 0; + } + + getActiveTaskCount(): number { + return this.activeTasks.size; + } + + getQueuedTaskCount(): number { + return this.taskQueue.length; + } + + private emitProgress(): void { + const tasks = this.getActiveTasks(); + const activeCount = tasks.filter(t => + t.status !== 'completed' && t.status !== 'failed' + ).length; + + console.log('[ArchiveProgressManager] Emitting progress:', { + tasks: tasks.length, + activeCount, + totalCount: tasks.length + }); + + this.emit('archive-progress', { + tasks, + activeCount, + totalCount: tasks.length + }); + } + + clearAll(): void { + this.activeTasks.clear(); + this.emitProgress(); + } +} \ No newline at end of file From 3dc42390275acbf4256d59c9a084e633917de4b1 Mon Sep 17 00:00:00 2001 From: Parminder Klair Date: Wed, 27 Aug 2025 11:37:11 +0100 Subject: [PATCH 28/30] merge fix --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index d643405e..f55dff2d 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,23 @@ Alternatively, for AUR maintainers, see `docs/PKGBUILD.example` for a template P ```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**: `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. + ## Building from Source @@ -136,7 +153,11 @@ pnpm build:arch ```bash # On Arch Linux sudo pacman -S bsdtar +<<<<<<< HEAD +======= + +>>>>>>> 1fc06b5 (merge fix) # On Ubuntu/Debian (for cross-platform builds) sudo apt-get install bsdtar ``` From e6fdabda12b18c8cc4c9758d94ae8b3efb8923b2 Mon Sep 17 00:00:00 2001 From: Parminder Klair Date: Wed, 27 Aug 2025 11:24:23 +0100 Subject: [PATCH 29/30] merge fix --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index f55dff2d..d00952aa 100644 --- a/README.md +++ b/README.md @@ -153,11 +153,8 @@ pnpm build:arch ```bash # On Arch Linux sudo pacman -S bsdtar -<<<<<<< HEAD -======= ->>>>>>> 1fc06b5 (merge fix) # On Ubuntu/Debian (for cross-platform builds) sudo apt-get install bsdtar ``` From e4793db11176e37d8dd27ea9be6f9c306a79a37f Mon Sep 17 00:00:00 2001 From: Parminder Klair Date: Wed, 27 Aug 2025 11:36:01 +0100 Subject: [PATCH 30/30] Update Arch Linux package naming to stravu-crystal --- NOTICES | 2 +- README.md | 6 +- docs/PKGBUILD.example | 28 ++++----- package.json | 6 +- scripts/configure-arch-build.js | 106 ++++++++++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 21 deletions(-) create mode 100644 scripts/configure-arch-build.js diff --git a/NOTICES b/NOTICES index 20af337c..f3ac29b9 100644 --- a/NOTICES +++ b/NOTICES @@ -3675,7 +3675,7 @@ For more information, please refer to ================================================================================ Package: Crystal -Version: 0.2.0 +Version: 0.2.3 Author: [object Object] License: MIT diff --git a/README.md b/README.md index d00952aa..6cb87b34 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ 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**: `Crystal-{version}-linux-x64.pkg.tar.xz` - Install with `sudo pacman -U Crystal-{version}-linux-x64.pkg.tar.xz` + - **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 @@ -113,8 +113,8 @@ 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 +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. diff --git a/docs/PKGBUILD.example b/docs/PKGBUILD.example index f37ed6e8..950208de 100644 --- a/docs/PKGBUILD.example +++ b/docs/PKGBUILD.example @@ -1,5 +1,5 @@ # Maintainer: Your Name -pkgname=crystal-bin +pkgname=stravu-crystal-bin pkgver=0.2.0 pkgrel=1 pkgdesc="Claude Code Commander for managing multiple Claude Code instances" @@ -7,38 +7,38 @@ 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=('crystal') -conflicts=('crystal') -source=("https://github.com/stravu/crystal/releases/download/v${pkgver}/Crystal-${pkgver}-linux-x64.pkg.tar.xz") +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}/Crystal-${pkgver}-linux-x64.pkg.tar.xz" -C "${pkgdir}/" + 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}/Crystal-${pkgver}-linux-x64.AppImage") +# source=("https://github.com/stravu/crystal/releases/download/v${pkgver}/stravu-crystal-${pkgver}-linux-x64.AppImage") # # package() { # # Install AppImage -# install -Dm755 "${srcdir}/Crystal-${pkgver}-linux-x64.AppImage" "${pkgdir}/opt/crystal/crystal.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/crystal" <