├── .nvmrc ├── .node-version ├── .github ├── CODEOWNERS └── pull_request_template.md ├── pnpm-workspace.yaml ├── main ├── assets │ ├── icon.icns │ ├── icon.ico │ ├── icon.png │ └── crystal-logo.svg ├── src │ ├── database │ │ ├── migrations │ │ │ ├── add_favorite_support.sql │ │ │ ├── add_last_viewed_field.sql │ │ │ ├── add_commit_message_to_execution_diffs.sql │ │ │ ├── add_run_started_timestamp.sql │ │ │ ├── add_claude_session_id.sql │ │ │ ├── add_archived_field.sql │ │ │ ├── add_is_main_repo_session.sql │ │ │ ├── add_permission_mode.sql │ │ │ ├── ignore_main_branch_column.sql │ │ │ ├── deprecate_main_branch.sql │ │ │ ├── remove_main_branch_from_code.sql │ │ │ ├── add_prompt_markers.sql │ │ │ ├── 003_add_tool_panels.sql │ │ │ ├── add_project_support.sql │ │ │ ├── add_conversation_support.sql │ │ │ ├── add_build_commands.sql │ │ │ ├── add_display_order.sql │ │ │ ├── normalize_timestamp_fields.sql │ │ │ ├── add_execution_diffs.sql │ │ │ ├── 005_unified_panel_settings.sql │ │ │ └── 004_claude_panels.sql │ │ └── schema.sql │ ├── services │ │ ├── database.ts │ │ ├── uiStateManager.ts │ │ ├── mcpPermissionServer.ts │ │ └── panels │ │ │ └── codex │ │ │ └── CODEX_CONFIG.md │ ├── types │ │ └── global.d.ts │ ├── test │ │ └── setup.ts │ ├── utils │ │ ├── worktreeUtils.ts │ │ ├── shellEscape.ts │ │ ├── crystalDirectory.ts │ │ └── promptEnhancer.ts │ ├── test-updater.ts │ ├── polyfills │ │ ├── README.md │ │ └── readablestream.ts │ ├── ipc │ │ ├── prompt.ts │ │ ├── dialog.ts │ │ ├── types.ts │ │ ├── nimbalyst.ts │ │ ├── config.ts │ │ ├── index.ts │ │ └── uiState.ts │ └── autoUpdater.ts ├── tsconfig.json ├── vitest.config.ts ├── eslint.config.js └── package.json ├── frontend ├── public │ ├── favicon.ico │ ├── stravu-logo.png │ ├── favicon-96x96.png │ ├── apple-touch-icon.png │ └── favicon.svg ├── postcss.config.js ├── src │ ├── utils │ │ ├── cn.ts │ │ ├── dashboardCache.ts │ │ ├── debounce.ts │ │ ├── sanitizer.ts │ │ ├── console.ts │ │ └── gitStatusLogger.ts │ ├── types │ │ ├── assets.d.ts │ │ ├── ansi-to-html.d.ts │ │ ├── folder.ts │ │ ├── panelComponents.ts │ │ ├── panelStore.ts │ │ ├── projectDashboard.ts │ │ ├── project.ts │ │ ├── config.ts │ │ └── diff.ts │ ├── stores │ │ ├── errorStore.ts │ │ ├── navigationStore.ts │ │ ├── slashCommandStore.ts │ │ ├── configStore.ts │ │ └── sessionHistoryStore.ts │ ├── components │ │ ├── MainProcessLogger.tsx │ │ ├── LoadingSpinner.tsx │ │ ├── panels │ │ │ ├── PanelLoadingFallback.tsx │ │ │ ├── DashboardPanel.tsx │ │ │ └── ai │ │ │ │ └── transformers │ │ │ │ └── MessageTransformer.ts │ │ ├── dialog │ │ │ └── BaseAIToolConfig.tsx │ │ ├── EmptyState.tsx │ │ ├── ui │ │ │ ├── FieldWithTooltip.tsx │ │ │ ├── EnhancedInput.tsx │ │ │ ├── SettingsSection.tsx │ │ │ ├── StatusDot.tsx │ │ │ ├── IconButton.tsx │ │ │ ├── Textarea.tsx │ │ │ ├── Tooltip.tsx │ │ │ └── CollapsibleCard.tsx │ │ ├── session │ │ │ └── FolderArchiveDialog.tsx │ │ ├── icons │ │ │ └── NimbalystIcon.tsx │ │ ├── MonacoErrorBoundary.tsx │ │ ├── dashboard │ │ │ └── StatusSummaryCards.tsx │ │ └── CommitModeIndicator.tsx │ ├── styles │ │ ├── tokens.css │ │ ├── monaco-overrides.css │ │ └── tokens │ │ │ ├── spacing.css │ │ │ └── typography.css │ ├── main.tsx │ ├── hooks │ │ ├── useResizable.ts │ │ └── useResizablePanel.ts │ ├── contexts │ │ ├── SessionContext.tsx │ │ └── ThemeContext.tsx │ └── assets │ │ └── crystal-logo.svg ├── tsconfig.node.json ├── vite.config.ts ├── vite.config.js ├── tsconfig.json ├── index.html ├── eslint.config.js └── package.json ├── screenshots ├── screenshot-diff.png ├── screenshot-run.png └── screenshot-create.png ├── .eslintignore ├── .npmrc ├── shared ├── package.json ├── types.ts └── types │ ├── models.ts │ └── aiPanelConfig.ts ├── tests ├── health-check.spec.ts ├── setup.ts └── smoke.spec.ts ├── setup-dev.sh ├── LICENSE ├── scripts ├── restore-version.js ├── README.md ├── prepare-canary.js ├── build-flatpak.sh ├── inject-build-info.js └── test-license-generation.js ├── playwright.config.ts ├── .gitignore ├── playwright.ci.config.ts ├── docs ├── troubleshooting │ ├── DIFF_VIEWER_CSS.md │ └── SETUP_TROUBLESHOOTING.md ├── LICENSE-COMPATIBILITY.md └── SESSION_OUTPUT_SYSTEM.md ├── playwright.ci.minimal.config.ts ├── com.stravu.crystal.yml ├── com.stravu.crystal.metainfo.xml ├── AGENTS.md └── check-license-compatibility.sh /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.15.1 -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 22.15.1 -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @jordan-BAIC @ghinkle @SaucyWrong 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'frontend' 3 | - 'main' 4 | - 'shared' -------------------------------------------------------------------------------- /main/assets/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stravu/crystal/HEAD/main/assets/icon.icns -------------------------------------------------------------------------------- /main/assets/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stravu/crystal/HEAD/main/assets/icon.ico -------------------------------------------------------------------------------- /main/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stravu/crystal/HEAD/main/assets/icon.png -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stravu/crystal/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/stravu-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stravu/crystal/HEAD/frontend/public/stravu-logo.png -------------------------------------------------------------------------------- /screenshots/screenshot-diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stravu/crystal/HEAD/screenshots/screenshot-diff.png -------------------------------------------------------------------------------- /screenshots/screenshot-run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stravu/crystal/HEAD/screenshots/screenshot-run.png -------------------------------------------------------------------------------- /frontend/public/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stravu/crystal/HEAD/frontend/public/favicon-96x96.png -------------------------------------------------------------------------------- /screenshots/screenshot-create.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stravu/crystal/HEAD/screenshots/screenshot-create.png -------------------------------------------------------------------------------- /frontend/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stravu/crystal/HEAD/frontend/public/apple-touch-icon.png -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } -------------------------------------------------------------------------------- /main/src/database/migrations/add_favorite_support.sql: -------------------------------------------------------------------------------- 1 | -- Add favorite support to sessions 2 | ALTER TABLE sessions ADD COLUMN is_favorite BOOLEAN DEFAULT 0; -------------------------------------------------------------------------------- /main/src/database/migrations/add_last_viewed_field.sql: -------------------------------------------------------------------------------- 1 | -- Add last_viewed_at field to sessions table 2 | ALTER TABLE sessions ADD COLUMN last_viewed_at TEXT; -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | dist-electron 4 | *.min.js 5 | vite.config.ts 6 | *.config.js 7 | *.config.cjs 8 | frontend/public 9 | main/dist 10 | backend -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | public-hoist-pattern[]=* 2 | public-hoist-pattern[]=!better-sqlite3 3 | public-hoist-pattern[]=!@homebridge/node-pty-prebuilt-multiarch 4 | auto-install-peers=false -------------------------------------------------------------------------------- /main/src/database/migrations/add_commit_message_to_execution_diffs.sql: -------------------------------------------------------------------------------- 1 | -- Add commit_message column to execution_diffs table 2 | ALTER TABLE execution_diffs ADD COLUMN commit_message TEXT; -------------------------------------------------------------------------------- /main/src/database/migrations/add_run_started_timestamp.sql: -------------------------------------------------------------------------------- 1 | -- Add run_started_at field to track when Claude actually starts running 2 | ALTER TABLE sessions ADD COLUMN run_started_at TEXT; -------------------------------------------------------------------------------- /main/src/database/migrations/add_claude_session_id.sql: -------------------------------------------------------------------------------- 1 | -- Add claude_session_id column to sessions table to store Claude's actual session ID 2 | ALTER TABLE sessions ADD COLUMN claude_session_id TEXT; -------------------------------------------------------------------------------- /frontend/src/utils/cn.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from 'clsx' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } -------------------------------------------------------------------------------- /shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shared", 3 | "version": "0.3.4", 4 | "private": true, 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "scripts": { 8 | "typecheck": "echo 'No TypeScript files to check'" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /main/src/database/migrations/add_archived_field.sql: -------------------------------------------------------------------------------- 1 | -- Add archived field to sessions table 2 | ALTER TABLE sessions ADD COLUMN archived BOOLEAN DEFAULT 0; 3 | 4 | -- Create index for faster queries on active sessions 5 | CREATE INDEX IF NOT EXISTS idx_sessions_archived ON sessions(archived); -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } -------------------------------------------------------------------------------- /main/src/database/migrations/add_is_main_repo_session.sql: -------------------------------------------------------------------------------- 1 | -- Add is_main_repo column to sessions table to mark sessions that run in the main repository 2 | ALTER TABLE sessions ADD COLUMN is_main_repo BOOLEAN DEFAULT 0; 3 | 4 | -- Create index for quick lookup of main repo sessions 5 | CREATE INDEX IF NOT EXISTS idx_sessions_is_main_repo ON sessions(is_main_repo, project_id); -------------------------------------------------------------------------------- /main/src/database/migrations/add_permission_mode.sql: -------------------------------------------------------------------------------- 1 | -- Add permission mode to sessions table 2 | ALTER TABLE sessions ADD COLUMN permission_mode TEXT DEFAULT 'ignore' CHECK(permission_mode IN ('approve', 'ignore')); 3 | 4 | -- Also add default permission mode to projects 5 | ALTER TABLE projects ADD COLUMN default_permission_mode TEXT DEFAULT 'ignore' CHECK(default_permission_mode IN ('approve', 'ignore')); -------------------------------------------------------------------------------- /main/src/database/migrations/ignore_main_branch_column.sql: -------------------------------------------------------------------------------- 1 | -- The main_branch column is now completely ignored by the application 2 | -- The system always uses dynamic branch detection via getProjectMainBranch() 3 | -- This migration documents that the main_branch column is no longer used 4 | -- but we keep it in the database for backward compatibility 5 | -- 6 | -- The column remains in the schema but all code ignores it -------------------------------------------------------------------------------- /frontend/src/types/assets.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const content: string; 3 | export default content; 4 | } 5 | 6 | declare module '*.png' { 7 | const content: string; 8 | export default content; 9 | } 10 | 11 | declare module '*.jpg' { 12 | const content: string; 13 | export default content; 14 | } 15 | 16 | declare module '*.jpeg' { 17 | const content: string; 18 | export default content; 19 | } -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | server: { 7 | port: 4521, 8 | strictPort: true 9 | }, 10 | base: './', 11 | build: { 12 | // Ensure assets are copied and paths are relative 13 | assetsDir: 'assets', 14 | // Copy public files to dist 15 | copyPublicDir: true 16 | } 17 | }); -------------------------------------------------------------------------------- /main/src/services/database.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseService } from '../database/database'; 2 | import { join } from 'path'; 3 | import { getCrystalDirectory } from '../utils/crystalDirectory'; 4 | 5 | // Create and export a singleton instance 6 | const dbPath = join(getCrystalDirectory(), 'sessions.db'); 7 | export const databaseService = new DatabaseService(dbPath); 8 | 9 | // Initialize the database schema and run migrations 10 | databaseService.initialize(); -------------------------------------------------------------------------------- /frontend/src/types/ansi-to-html.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'ansi-to-html' { 2 | interface AnsiToHtmlOptions { 3 | fg?: string; 4 | bg?: string; 5 | newline?: boolean; 6 | escapeXML?: boolean; 7 | stream?: boolean; 8 | colors?: { 9 | [key: number]: string; 10 | }; 11 | } 12 | 13 | class AnsiToHtml { 14 | constructor(options?: AnsiToHtmlOptions); 15 | toHtml(text: string): string; 16 | } 17 | 18 | export = AnsiToHtml; 19 | } -------------------------------------------------------------------------------- /frontend/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | export default defineConfig({ 4 | plugins: [react()], 5 | server: { 6 | port: 4521, 7 | strictPort: true 8 | }, 9 | base: './', 10 | build: { 11 | // Ensure assets are copied and paths are relative 12 | assetsDir: 'assets', 13 | // Copy public files to dist 14 | copyPublicDir: true 15 | } 16 | }); 17 | -------------------------------------------------------------------------------- /main/src/database/migrations/deprecate_main_branch.sql: -------------------------------------------------------------------------------- 1 | -- The main_branch field in the projects table is now deprecated. 2 | -- It is kept for backward compatibility but is now used as an override. 3 | -- If main_branch is NULL or empty, the system will auto-detect the main branch 4 | -- from the project's current branch. 5 | -- 6 | -- This migration doesn't change the schema, it just documents the change in behavior. 7 | -- The field can still be set via project settings for advanced users who need 8 | -- to override the auto-detected branch. -------------------------------------------------------------------------------- /frontend/src/types/folder.ts: -------------------------------------------------------------------------------- 1 | export interface Folder { 2 | id: string; 3 | name: string; 4 | projectId: number; 5 | parentFolderId?: string | null; 6 | displayOrder: number; 7 | createdAt: string; 8 | updatedAt: string; 9 | children?: Folder[]; // For tree representation 10 | } 11 | 12 | export interface CreateFolderRequest { 13 | name: string; 14 | projectId: number; 15 | parentFolderId?: string | null; 16 | } 17 | 18 | export interface UpdateFolderRequest { 19 | name?: string; 20 | displayOrder?: number; 21 | parentFolderId?: string | null; 22 | } -------------------------------------------------------------------------------- /main/src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * Global type declarations for ReadableStream and related Web Streams API 5 | * This ensures TypeScript knows these are available globally after polyfill 6 | */ 7 | declare global { 8 | // These types are imported from the DOM lib, but we re-declare them here 9 | // to ensure they're available in the Node.js context after polyfilling 10 | var ReadableStream: typeof globalThis.ReadableStream; 11 | var WritableStream: typeof globalThis.WritableStream; 12 | var TransformStream: typeof globalThis.TransformStream; 13 | } 14 | 15 | export {}; -------------------------------------------------------------------------------- /main/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "commonjs", 5 | "lib": ["ES2022", "DOM"], 6 | "outDir": "./dist", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "resolveJsonModule": true, 12 | "moduleResolution": "node", 13 | "allowSyntheticDefaultImports": true, 14 | "declaration": true, 15 | "declarationMap": true, 16 | "sourceMap": true, 17 | "types": ["node"] 18 | }, 19 | "include": ["src/**/*", "../shared/**/*"], 20 | "exclude": ["node_modules", "dist"] 21 | } -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "moduleResolution": "bundler", 9 | "allowImportingTsExtensions": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "noEmit": true, 13 | "jsx": "react-jsx", 14 | "strict": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noFallthroughCasesInSwitch": true 18 | }, 19 | "include": ["src"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } -------------------------------------------------------------------------------- /frontend/src/stores/errorStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | interface ErrorInfo { 4 | title?: string; 5 | error: string; 6 | details?: string; 7 | command?: string; 8 | } 9 | 10 | interface ErrorStore { 11 | currentError: ErrorInfo | null; 12 | showError: (error: ErrorInfo) => void; 13 | clearError: () => void; 14 | } 15 | 16 | export const useErrorStore = create((set) => ({ 17 | currentError: null, 18 | 19 | showError: (error) => { 20 | console.error('[ErrorStore] Showing error:', error); 21 | set({ currentError: error }); 22 | }, 23 | 24 | clearError: () => { 25 | set({ currentError: null }); 26 | }, 27 | })); -------------------------------------------------------------------------------- /tests/health-check.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test.describe('Health Check', () => { 4 | test('Electron app should start', async ({ page }) => { 5 | // Try to navigate to the app 6 | await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 }); 7 | 8 | // Wait for any content to appear 9 | await page.waitForSelector('body', { timeout: 10000 }); 10 | 11 | // Check that the page has loaded 12 | const title = await page.title(); 13 | expect(title).toBeTruthy(); 14 | 15 | 16 | // Take a screenshot for debugging 17 | await page.screenshot({ path: 'test-results/health-check.png' }); 18 | }); 19 | }); -------------------------------------------------------------------------------- /main/src/test/setup.ts: -------------------------------------------------------------------------------- 1 | // Test setup file for Vitest 2 | import { vi } from 'vitest'; 3 | 4 | // Mock Electron modules 5 | vi.mock('electron', () => ({ 6 | app: { 7 | getPath: vi.fn(() => '/mock/path'), 8 | getName: vi.fn(() => 'Crystal'), 9 | getVersion: vi.fn(() => '0.1.0'), 10 | }, 11 | ipcMain: { 12 | handle: vi.fn(), 13 | on: vi.fn(), 14 | removeHandler: vi.fn(), 15 | }, 16 | BrowserWindow: vi.fn(), 17 | })); 18 | 19 | // Set up global test environment 20 | global.console = { 21 | ...console, 22 | // Suppress logs during tests unless debugging 23 | log: vi.fn(), 24 | debug: vi.fn(), 25 | info: vi.fn(), 26 | warn: vi.fn(), 27 | error: vi.fn(), 28 | }; -------------------------------------------------------------------------------- /main/src/database/migrations/remove_main_branch_from_code.sql: -------------------------------------------------------------------------------- 1 | -- The main_branch column is now completely removed from the codebase 2 | -- The system ONLY uses the current branch from the project root directory 3 | -- via getProjectMainBranch() which calls `git branch --show-current` 4 | -- 5 | -- This migration documents that: 6 | -- 1. The main_branch field has been removed from all TypeScript interfaces 7 | -- 2. The createProject and updateProject methods no longer accept main_branch 8 | -- 3. getProjectMainBranch() no longer uses the project object parameter 9 | -- 4. There is NO caching - the current branch is always checked dynamically 10 | -- 11 | -- The database column remains for backward compatibility but is completely ignored -------------------------------------------------------------------------------- /frontend/src/types/panelComponents.ts: -------------------------------------------------------------------------------- 1 | import { ToolPanel, ToolPanelType } from '../../../shared/types/panels'; 2 | 3 | export type PanelContext = 'project' | 'worktree'; 4 | 5 | export interface PanelTabBarProps { 6 | panels: ToolPanel[]; 7 | activePanel?: ToolPanel; 8 | onPanelSelect: (panel: ToolPanel) => void; 9 | onPanelClose: (panel: ToolPanel) => void; 10 | onPanelCreate: (type: ToolPanelType) => void; 11 | context?: PanelContext; // Optional context to filter available panels 12 | } 13 | 14 | export interface PanelContainerProps { 15 | panel: ToolPanel; 16 | isActive: boolean; 17 | isMainRepo?: boolean; 18 | } 19 | 20 | export interface TerminalPanelProps { 21 | panel: ToolPanel; 22 | isActive: boolean; 23 | } 24 | -------------------------------------------------------------------------------- /main/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | import path from 'path'; 3 | 4 | export default defineConfig({ 5 | test: { 6 | globals: true, 7 | environment: 'node', 8 | coverage: { 9 | provider: 'v8', 10 | reporter: ['text', 'json', 'html'], 11 | exclude: [ 12 | 'node_modules/', 13 | 'dist/', 14 | '**/*.d.ts', 15 | '**/*.config.*', 16 | '**/test/**', 17 | 'src/index.ts', 18 | 'src/preload.ts', 19 | ] 20 | }, 21 | include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], 22 | setupFiles: ['./src/test/setup.ts'], 23 | }, 24 | resolve: { 25 | alias: { 26 | '@': path.resolve(__dirname, './src'), 27 | }, 28 | }, 29 | }); -------------------------------------------------------------------------------- /main/src/database/migrations/add_prompt_markers.sql: -------------------------------------------------------------------------------- 1 | -- Prompt markers table to track prompt positions in the terminal output 2 | CREATE TABLE IF NOT EXISTS prompt_markers ( 3 | id INTEGER PRIMARY KEY AUTOINCREMENT, 4 | session_id TEXT NOT NULL, 5 | prompt_text TEXT NOT NULL, 6 | output_index INTEGER NOT NULL, -- Position in the session_outputs table 7 | output_line INTEGER, -- Approximate line number in output for scrolling 8 | timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, 9 | FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE 10 | ); 11 | 12 | -- Index for faster lookups 13 | CREATE INDEX IF NOT EXISTS idx_prompt_markers_session_id ON prompt_markers(session_id); 14 | CREATE INDEX IF NOT EXISTS idx_prompt_markers_timestamp ON prompt_markers(timestamp); -------------------------------------------------------------------------------- /main/src/database/migrations/003_add_tool_panels.sql: -------------------------------------------------------------------------------- 1 | -- Tool panels table 2 | CREATE TABLE IF NOT EXISTS tool_panels ( 3 | id TEXT PRIMARY KEY, 4 | session_id TEXT NOT NULL, 5 | type TEXT NOT NULL, 6 | title TEXT NOT NULL, 7 | state TEXT, -- JSON string 8 | metadata TEXT, -- JSON string 9 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 10 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, 11 | FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE 12 | ); 13 | 14 | -- Track active panel per session 15 | ALTER TABLE sessions ADD COLUMN active_panel_id TEXT; 16 | 17 | -- Index for faster queries 18 | CREATE INDEX idx_tool_panels_session_id ON tool_panels(session_id); 19 | CREATE INDEX idx_tool_panels_type ON tool_panels(type); -------------------------------------------------------------------------------- /main/src/database/migrations/add_project_support.sql: -------------------------------------------------------------------------------- 1 | -- Add projects table 2 | CREATE TABLE IF NOT EXISTS projects ( 3 | id INTEGER PRIMARY KEY AUTOINCREMENT, 4 | name TEXT NOT NULL, 5 | path TEXT NOT NULL UNIQUE, 6 | system_prompt TEXT, 7 | run_script TEXT, 8 | active BOOLEAN NOT NULL DEFAULT 0, 9 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 10 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP 11 | ); 12 | 13 | -- Add project_id to sessions table 14 | ALTER TABLE sessions ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE; 15 | 16 | -- Create index for faster project-based queries 17 | CREATE INDEX idx_sessions_project_id ON sessions(project_id); 18 | 19 | -- Migrate existing sessions to a default project if gitRepoPath exists 20 | -- This will be handled in the migration code -------------------------------------------------------------------------------- /main/src/database/migrations/add_conversation_support.sql: -------------------------------------------------------------------------------- 1 | -- Rename prompt column to initial_prompt for clarity 2 | ALTER TABLE sessions RENAME COLUMN prompt TO initial_prompt; 3 | 4 | -- Create conversation messages table 5 | CREATE TABLE IF NOT EXISTS conversation_messages ( 6 | id INTEGER PRIMARY KEY AUTOINCREMENT, 7 | session_id TEXT NOT NULL, 8 | message_type TEXT NOT NULL CHECK (message_type IN ('user', 'assistant')), 9 | content TEXT NOT NULL, 10 | timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, 11 | FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE 12 | ); 13 | 14 | -- Create indexes for conversation messages 15 | CREATE INDEX IF NOT EXISTS idx_conversation_messages_session_id ON conversation_messages(session_id); 16 | CREATE INDEX IF NOT EXISTS idx_conversation_messages_timestamp ON conversation_messages(timestamp); -------------------------------------------------------------------------------- /frontend/src/components/MainProcessLogger.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | export function MainProcessLogger() { 4 | useEffect(() => { 5 | // Forward main process logs to browser console 6 | const unsubscribe = window.electronAPI?.events?.onMainLog?.((level: string, message: string) => { 7 | const prefix = '[Main Process]'; 8 | switch (level) { 9 | case 'error': 10 | console.error(prefix, message); 11 | break; 12 | case 'warn': 13 | console.warn(prefix, message); 14 | break; 15 | case 'info': 16 | console.info(prefix, message); 17 | break; 18 | default: 19 | console.log(prefix, message); 20 | } 21 | }); 22 | 23 | return () => { 24 | unsubscribe?.(); 25 | }; 26 | }, []); 27 | 28 | return null; 29 | } -------------------------------------------------------------------------------- /main/src/utils/worktreeUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility functions for working with git worktrees 3 | */ 4 | 5 | /** 6 | * Extract worktree name from the current working directory path 7 | * Returns the worktree name if running in a worktree, undefined if in main repository 8 | */ 9 | export function getCurrentWorktreeName(cwd: string): string | undefined { 10 | try { 11 | // Match worktrees directory followed by worktree name 12 | // Handles both Unix (/) and Windows (\) path separators 13 | // For paths like "worktrees/feature/dev-mode-worktree-label", captures "feature/dev-mode-worktree-label" 14 | const worktreeMatch = cwd.match(/worktrees[\/\\](.+)/); 15 | return worktreeMatch ? worktreeMatch[1] : undefined; 16 | } catch (error) { 17 | console.log('Could not extract worktree name:', error); 18 | return undefined; 19 | } 20 | } -------------------------------------------------------------------------------- /main/src/test-updater.ts: -------------------------------------------------------------------------------- 1 | import { autoUpdater } from 'electron-updater'; 2 | 3 | export function setupTestUpdater() { 4 | // Point to local server for testing 5 | autoUpdater.setFeedURL({ 6 | provider: 'generic', 7 | url: process.env.UPDATE_SERVER_URL || 'http://localhost:8080' 8 | }); 9 | 10 | // Configure for testing 11 | autoUpdater.autoDownload = false; 12 | autoUpdater.autoInstallOnAppQuit = true; 13 | autoUpdater.allowPrerelease = false; 14 | autoUpdater.allowDowngrade = false; 15 | 16 | // Log all events for debugging 17 | autoUpdater.logger = console; 18 | // Set debug level for winston-based loggers if available 19 | const logger = autoUpdater.logger as { transports?: { file?: { level: string } } }; 20 | if (logger && logger.transports && logger.transports.file) { 21 | logger.transports.file.level = 'debug'; 22 | } 23 | } -------------------------------------------------------------------------------- /frontend/src/components/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2 } from 'lucide-react'; 2 | import { cn } from '../utils/cn'; 3 | 4 | interface LoadingSpinnerProps { 5 | text?: string; 6 | size?: 'small' | 'medium' | 'large'; 7 | className?: string; 8 | } 9 | 10 | export function LoadingSpinner({ text = 'Loading...', size = 'medium', className }: LoadingSpinnerProps) { 11 | const sizeClasses = { 12 | small: 'w-4 h-4', 13 | medium: 'w-6 h-6', 14 | large: 'w-8 h-8' 15 | }; 16 | 17 | const textSizeClasses = { 18 | small: 'text-sm', 19 | medium: 'text-base', 20 | large: 'text-lg' 21 | }; 22 | 23 | return ( 24 |
25 | 26 | {text} 27 |
28 | ); 29 | } -------------------------------------------------------------------------------- /frontend/src/stores/navigationStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | interface NavigationState { 4 | activeView: 'sessions' | 'project'; 5 | activeProjectId: number | null; 6 | 7 | // Actions 8 | setActiveView: (view: 'sessions' | 'project') => void; 9 | setActiveProjectId: (projectId: number | null) => void; 10 | navigateToProject: (projectId: number) => void; 11 | navigateToSessions: () => void; 12 | } 13 | 14 | export const useNavigationStore = create((set) => ({ 15 | activeView: 'sessions', 16 | activeProjectId: null, 17 | 18 | setActiveView: (view) => set({ activeView: view }), 19 | 20 | setActiveProjectId: (projectId) => set({ activeProjectId: projectId }), 21 | 22 | navigateToProject: (projectId) => set({ 23 | activeView: 'project', 24 | activeProjectId: projectId 25 | }), 26 | 27 | navigateToSessions: () => set({ 28 | activeView: 'sessions', 29 | activeProjectId: null 30 | }), 31 | })); -------------------------------------------------------------------------------- /setup-dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Development environment setup script for Crystal 4 | 5 | echo "🔧 Setting up Crystal development environment..." 6 | 7 | # Check if homebrew is installed 8 | if ! command -v brew &> /dev/null; then 9 | echo "❌ Homebrew is required. Please install from https://brew.sh" 10 | exit 1 11 | fi 12 | 13 | # Ensure python-setuptools is installed (fixes distutils issue) 14 | if ! brew list python-setuptools &> /dev/null; then 15 | echo "📦 Installing python-setuptools..." 16 | brew install python-setuptools 17 | fi 18 | 19 | # Check Node version 20 | NODE_VERSION=$(node -v) 21 | echo "📌 Using Node.js $NODE_VERSION" 22 | 23 | # Check if pnpm is installed 24 | if ! command -v pnpm &> /dev/null; then 25 | echo "📦 Installing pnpm..." 26 | npm install -g pnpm 27 | fi 28 | 29 | # Run the setup 30 | echo "🚀 Running pnpm setup..." 31 | pnpm -w run setup 32 | 33 | echo "✅ Setup complete! You can now run 'pnpm dev' to start the application." -------------------------------------------------------------------------------- /frontend/src/components/panels/PanelLoadingFallback.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { RefreshCw } from 'lucide-react'; 3 | 4 | interface PanelLoadingFallbackProps { 5 | panelType?: string; 6 | message?: string; 7 | } 8 | 9 | /** 10 | * Optimized loading fallback for lazy-loaded panels 11 | * Reduces unnecessary re-renders and provides better UX 12 | */ 13 | export const PanelLoadingFallback: React.FC = React.memo(({ 14 | panelType = 'panel', 15 | message 16 | }) => ( 17 |
18 |
19 |
27 |
28 | )); 29 | 30 | PanelLoadingFallback.displayName = 'PanelLoadingFallback'; -------------------------------------------------------------------------------- /main/src/database/migrations/add_build_commands.sql: -------------------------------------------------------------------------------- 1 | -- Add build_script column to projects table 2 | ALTER TABLE projects ADD COLUMN build_script TEXT; 3 | 4 | -- Add main_branch column to projects table if it doesn't exist 5 | -- (This might already exist from another migration) 6 | ALTER TABLE projects ADD COLUMN main_branch TEXT DEFAULT 'main'; 7 | 8 | -- Create a new table for multiple run commands 9 | CREATE TABLE IF NOT EXISTS project_run_commands ( 10 | id INTEGER PRIMARY KEY AUTOINCREMENT, 11 | project_id INTEGER NOT NULL, 12 | command TEXT NOT NULL, 13 | display_name TEXT, 14 | order_index INTEGER DEFAULT 0, 15 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 16 | FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE 17 | ); 18 | 19 | -- Create index for faster queries 20 | CREATE INDEX idx_project_run_commands_project_id ON project_run_commands(project_id); 21 | 22 | -- Migrate existing run_script data to the new table 23 | -- This will be handled in the migration code -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Crystal 10 | 16 | 17 | 18 | 22 |
23 | 24 | 25 | -------------------------------------------------------------------------------- /main/eslint.config.js: -------------------------------------------------------------------------------- 1 | const js = require('@eslint/js'); 2 | const typescript = require('typescript-eslint'); 3 | 4 | module.exports = [ 5 | js.configs.recommended, 6 | ...typescript.configs.recommended, 7 | { 8 | files: ['src/**/*.ts'], 9 | languageOptions: { 10 | ecmaVersion: 2022, 11 | sourceType: 'module', 12 | parser: typescript.parser 13 | }, 14 | rules: { 15 | '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], 16 | '@typescript-eslint/no-explicit-any': 'error', 17 | '@typescript-eslint/explicit-module-boundary-types': 'off', 18 | '@typescript-eslint/no-require-imports': 'warn', // Downgrade to warning 19 | 'no-console': 'off', // Allow console in main process 20 | 'no-useless-escape': 'warn', // Downgrade to warning 21 | 'prefer-const': 'warn', // Downgrade to warning 22 | 'no-empty': 'warn' // Downgrade to warning 23 | } 24 | }, 25 | { 26 | ignores: ['dist/', 'node_modules/', '*.config.js'] 27 | } 28 | ]; -------------------------------------------------------------------------------- /frontend/src/styles/tokens.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Crystal Design Tokens 3 | * Central import file for all design tokens 4 | */ 5 | 6 | /* Import all token categories */ 7 | @import './tokens/colors.css'; 8 | @import './tokens/spacing.css'; 9 | @import './tokens/typography.css'; 10 | @import './tokens/effects.css'; 11 | 12 | /* 13 | * Token Categories: 14 | * 15 | * 1. Colors (./tokens/colors.css) 16 | * - Primitive color values 17 | * - Semantic color tokens 18 | * - Component-specific colors 19 | * 20 | * 2. Spacing (./tokens/spacing.css) 21 | * - Base spacing scale 22 | * - Component-specific spacing 23 | * - Layout dimensions 24 | * 25 | * 3. Typography (./tokens/typography.css) 26 | * - Font families, sizes, weights 27 | * - Line heights, letter spacing 28 | * - Component-specific typography 29 | * 30 | * 4. Effects (./tokens/effects.css) 31 | * - Border radius values 32 | * - Box shadows 33 | * - Transitions and animations 34 | * - Z-index scale 35 | * - Opacity values 36 | */ -------------------------------------------------------------------------------- /frontend/src/components/dialog/BaseAIToolConfig.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Card } from '../ui/Card'; 3 | import { Sparkles } from 'lucide-react'; 4 | import type { AttachedImage, AttachedText } from '../../types/session'; 5 | 6 | export interface BaseAIToolConfig { 7 | prompt?: string; 8 | model?: string; 9 | attachedImages?: AttachedImage[]; 10 | attachedTexts?: AttachedText[]; 11 | ultrathink?: boolean; 12 | } 13 | 14 | export interface BaseAIToolConfigProps { 15 | config: BaseAIToolConfig; 16 | onChange: (config: BaseAIToolConfig) => void; 17 | disabled?: boolean; 18 | children?: React.ReactNode; 19 | } 20 | 21 | export const BaseAIToolConfigComponent: React.FC = ({ 22 | children 23 | }) => { 24 | return ( 25 | 26 |
27 | 28 | AI Tool Configuration 29 |
30 | 31 | {children} 32 |
33 | ); 34 | }; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Stravu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /frontend/src/components/EmptyState.tsx: -------------------------------------------------------------------------------- 1 | import { LucideIcon } from 'lucide-react'; 2 | import { Button } from './ui/Button'; 3 | import { cn } from '../utils/cn'; 4 | 5 | interface EmptyStateProps { 6 | icon: LucideIcon; 7 | title: string; 8 | description: string; 9 | action?: { 10 | label: string; 11 | onClick: () => void; 12 | }; 13 | className?: string; 14 | } 15 | 16 | export function EmptyState({ icon: Icon, title, description, action, className }: EmptyStateProps) { 17 | return ( 18 |
19 |
20 | 21 |
22 |

{title}

23 |

{description}

24 | {action && ( 25 | 28 | )} 29 |
30 | ); 31 | } -------------------------------------------------------------------------------- /main/src/polyfills/README.md: -------------------------------------------------------------------------------- 1 | # Polyfills 2 | 3 | This directory contains polyfills needed for the Crystal application to run properly in different Node.js environments. 4 | 5 | ## ReadableStream Polyfill 6 | 7 | The `readablestream.ts` file provides a polyfill for the Web Streams API (ReadableStream, WritableStream, TransformStream) which is required by the Claude Code SDK. 8 | 9 | ### Why is this needed? 10 | 11 | The `@anthropic-ai/claude-code` SDK uses the ReadableStream API internally, but this API is not available in older Node.js versions or might not be globally available in some Electron contexts. 12 | 13 | ### How it works: 14 | 15 | 1. First, it checks if ReadableStream is already available globally 16 | 2. If not, it tries to use Node.js's built-in `stream/web` module (available in Node 16.5+) 17 | 3. If that fails, it falls back to the `web-streams-polyfill` package 18 | 4. The polyfill makes these APIs available globally for the Claude Code SDK to use 19 | 20 | ### Usage: 21 | 22 | This polyfill is automatically loaded at the very beginning of the main process in `index.ts` before any other imports to ensure it's available when the Claude Code SDK is initialized. -------------------------------------------------------------------------------- /frontend/src/utils/dashboardCache.ts: -------------------------------------------------------------------------------- 1 | import type { ProjectDashboardData } from '../types/projectDashboard'; 2 | 3 | interface CacheEntry { 4 | data: ProjectDashboardData; 5 | timestamp: number; 6 | } 7 | 8 | class DashboardCache { 9 | private cache: Map = new Map(); 10 | private readonly CACHE_DURATION = 60 * 1000; // 1 minute cache 11 | 12 | set(projectId: number, data: ProjectDashboardData): void { 13 | this.cache.set(projectId, { 14 | data, 15 | timestamp: Date.now() 16 | }); 17 | } 18 | 19 | get(projectId: number): ProjectDashboardData | null { 20 | const entry = this.cache.get(projectId); 21 | 22 | if (!entry) { 23 | return null; 24 | } 25 | 26 | // Check if cache is expired 27 | if (Date.now() - entry.timestamp > this.CACHE_DURATION) { 28 | this.cache.delete(projectId); 29 | return null; 30 | } 31 | 32 | return entry.data; 33 | } 34 | 35 | invalidate(projectId: number): void { 36 | this.cache.delete(projectId); 37 | } 38 | 39 | invalidateAll(): void { 40 | this.cache.clear(); 41 | } 42 | } 43 | 44 | export const dashboardCache = new DashboardCache(); -------------------------------------------------------------------------------- /frontend/src/utils/debounce.ts: -------------------------------------------------------------------------------- 1 | export interface DebouncedFunction unknown> { 2 | (...args: Parameters): void; 3 | cancel: () => void; 4 | flush: () => void; 5 | } 6 | 7 | export function debounce unknown>( 8 | func: T, 9 | wait: number 10 | ): DebouncedFunction { 11 | let timeout: NodeJS.Timeout | null = null; 12 | let lastArgs: Parameters | null = null; 13 | 14 | const debounced = function(...args: Parameters) { 15 | lastArgs = args; 16 | 17 | if (timeout) { 18 | clearTimeout(timeout); 19 | } 20 | 21 | timeout = setTimeout(() => { 22 | func(...args); 23 | lastArgs = null; 24 | timeout = null; 25 | }, wait); 26 | }; 27 | 28 | debounced.cancel = function() { 29 | if (timeout) { 30 | clearTimeout(timeout); 31 | timeout = null; 32 | } 33 | lastArgs = null; 34 | }; 35 | 36 | debounced.flush = function() { 37 | if (timeout && lastArgs) { 38 | clearTimeout(timeout); 39 | func(...lastArgs); 40 | timeout = null; 41 | lastArgs = null; 42 | } 43 | }; 44 | 45 | return debounced; 46 | } -------------------------------------------------------------------------------- /main/src/database/migrations/add_display_order.sql: -------------------------------------------------------------------------------- 1 | -- Add display_order to projects table 2 | ALTER TABLE projects ADD COLUMN display_order INTEGER; 3 | 4 | -- Add display_order to sessions table 5 | ALTER TABLE sessions ADD COLUMN display_order INTEGER; 6 | 7 | -- Initialize display_order for existing projects based on creation order 8 | UPDATE projects 9 | SET display_order = ( 10 | SELECT COUNT(*) 11 | FROM projects p2 12 | WHERE p2.created_at <= projects.created_at OR (p2.created_at = projects.created_at AND p2.id <= projects.id) 13 | ) - 1 14 | WHERE display_order IS NULL; 15 | 16 | -- Initialize display_order for existing sessions within each project 17 | UPDATE sessions 18 | SET display_order = ( 19 | SELECT COUNT(*) 20 | FROM sessions s2 21 | WHERE s2.project_id = sessions.project_id 22 | AND (s2.created_at < sessions.created_at OR (s2.created_at = sessions.created_at AND s2.id <= sessions.id)) 23 | ) - 1 24 | WHERE display_order IS NULL; 25 | 26 | -- Create indexes for faster queries 27 | CREATE INDEX IF NOT EXISTS idx_projects_display_order ON projects(display_order); 28 | CREATE INDEX IF NOT EXISTS idx_sessions_display_order ON sessions(project_id, display_order); -------------------------------------------------------------------------------- /frontend/src/components/ui/FieldWithTooltip.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { HelpCircle } from 'lucide-react'; 3 | import { cn } from '../../utils/cn'; 4 | import { Tooltip } from './Tooltip'; 5 | 6 | export interface FieldWithTooltipProps { 7 | label: string; 8 | tooltip: string; 9 | required?: boolean; 10 | children: React.ReactNode; 11 | className?: string; 12 | } 13 | 14 | export const FieldWithTooltip: React.FC = ({ 15 | label, 16 | tooltip, 17 | required = false, 18 | children, 19 | className, 20 | }) => { 21 | return ( 22 |
23 |
24 | 28 | 29 | 30 | 31 |
32 | {children} 33 |
34 | ); 35 | }; 36 | 37 | FieldWithTooltip.displayName = 'FieldWithTooltip'; -------------------------------------------------------------------------------- /main/src/database/migrations/normalize_timestamp_fields.sql: -------------------------------------------------------------------------------- 1 | -- Convert TEXT timestamp fields to DATETIME for consistency 2 | -- This migration converts last_viewed_at and run_started_at from TEXT to DATETIME 3 | 4 | -- Step 1: Create new temporary columns with DATETIME type 5 | ALTER TABLE sessions ADD COLUMN last_viewed_at_new DATETIME; 6 | ALTER TABLE sessions ADD COLUMN run_started_at_new DATETIME; 7 | 8 | -- Step 2: Copy and convert existing data 9 | -- SQLite will automatically parse ISO 8601 strings to DATETIME 10 | UPDATE sessions SET last_viewed_at_new = datetime(last_viewed_at) WHERE last_viewed_at IS NOT NULL; 11 | UPDATE sessions SET run_started_at_new = datetime(run_started_at) WHERE run_started_at IS NOT NULL; 12 | 13 | -- Step 3: Drop old columns 14 | ALTER TABLE sessions DROP COLUMN last_viewed_at; 15 | ALTER TABLE sessions DROP COLUMN run_started_at; 16 | 17 | -- Step 4: Rename new columns to original names 18 | ALTER TABLE sessions RENAME COLUMN last_viewed_at_new TO last_viewed_at; 19 | ALTER TABLE sessions RENAME COLUMN run_started_at_new TO run_started_at; 20 | 21 | -- Step 5: Add missing completion_timestamp field to prompt_markers table 22 | ALTER TABLE prompt_markers ADD COLUMN completion_timestamp DATETIME; -------------------------------------------------------------------------------- /scripts/restore-version.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const { execSync } = require('child_process'); 6 | 7 | // In CI environment, skip restoration since it's not needed 8 | // The CI runs in a fresh environment each time 9 | // GitHub Actions sets GITHUB_ACTIONS=true (boolean true, not string 'true') 10 | if (process.env.CI || process.env.GITHUB_ACTIONS) { 11 | console.log('Skipping package.json restoration in CI environment'); 12 | process.exit(0); 13 | } 14 | 15 | // Restore the original package.json version from git (for local development only) 16 | try { 17 | // First, discard any uncommitted changes to package.json 18 | execSync('git checkout HEAD -- package.json', { 19 | stdio: 'inherit', 20 | // Add timeout to prevent hanging 21 | timeout: 5000 22 | }); 23 | console.log('Restored original package.json version'); 24 | } catch (err) { 25 | console.error('Failed to restore package.json:', err.message); 26 | // In CI, don't fail the build over this 27 | if (process.env.CI || process.env.GITHUB_ACTIONS) { 28 | console.log('Continuing despite restore failure in CI'); 29 | process.exit(0); 30 | } 31 | process.exit(1); 32 | } -------------------------------------------------------------------------------- /scripts/README.md: -------------------------------------------------------------------------------- 1 | # Scripts Directory 2 | 3 | This directory contains build and maintenance scripts for the Crystal application. 4 | 5 | ## generate-notices.js 6 | 7 | Generates a NOTICES file containing all third-party licenses for dependencies included in the Crystal distribution. 8 | 9 | ### Usage 10 | 11 | ```bash 12 | # Generate NOTICES file 13 | pnpm run generate-notices 14 | 15 | # Or run directly 16 | node scripts/generate-notices.js 17 | ``` 18 | 19 | ### How it works 20 | 21 | 1. Scans all node_modules directories in the workspace 22 | 2. Collects license information from LICENSE files and package.json 23 | 3. Excludes development-only dependencies that aren't distributed 24 | 4. Creates a NOTICES file in the project root 25 | 26 | ### When to run 27 | 28 | - Automatically runs during `pnpm run build:mac` and `pnpm run release:mac` 29 | - Should be run whenever dependencies change 30 | - CI/CD runs this in the license-compliance workflow 31 | 32 | ### License compliance 33 | 34 | The script helps ensure Crystal complies with open source license requirements by: 35 | - Including all third-party license texts in distributions 36 | - Identifying packages with missing license information 37 | - Supporting the license-compliance GitHub workflow -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | import { chromium } from '@playwright/test'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | import * as os from 'os'; 5 | 6 | export async function setupTestProject() { 7 | // Create a temporary test project directory 8 | const testProjectPath = path.join(os.tmpdir(), `crystal-test-${Date.now()}`); 9 | fs.mkdirSync(testProjectPath, { recursive: true }); 10 | 11 | // Initialize git in the test directory 12 | const { execSync } = require('child_process'); 13 | execSync('git init -b main', { cwd: testProjectPath, stdio: 'pipe' }); 14 | execSync('git config user.email "test@example.com"', { cwd: testProjectPath, stdio: 'pipe' }); 15 | execSync('git config user.name "Test User"', { cwd: testProjectPath, stdio: 'pipe' }); 16 | execSync('touch README.md', { cwd: testProjectPath }); 17 | execSync('git add .', { cwd: testProjectPath }); 18 | execSync('git commit -m "Initial commit"', { cwd: testProjectPath }); 19 | 20 | return testProjectPath; 21 | } 22 | 23 | export async function cleanupTestProject(projectPath: string) { 24 | try { 25 | fs.rmSync(projectPath, { recursive: true, force: true }); 26 | } catch (error) { 27 | console.error('Failed to cleanup test project:', error); 28 | } 29 | } -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App'; 4 | import { ThemeProvider } from './contexts/ThemeContext'; 5 | import { ErrorBoundary } from './components/ErrorBoundary'; 6 | import './index.css'; 7 | import './styles/markdown-preview.css'; 8 | 9 | // Global error handlers to catch errors that React error boundaries can't 10 | window.addEventListener('unhandledrejection', (event) => { 11 | console.error('Unhandled promise rejection:', event.reason); 12 | // Prevent default browser behavior (showing error in console) 13 | event.preventDefault(); 14 | 15 | // Show a user-friendly error message 16 | alert('An unexpected error occurred. The application may need to be restarted.\n\nError: ' + (event.reason?.message || String(event.reason))); 17 | }); 18 | 19 | window.addEventListener('error', (event) => { 20 | console.error('Uncaught error:', event.error); 21 | // Note: We don't prevent default here as the error boundary should catch React errors 22 | }); 23 | 24 | ReactDOM.createRoot(document.getElementById('root')!).render( 25 | 26 | 27 | 28 | 29 | 30 | 31 | , 32 | ); -------------------------------------------------------------------------------- /frontend/src/utils/sanitizer.ts: -------------------------------------------------------------------------------- 1 | import DOMPurify from 'dompurify'; 2 | 3 | // Configure DOMPurify for safe HTML output 4 | const config = { 5 | ALLOWED_TAGS: ['span', 'br', 'p', 'div', 'b', 'i', 'em', 'strong', 'code', 'pre'], 6 | ALLOWED_ATTR: ['class', 'style'], 7 | ALLOWED_STYLE_PROPS: ['color', 'background-color', 'font-weight'], 8 | KEEP_CONTENT: true, 9 | RETURN_DOM: false, 10 | RETURN_DOM_FRAGMENT: false, 11 | }; 12 | 13 | /** 14 | * Sanitize HTML content to prevent XSS attacks 15 | * @param dirty - The potentially unsafe HTML string 16 | * @returns The sanitized HTML string 17 | */ 18 | export function sanitizeHtml(dirty: string): string { 19 | return DOMPurify.sanitize(dirty, config); 20 | } 21 | 22 | /** 23 | * Sanitize and format git output for safe display 24 | * @param output - The raw git output 25 | * @returns The sanitized and formatted output 26 | */ 27 | export function sanitizeGitOutput(output: string): string { 28 | // First escape any HTML entities in the raw output 29 | const escaped = output 30 | .replace(/&/g, '&') 31 | .replace(//g, '>') 33 | .replace(/"/g, '"') 34 | .replace(/'/g, '''); 35 | 36 | // Then apply any formatting (this is now safe since we've escaped the content) 37 | return escaped; 38 | } -------------------------------------------------------------------------------- /frontend/src/stores/slashCommandStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | interface SlashCommandStore { 4 | slashCommands: Record; // panelId -> available commands 5 | setSlashCommands: (panelId: string, commands: string[]) => void; 6 | getSlashCommands: (panelId: string) => string[]; 7 | clearSlashCommands: (panelId: string) => void; 8 | } 9 | 10 | export const useSlashCommandStore = create((set, get) => ({ 11 | slashCommands: {}, 12 | 13 | setSlashCommands: (panelId: string, commands: string[]) => { 14 | console.log(`[slash-debug] Storing slash commands for panel ${panelId}:`, commands); 15 | set((state) => ({ 16 | slashCommands: { 17 | ...state.slashCommands, 18 | [panelId]: commands, 19 | }, 20 | })); 21 | }, 22 | 23 | getSlashCommands: (panelId: string) => { 24 | const commands = get().slashCommands[panelId] || []; 25 | console.log(`[slash-debug] Retrieved slash commands for panel ${panelId}:`, commands); 26 | return commands; 27 | }, 28 | 29 | clearSlashCommands: (panelId: string) => { 30 | console.log(`[slash-debug] Clearing slash commands for panel ${panelId}`); 31 | set((state) => { 32 | const { [panelId]: _, ...rest } = state.slashCommands; 33 | return { slashCommands: rest }; 34 | }); 35 | }, 36 | })); 37 | -------------------------------------------------------------------------------- /frontend/src/types/panelStore.ts: -------------------------------------------------------------------------------- 1 | import { ToolPanel, PanelEvent, PanelEventType } from '../../../shared/types/panels'; 2 | 3 | export interface PanelStore { 4 | // State (using plain objects instead of Maps for React reactivity) 5 | panels: Record; // sessionId -> panels 6 | activePanels: Record; // sessionId -> active panelId 7 | panelEvents: PanelEvent[]; // Recent events 8 | eventSubscriptions: Record>; // panelId -> subscribed events 9 | 10 | // Synchronous state update actions 11 | setPanels: (sessionId: string, panels: ToolPanel[]) => void; 12 | setActivePanel: (sessionId: string, panelId: string) => void; 13 | addPanel: (panel: ToolPanel) => void; 14 | removePanel: (sessionId: string, panelId: string) => void; 15 | updatePanelState: (panel: ToolPanel) => void; 16 | 17 | // Event actions 18 | subscribeToPanelEvents: (panelId: string, eventTypes: PanelEventType[]) => void; 19 | unsubscribeFromPanelEvents: (panelId: string, eventTypes: PanelEventType[]) => void; 20 | addPanelEvent: (event: PanelEvent) => void; 21 | 22 | // Getters 23 | getSessionPanels: (sessionId: string) => ToolPanel[]; 24 | getActivePanel: (sessionId: string) => ToolPanel | undefined; 25 | getPanelEvents: (panelId?: string, eventTypes?: PanelEventType[]) => PanelEvent[]; 26 | 27 | } -------------------------------------------------------------------------------- /frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import typescript from 'typescript-eslint'; 3 | import reactHooks from 'eslint-plugin-react-hooks'; 4 | import reactRefresh from 'eslint-plugin-react-refresh'; 5 | 6 | export default [ 7 | js.configs.recommended, 8 | ...typescript.configs.recommended, 9 | { 10 | files: ['src/**/*.{ts,tsx}'], 11 | languageOptions: { 12 | ecmaVersion: 2022, 13 | sourceType: 'module', 14 | parser: typescript.parser, 15 | parserOptions: { 16 | ecmaFeatures: { 17 | jsx: true 18 | } 19 | } 20 | }, 21 | plugins: { 22 | 'react-hooks': reactHooks, 23 | 'react-refresh': reactRefresh 24 | }, 25 | rules: { 26 | 'react-hooks/rules-of-hooks': 'error', 27 | 'react-hooks/exhaustive-deps': 'warn', 28 | 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], 29 | '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], 30 | '@typescript-eslint/no-explicit-any': 'error', 31 | '@typescript-eslint/explicit-module-boundary-types': 'off', 32 | 'no-console': ['warn', { allow: ['warn', 'error'] }], 33 | 'no-useless-escape': 'warn' // Downgrade to warning for now 34 | } 35 | }, 36 | { 37 | ignores: ['dist/', 'node_modules/', '*.config.js', '*.config.ts', 'vite.config.d.ts'] 38 | } 39 | ]; -------------------------------------------------------------------------------- /frontend/src/components/ui/EnhancedInput.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cn } from '../../utils/cn'; 3 | import { Input, InputProps } from './Input'; 4 | 5 | export interface EnhancedInputProps extends Omit { 6 | size?: 'sm' | 'md' | 'lg'; 7 | required?: boolean; 8 | showRequiredIndicator?: boolean; 9 | } 10 | 11 | export const EnhancedInput = React.forwardRef( 12 | ({ 13 | className, 14 | size = 'md', 15 | required = false, 16 | showRequiredIndicator = false, 17 | label, 18 | error, 19 | ...props 20 | }, ref) => { 21 | 22 | const sizeClasses = { 23 | sm: 'px-3 py-2 text-sm', 24 | md: 'px-4 py-3 text-base', 25 | lg: 'px-5 py-4 text-lg', 26 | }; 27 | 28 | // Show error for required fields that are empty 29 | const showRequiredError = required && showRequiredIndicator && !props.value; 30 | const actualError = error || (showRequiredError ? 'This field is required' : undefined); 31 | 32 | return ( 33 | 44 | ); 45 | } 46 | ); 47 | 48 | EnhancedInput.displayName = 'EnhancedInput'; -------------------------------------------------------------------------------- /frontend/src/components/panels/DashboardPanel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ProjectDashboard } from '../ProjectDashboard'; 3 | import { useSession } from '../../contexts/SessionContext'; 4 | 5 | interface DashboardPanelProps { 6 | panelId: string; 7 | sessionId: string; 8 | isActive: boolean; 9 | } 10 | 11 | const DashboardPanel: React.FC = () => { 12 | const sessionContext = useSession(); 13 | 14 | // Get project info from session context 15 | const projectIdStr = sessionContext?.projectId; 16 | const projectName = sessionContext?.projectName || 'Project'; 17 | 18 | if (!projectIdStr) { 19 | return ( 20 |
21 |
No project selected
22 |
23 | ); 24 | } 25 | 26 | const projectId = parseInt(projectIdStr, 10); 27 | if (isNaN(projectId)) { 28 | return ( 29 |
30 |
Invalid project ID
31 |
32 | ); 33 | } 34 | 35 | return ( 36 |
37 | 41 |
42 | ); 43 | }; 44 | 45 | export default DashboardPanel; -------------------------------------------------------------------------------- /main/src/ipc/prompt.ts: -------------------------------------------------------------------------------- 1 | import { IpcMain } from 'electron'; 2 | import type { AppServices } from './types'; 3 | 4 | export function registerPromptHandlers(ipcMain: IpcMain, { sessionManager }: AppServices): void { 5 | ipcMain.handle('sessions:get-prompts', async (_event, sessionId: string) => { 6 | try { 7 | const prompts = sessionManager.getSessionPrompts(sessionId); 8 | return { success: true, data: prompts }; 9 | } catch (error) { 10 | console.error('Failed to get session prompts:', error); 11 | return { success: false, error: 'Failed to get session prompts' }; 12 | } 13 | }); 14 | 15 | // Prompts handlers 16 | ipcMain.handle('prompts:get-all', async () => { 17 | try { 18 | const prompts = sessionManager.getPromptHistory(); 19 | return { success: true, data: prompts }; 20 | } catch (error) { 21 | console.error('Failed to get prompts:', error); 22 | return { success: false, error: 'Failed to get prompts' }; 23 | } 24 | }); 25 | 26 | ipcMain.handle('prompts:get-by-id', async (_event, promptId: string) => { 27 | try { 28 | const promptMarker = sessionManager.getPromptById(promptId); 29 | return { success: true, data: promptMarker }; 30 | } catch (error) { 31 | console.error('Failed to get prompt by id:', error); 32 | return { success: false, error: 'Failed to get prompt by id' }; 33 | } 34 | }); 35 | } -------------------------------------------------------------------------------- /frontend/src/components/ui/SettingsSection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cn } from '../../utils/cn'; 3 | 4 | interface SettingsSectionProps { 5 | title: string; 6 | description?: string; 7 | icon?: React.ReactNode; 8 | children: React.ReactNode; 9 | className?: string; 10 | spacing?: 'sm' | 'md' | 'lg'; 11 | } 12 | 13 | export function SettingsSection({ 14 | title, 15 | description, 16 | icon, 17 | children, 18 | className, 19 | spacing = 'md' 20 | }: SettingsSectionProps) { 21 | const spacingClasses = { 22 | sm: 'space-y-3', 23 | md: 'space-y-4', 24 | lg: 'space-y-6' 25 | }; 26 | 27 | return ( 28 |
29 |
30 | {icon && ( 31 |
32 | {icon} 33 |
34 | )} 35 |
36 |

37 | {title} 38 |

39 | {description && ( 40 |

41 | {description} 42 |

43 | )} 44 |
45 |
46 |
47 | {children} 48 |
49 |
50 | ); 51 | } -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | export default defineConfig({ 4 | testDir: './tests', 5 | // Maximum time one test can run for 6 | timeout: 60 * 1000, 7 | expect: { 8 | // Maximum time expect() should wait for the condition to be met 9 | timeout: 10000 10 | }, 11 | // Run tests in files in parallel 12 | fullyParallel: true, 13 | // Fail the build on CI if you accidentally left test.only in the source code 14 | forbidOnly: !!process.env.CI, 15 | // Retry on CI only 16 | retries: process.env.CI ? 2 : 0, 17 | // Opt out of parallel tests on CI 18 | workers: process.env.CI ? 1 : undefined, 19 | // Reporter to use 20 | reporter: 'list', 21 | 22 | use: { 23 | // Base URL to use in actions like `await page.goto('/')` 24 | baseURL: 'http://localhost:4521', 25 | // Collect trace when retrying the failed test 26 | trace: 'on-first-retry', 27 | // Take screenshot on failure 28 | screenshot: 'only-on-failure', 29 | }, 30 | 31 | // Configure projects for major browsers 32 | projects: [ 33 | { 34 | name: 'chromium', 35 | use: { ...devices['Desktop Chrome'] }, 36 | }, 37 | ], 38 | 39 | // Run your local dev server before starting the tests 40 | webServer: { 41 | command: 'pnpm electron-dev', 42 | port: 4521, 43 | reuseExistingServer: !process.env.CI, 44 | timeout: 120 * 1000, 45 | }, 46 | }); -------------------------------------------------------------------------------- /frontend/src/types/projectDashboard.ts: -------------------------------------------------------------------------------- 1 | export interface MainBranchStatus { 2 | status: 'up-to-date' | 'behind' | 'ahead' | 'diverged'; 3 | aheadCount?: number; 4 | behindCount?: number; 5 | lastFetched: string; 6 | } 7 | 8 | export interface RemoteStatus { 9 | name: string; 10 | url: string; 11 | branch: string; 12 | status: 'up-to-date' | 'behind' | 'ahead' | 'diverged'; 13 | aheadCount: number; 14 | behindCount: number; 15 | isUpstream?: boolean; 16 | isFork?: boolean; 17 | } 18 | 19 | export interface SessionBranchInfo { 20 | sessionId: string; 21 | sessionName: string; 22 | branchName: string; 23 | worktreePath: string; 24 | baseCommit: string; 25 | baseBranch: string; 26 | isStale: boolean; 27 | staleSince?: string; 28 | hasUncommittedChanges: boolean; 29 | pullRequest?: { 30 | number: number; 31 | title: string; 32 | state: 'open' | 'closed' | 'merged'; 33 | url: string; 34 | }; 35 | commitsAhead: number; 36 | commitsBehind: number; 37 | } 38 | 39 | export interface ProjectDashboardData { 40 | projectId: number; 41 | projectName: string; 42 | projectPath: string; 43 | mainBranch: string; 44 | mainBranchStatus?: MainBranchStatus; // Optional during progressive loading 45 | remotes?: RemoteStatus[]; 46 | sessionBranches: SessionBranchInfo[]; 47 | lastRefreshed: string; 48 | } 49 | 50 | export interface ProjectDashboardError { 51 | message: string; 52 | details?: string; 53 | } -------------------------------------------------------------------------------- /scripts/prepare-canary.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const { execSync } = require('child_process'); 6 | 7 | // Path to the main package.json 8 | const packageJsonPath = path.join(__dirname, '..', 'package.json'); 9 | 10 | // Read the package.json 11 | const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); 12 | 13 | // Get git commit information 14 | let gitCommit = 'unknown'; 15 | try { 16 | const gitHash = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim(); 17 | 18 | // Check if the working directory is clean (no uncommitted changes) 19 | try { 20 | execSync('git diff-index --quiet HEAD --', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }); 21 | gitCommit = gitHash; 22 | } catch { 23 | // Working directory has uncommitted changes 24 | gitCommit = `${gitHash}`; 25 | } 26 | } catch (err) { 27 | console.warn('Could not get git commit information:', err.message); 28 | gitCommit = Date.now().toString(36); // Fallback to timestamp-based ID 29 | } 30 | 31 | // Create canary version 32 | const canaryVersion = `${packageJson.version}-canary.${gitCommit}`; 33 | 34 | // Update the version in package.json 35 | packageJson.version = canaryVersion; 36 | 37 | // Write the updated package.json 38 | fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n'); 39 | 40 | console.log(`Updated version to canary build: ${canaryVersion}`); -------------------------------------------------------------------------------- /main/src/database/migrations/add_execution_diffs.sql: -------------------------------------------------------------------------------- 1 | -- Execution diffs table to store git diff data for each prompt execution 2 | CREATE TABLE IF NOT EXISTS execution_diffs ( 3 | id INTEGER PRIMARY KEY AUTOINCREMENT, 4 | session_id TEXT NOT NULL, 5 | prompt_marker_id INTEGER, -- Link to prompt_markers table 6 | execution_sequence INTEGER NOT NULL, -- Order of execution within session 7 | git_diff TEXT, -- The full git diff output 8 | files_changed TEXT, -- JSON array of changed file paths 9 | stats_additions INTEGER DEFAULT 0, -- Number of lines added 10 | stats_deletions INTEGER DEFAULT 0, -- Number of lines deleted 11 | stats_files_changed INTEGER DEFAULT 0, -- Number of files changed 12 | before_commit_hash TEXT, -- Git commit hash before changes 13 | after_commit_hash TEXT, -- Git commit hash after changes (if committed) 14 | timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, 15 | FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE, 16 | FOREIGN KEY (prompt_marker_id) REFERENCES prompt_markers(id) ON DELETE SET NULL 17 | ); 18 | 19 | -- Index for faster lookups 20 | CREATE INDEX IF NOT EXISTS idx_execution_diffs_session_id ON execution_diffs(session_id); 21 | CREATE INDEX IF NOT EXISTS idx_execution_diffs_prompt_marker_id ON execution_diffs(prompt_marker_id); 22 | CREATE INDEX IF NOT EXISTS idx_execution_diffs_timestamp ON execution_diffs(timestamp); 23 | CREATE INDEX IF NOT EXISTS idx_execution_diffs_sequence ON execution_diffs(session_id, execution_sequence); -------------------------------------------------------------------------------- /frontend/src/stores/configStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { API } from '../utils/api'; 3 | import type { AppConfig } from '../types/config'; 4 | 5 | interface ConfigStore { 6 | config: AppConfig | null; 7 | isLoading: boolean; 8 | error: string | null; 9 | fetchConfig: () => Promise; 10 | updateConfig: (updates: Partial) => Promise; 11 | } 12 | 13 | export const useConfigStore = create((set, get) => ({ 14 | config: null, 15 | isLoading: false, 16 | error: null, 17 | 18 | fetchConfig: async () => { 19 | set({ isLoading: true, error: null }); 20 | try { 21 | const response = await API.config.get(); 22 | if (response.success && response.data) { 23 | set({ config: response.data, isLoading: false }); 24 | } else { 25 | set({ error: response.error || 'Failed to fetch config', isLoading: false }); 26 | } 27 | } catch (error) { 28 | set({ error: 'Failed to fetch config', isLoading: false }); 29 | } 30 | }, 31 | 32 | updateConfig: async (updates: Partial) => { 33 | try { 34 | const response = await API.config.update(updates); 35 | if (response.success) { 36 | // Refetch to ensure we have the latest config 37 | await get().fetchConfig(); 38 | } else { 39 | set({ error: response.error || 'Failed to update config' }); 40 | } 41 | } catch (error) { 42 | set({ error: 'Failed to update config' }); 43 | } 44 | }, 45 | })); -------------------------------------------------------------------------------- /frontend/src/utils/console.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Performance-optimized console utilities 3 | * Reduces console.log calls in production builds 4 | */ 5 | 6 | const isDevelopment = process.env.NODE_ENV === 'development'; 7 | const isVerboseEnabled = () => { 8 | // Check if verbose logging is enabled in settings 9 | try { 10 | const verboseLogging = localStorage.getItem('crystal.verboseLogging'); 11 | return verboseLogging === 'true'; 12 | } catch { 13 | return false; 14 | } 15 | }; 16 | 17 | export const devLog = { 18 | log: (...args: unknown[]) => { 19 | if (isDevelopment || isVerboseEnabled()) { 20 | console.log(...args); 21 | } 22 | }, 23 | 24 | warn: (...args: unknown[]) => { 25 | if (isDevelopment || isVerboseEnabled()) { 26 | console.warn(...args); 27 | } 28 | }, 29 | 30 | error: (...args: unknown[]) => { 31 | // Always log errors 32 | console.error(...args); 33 | }, 34 | 35 | debug: (...args: unknown[]) => { 36 | if (isDevelopment && isVerboseEnabled()) { 37 | console.debug(...args); 38 | } 39 | }, 40 | 41 | info: (...args: unknown[]) => { 42 | if (isDevelopment || isVerboseEnabled()) { 43 | console.info(...args); 44 | } 45 | } 46 | }; 47 | 48 | /** 49 | * Performance-focused logging for component renders 50 | * Only logs in development with verbose enabled 51 | */ 52 | export const renderLog = (...args: unknown[]) => { 53 | if (isDevelopment && isVerboseEnabled()) { 54 | console.log(...args); 55 | } 56 | }; -------------------------------------------------------------------------------- /main/src/database/migrations/005_unified_panel_settings.sql: -------------------------------------------------------------------------------- 1 | -- Migration 005: Unified panel settings storage 2 | -- Store all panel-specific settings as JSON in tool_panels.settings column 3 | 4 | -- Step 1: Add settings column to tool_panels if it doesn't exist 5 | -- Note: This column will store all panel-specific settings as JSON 6 | ALTER TABLE tool_panels ADD COLUMN settings TEXT DEFAULT '{}'; 7 | 8 | -- Step 2: Migrate existing claude_panel_settings data to the new structure 9 | -- This will move data from the separate table into the JSON settings column 10 | UPDATE tool_panels 11 | SET settings = json_object( 12 | 'model', COALESCE((SELECT model FROM claude_panel_settings WHERE panel_id = tool_panels.id), 'auto'), 13 | 'commitMode', COALESCE((SELECT commit_mode FROM claude_panel_settings WHERE panel_id = tool_panels.id), 0), 14 | 'systemPrompt', (SELECT system_prompt FROM claude_panel_settings WHERE panel_id = tool_panels.id), 15 | 'maxTokens', COALESCE((SELECT max_tokens FROM claude_panel_settings WHERE panel_id = tool_panels.id), 4096), 16 | 'temperature', COALESCE((SELECT temperature FROM claude_panel_settings WHERE panel_id = tool_panels.id), 0.7) 17 | ) 18 | WHERE type = 'claude' AND EXISTS (SELECT 1 FROM claude_panel_settings WHERE panel_id = tool_panels.id); 19 | 20 | -- Step 3: Drop the claude_panel_settings table as it's no longer needed 21 | DROP TABLE IF EXISTS claude_panel_settings; 22 | 23 | -- Step 4: Create indexes for better performance 24 | CREATE INDEX IF NOT EXISTS idx_tool_panels_settings ON tool_panels(type, settings); -------------------------------------------------------------------------------- /shared/types.ts: -------------------------------------------------------------------------------- 1 | // Shared types between frontend and backend 2 | 3 | export type CommitMode = 'structured' | 'checkpoint' | 'disabled'; 4 | 5 | export interface CommitModeSettings { 6 | mode: CommitMode; 7 | structuredPromptTemplate?: string; 8 | checkpointPrefix?: string; 9 | allowClaudeTools?: boolean; 10 | } 11 | 12 | export interface ProjectCharacteristics { 13 | hasHusky: boolean; 14 | hasChangeset: boolean; 15 | hasConventionalCommits: boolean; 16 | suggestedMode: CommitMode; 17 | } 18 | 19 | export interface CommitResult { 20 | success: boolean; 21 | commitHash?: string; 22 | error?: string; 23 | } 24 | 25 | export interface FinalizeSessionOptions { 26 | squashCommits?: boolean; 27 | commitMessage?: string; 28 | runPostProcessing?: boolean; 29 | postProcessingCommands?: string[]; 30 | } 31 | 32 | // Default commit mode settings 33 | export const DEFAULT_COMMIT_MODE_SETTINGS: CommitModeSettings = { 34 | mode: 'checkpoint', 35 | checkpointPrefix: 'checkpoint: ', 36 | }; 37 | 38 | // Default structured prompt template 39 | export const DEFAULT_STRUCTURED_PROMPT_TEMPLATE = ` 40 | After completing the requested changes, please create a git commit with an appropriate message. Follow these guidelines: 41 | - Use Conventional Commits format (feat:, fix:, docs:, style:, refactor:, test:, chore:) 42 | - Include a clear, concise description of the changes 43 | - Only commit files that are directly related to this task 44 | - If this project uses changesets and you've made a user-facing change, you may run 'pnpm changeset' if appropriate 45 | `.trim(); -------------------------------------------------------------------------------- /frontend/src/types/project.ts: -------------------------------------------------------------------------------- 1 | export interface Project { 2 | id: number; 3 | name: string; 4 | path: string; 5 | system_prompt?: string | null; 6 | run_script?: string | null; 7 | build_script?: string | null; 8 | active: boolean; 9 | created_at: string; 10 | updated_at: string; 11 | open_ide_command?: string | null; 12 | displayOrder?: number; 13 | worktree_folder?: string | null; 14 | lastUsedModel?: string; 15 | commit_mode?: 'structured' | 'checkpoint' | 'disabled'; 16 | commit_structured_prompt_template?: string; 17 | commit_checkpoint_prefix?: string; 18 | } 19 | 20 | export interface ProjectRunCommand { 21 | id: number; 22 | project_id: number; 23 | command: string; 24 | display_name?: string; 25 | order_index: number; 26 | created_at: string; 27 | } 28 | 29 | export interface CreateProjectRequest { 30 | name: string; 31 | path: string; 32 | systemPrompt?: string; 33 | runScript?: string; 34 | buildScript?: string; 35 | openIdeCommand?: string; 36 | commitMode?: 'structured' | 'checkpoint' | 'disabled'; 37 | commitStructuredPromptTemplate?: string; 38 | commitCheckpointPrefix?: string; 39 | } 40 | 41 | export interface UpdateProjectRequest { 42 | name?: string; 43 | path?: string; 44 | system_prompt?: string | null; 45 | run_script?: string | null; 46 | build_script?: string | null; 47 | active?: boolean; 48 | open_ide_command?: string | null; 49 | worktree_folder?: string | null; 50 | lastUsedModel?: string; 51 | commit_mode?: 'structured' | 'checkpoint' | 'disabled'; 52 | commit_structured_prompt_template?: string; 53 | commit_checkpoint_prefix?: string; 54 | } -------------------------------------------------------------------------------- /main/src/polyfills/readablestream.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ReadableStream polyfill for Node.js environments 3 | * This ensures the Claude Code SDK has access to the ReadableStream API 4 | */ 5 | 6 | // Check if ReadableStream is already available 7 | if (typeof globalThis.ReadableStream === 'undefined') { 8 | try { 9 | // Try to import from Node.js built-in stream/web module (Node 16.5+) 10 | const { ReadableStream, WritableStream, TransformStream } = require('stream/web'); 11 | 12 | // Make them available globally 13 | globalThis.ReadableStream = ReadableStream; 14 | globalThis.WritableStream = WritableStream; 15 | globalThis.TransformStream = TransformStream; 16 | 17 | console.log('[Polyfill] Using Node.js built-in ReadableStream from stream/web'); 18 | } catch (error) { 19 | // If stream/web is not available, use the web-streams-polyfill package 20 | try { 21 | const streams = require('web-streams-polyfill/ponyfill'); 22 | 23 | globalThis.ReadableStream = streams.ReadableStream; 24 | globalThis.WritableStream = streams.WritableStream; 25 | globalThis.TransformStream = streams.TransformStream; 26 | 27 | console.log('[Polyfill] Using web-streams-polyfill for ReadableStream'); 28 | } catch (polyfillError) { 29 | console.error('[Polyfill] Failed to load ReadableStream polyfill:', polyfillError); 30 | console.error('[Polyfill] The Claude Code SDK may not function properly without ReadableStream support'); 31 | } 32 | } 33 | } else { 34 | console.log('[Polyfill] ReadableStream already available, skipping polyfill'); 35 | } 36 | 37 | // Export for TypeScript typing if needed 38 | export {}; -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | ## Type of Change 5 | 6 | - [ ] Bug fix (non-breaking change which fixes an issue) 7 | - [ ] New feature (non-breaking change which adds functionality) 8 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 9 | - [ ] Documentation update 10 | - [ ] Performance improvement 11 | - [ ] Code refactoring 12 | 13 | ## Checklist 14 | 15 | - [ ] I have read the [CONTRIBUTING.md](../CONTRIBUTING.md) guidelines 16 | - [ ] My code follows the code style of this project 17 | - [ ] I have performed a self-review of my own code 18 | - [ ] I have commented my code, particularly in hard-to-understand areas 19 | - [ ] I have made corresponding changes to the documentation 20 | - [ ] My changes generate no new warnings 21 | - [ ] I have added tests that prove my fix is effective or that my feature works 22 | - [ ] New and existing unit tests pass locally with my changes 23 | - [ ] I have run `pnpm typecheck` and `pnpm lint` locally 24 | - [ ] I have tested the Electron app locally with `pnpm electron-dev` 25 | 26 | ## Critical Areas Modified 27 | 28 | - [ ] Session output handling (requires explicit permission) 29 | - [ ] Timestamp handling 30 | - [ ] State management/IPC events 31 | - [ ] Diff viewer CSS 32 | 33 | ## Screenshots (if applicable) 34 | 35 | 36 | ## Additional Notes 37 | -------------------------------------------------------------------------------- /frontend/src/styles/monaco-overrides.css: -------------------------------------------------------------------------------- 1 | /* Monaco Editor CSS Variable Overrides */ 2 | 3 | /* Dark theme overrides */ 4 | :root.dark { 5 | --vscode-editor-background: #111827 !important; /* gray-900 */ 6 | --vscode-editor-foreground: #f3f4f6 !important; /* gray-100 */ 7 | --vscode-editorWidget-background: #111827 !important; 8 | --vscode-editorWidget-foreground: #f3f4f6 !important; 9 | --vscode-diffEditor-insertedTextBackground: #10b98120 !important; 10 | --vscode-diffEditor-removedTextBackground: #ef444420 !important; 11 | --vscode-diffEditor-insertedLineBackground: #10b98115 !important; 12 | --vscode-diffEditor-removedLineBackground: #ef444415 !important; 13 | } 14 | 15 | /* Light theme overrides */ 16 | :root:not(.dark) { 17 | --vscode-editor-background: #ffffff !important; /* white */ 18 | --vscode-editor-foreground: #1e2026 !important; /* gray-900 */ 19 | --vscode-editorWidget-background: #ffffff !important; 20 | --vscode-editorWidget-foreground: #1e2026 !important; 21 | --vscode-diffEditor-insertedTextBackground: #16a34a15 !important; 22 | --vscode-diffEditor-removedTextBackground: #dc262615 !important; 23 | --vscode-diffEditor-insertedLineBackground: #16a34a10 !important; 24 | --vscode-diffEditor-removedLineBackground: #dc262610 !important; 25 | } 26 | 27 | /* Additional Monaco editor style overrides */ 28 | .monaco-editor, 29 | .monaco-diff-editor, 30 | .monaco-editor-background, 31 | .monaco-editor .margin, 32 | .monaco-editor .monaco-editor-background { 33 | background-color: var(--vscode-editor-background) !important; 34 | } 35 | 36 | .monaco-editor .view-overlays, 37 | .monaco-editor .margin-view-overlays { 38 | background: transparent !important; 39 | } -------------------------------------------------------------------------------- /frontend/src/components/session/FolderArchiveDialog.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Modal, ModalHeader, ModalBody, ModalFooter } from '../ui/Modal'; 3 | import { Button } from '../ui/Button'; 4 | import { FolderArchive } from 'lucide-react'; 5 | 6 | interface FolderArchiveDialogProps { 7 | isOpen: boolean; 8 | sessionCount: number; 9 | onArchiveSessionOnly: () => void; 10 | onArchiveEntireFolder: () => void; 11 | onCancel: () => void; 12 | } 13 | 14 | export const FolderArchiveDialog: React.FC = ({ 15 | isOpen, 16 | sessionCount, 17 | onArchiveSessionOnly, 18 | onArchiveEntireFolder, 19 | onCancel, 20 | }) => { 21 | return ( 22 | 23 | 24 |
25 | 26 | Archive Folder? 27 |
28 |
29 | 30 | 31 |

32 | This session is in a folder with {sessionCount} session{sessionCount !== 1 ? 's' : ''}. 33 | Would you like to archive all sessions in the folder? 34 |

35 |
36 | 37 | 38 | 41 | 44 | 47 | 48 |
49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /frontend/src/types/config.ts: -------------------------------------------------------------------------------- 1 | export interface AppConfig { 2 | gitRepoPath: string; 3 | verbose?: boolean; 4 | anthropicApiKey?: string; 5 | systemPromptAppend?: string; 6 | runScript?: string[]; 7 | claudeExecutablePath?: string; 8 | defaultPermissionMode?: 'approve' | 'ignore'; 9 | autoCheckUpdates?: boolean; 10 | stravuApiKey?: string; 11 | stravuServerUrl?: string; 12 | theme?: 'light' | 'dark'; 13 | notifications?: { 14 | enabled: boolean; 15 | playSound: boolean; 16 | notifyOnStatusChange: boolean; 17 | notifyOnWaiting: boolean; 18 | notifyOnComplete: boolean; 19 | }; 20 | devMode?: boolean; 21 | sessionCreationPreferences?: { 22 | sessionCount?: number; 23 | toolType?: 'claude' | 'codex' | 'none'; 24 | selectedTools?: { 25 | claude?: boolean; 26 | codex?: boolean; 27 | }; 28 | claudeConfig?: { 29 | model?: 'auto' | 'sonnet' | 'opus' | 'haiku'; 30 | permissionMode?: 'ignore' | 'approve'; 31 | ultrathink?: boolean; 32 | }; 33 | codexConfig?: { 34 | model?: string; 35 | modelProvider?: string; 36 | approvalPolicy?: 'auto' | 'manual'; 37 | sandboxMode?: 'read-only' | 'workspace-write' | 'danger-full-access'; 38 | webSearch?: boolean; 39 | }; 40 | showAdvanced?: boolean; 41 | baseBranch?: string; 42 | commitModeSettings?: { 43 | mode?: 'checkpoint' | 'incremental' | 'single'; 44 | checkpointPrefix?: string; 45 | }; 46 | }; 47 | // Crystal commit footer setting (enabled by default) 48 | enableCrystalFooter?: boolean; 49 | // PostHog analytics settings 50 | analytics?: { 51 | enabled: boolean; 52 | posthogApiKey?: string; 53 | posthogHost?: string; 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /main/src/database/schema.sql: -------------------------------------------------------------------------------- 1 | -- Sessions table to store persistent session data 2 | CREATE TABLE IF NOT EXISTS sessions ( 3 | id TEXT PRIMARY KEY, 4 | name TEXT NOT NULL, 5 | initial_prompt TEXT NOT NULL, 6 | worktree_name TEXT NOT NULL, 7 | worktree_path TEXT NOT NULL, 8 | status TEXT NOT NULL DEFAULT 'pending', 9 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 10 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, 11 | last_output TEXT, 12 | exit_code INTEGER, 13 | pid INTEGER, 14 | claude_session_id TEXT 15 | ); 16 | 17 | -- Session outputs table to store terminal output history 18 | CREATE TABLE IF NOT EXISTS session_outputs ( 19 | id INTEGER PRIMARY KEY AUTOINCREMENT, 20 | session_id TEXT NOT NULL, 21 | type TEXT NOT NULL, 22 | data TEXT NOT NULL, 23 | timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, 24 | FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE 25 | ); 26 | 27 | -- Conversation messages table to track conversation history 28 | CREATE TABLE IF NOT EXISTS conversation_messages ( 29 | id INTEGER PRIMARY KEY AUTOINCREMENT, 30 | session_id TEXT NOT NULL, 31 | message_type TEXT NOT NULL CHECK (message_type IN ('user', 'assistant')), 32 | content TEXT NOT NULL, 33 | timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, 34 | FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE 35 | ); 36 | 37 | -- Index for faster lookups 38 | CREATE INDEX IF NOT EXISTS idx_session_outputs_session_id ON session_outputs(session_id); 39 | CREATE INDEX IF NOT EXISTS idx_session_outputs_timestamp ON session_outputs(timestamp); 40 | CREATE INDEX IF NOT EXISTS idx_conversation_messages_session_id ON conversation_messages(session_id); 41 | CREATE INDEX IF NOT EXISTS idx_conversation_messages_timestamp ON conversation_messages(timestamp); -------------------------------------------------------------------------------- /scripts/build-flatpak.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Script to build Flatpak package from AppImage 4 | # This should be run after the AppImage is built 5 | 6 | set -e 7 | 8 | echo "Building Flatpak package for Crystal..." 9 | 10 | # Check if flatpak-builder is installed 11 | if ! command -v flatpak-builder &> /dev/null; then 12 | echo "Error: flatpak-builder is not installed" 13 | echo "Please install it with: sudo apt install flatpak-builder" 14 | exit 1 15 | fi 16 | 17 | # Check if AppImage exists 18 | APPIMAGE=$(ls dist-electron/Crystal-*-x64.AppImage 2>/dev/null | head -n1) 19 | if [ -z "$APPIMAGE" ]; then 20 | echo "Error: No AppImage found in dist-electron/" 21 | echo "Please build the AppImage first with: pnpm run build:linux" 22 | exit 1 23 | fi 24 | 25 | echo "Found AppImage: $APPIMAGE" 26 | 27 | # Install required runtime and SDK if not present 28 | echo "Installing Flatpak runtime and SDK..." 29 | flatpak install -y flathub org.freedesktop.Platform//23.08 org.freedesktop.Sdk//23.08 org.electronjs.Electron2.BaseApp//23.08 || true 30 | 31 | # Update the manifest with the actual AppImage path 32 | sed -i "s|path: dist-electron/Crystal-\*.AppImage|path: $APPIMAGE|" com.stravu.crystal.yml 33 | 34 | # Build the Flatpak 35 | echo "Building Flatpak..." 36 | flatpak-builder --force-clean --repo=repo build-dir com.stravu.crystal.yml 37 | 38 | # Create a single-file bundle 39 | echo "Creating Flatpak bundle..." 40 | flatpak build-bundle repo crystal.flatpak com.stravu.crystal 41 | 42 | # Restore the manifest 43 | git checkout com.stravu.crystal.yml 44 | 45 | echo "Flatpak bundle created: crystal.flatpak" 46 | echo "" 47 | echo "To install locally:" 48 | echo " flatpak install crystal.flatpak" 49 | echo "" 50 | echo "To run:" 51 | echo " flatpak run com.stravu.crystal" -------------------------------------------------------------------------------- /frontend/src/components/ui/StatusDot.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cn } from '../../utils/cn'; 3 | 4 | interface StatusDotProps { 5 | status: 'running' | 'waiting' | 'success' | 'error' | 'info' | 'default'; 6 | size?: 'sm' | 'md' | 'lg'; 7 | animated?: boolean; 8 | pulse?: boolean; 9 | className?: string; 10 | title?: string; 11 | } 12 | 13 | const statusColors = { 14 | running: 'bg-status-success', 15 | waiting: 'bg-status-warning', 16 | success: 'bg-status-success', 17 | error: 'bg-status-error', 18 | info: 'bg-status-info', 19 | default: 'bg-status-neutral' 20 | }; 21 | 22 | const sizeClasses = { 23 | sm: { 24 | dot: 'w-2 h-2', 25 | container: 'w-3 h-3' 26 | }, 27 | md: { 28 | dot: 'w-3 h-3', 29 | container: 'w-4 h-4' 30 | }, 31 | lg: { 32 | dot: 'w-4 h-4', 33 | container: 'w-5 h-5' 34 | } 35 | }; 36 | 37 | export const StatusDot: React.FC = ({ 38 | status, 39 | size = 'md', 40 | animated = false, 41 | pulse = false, 42 | className, 43 | title 44 | }) => { 45 | const sizes = sizeClasses[size]; 46 | const color = statusColors[status]; 47 | 48 | return ( 49 |
53 |
62 | {pulse && ( 63 |
71 | )} 72 |
73 | ); 74 | }; -------------------------------------------------------------------------------- /main/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "main", 3 | "version": "0.3.4", 4 | "description": "Electron main process for Claude Code Commander", 5 | "main": "dist/main/src/index.js", 6 | "type": "commonjs", 7 | "scripts": { 8 | "dev": "tsc -w", 9 | "build": "rimraf dist && tsc && npm run copy:assets && npm run bundle:mcp", 10 | "bundle:mcp": "node build-mcp-bridge.js", 11 | "copy:assets": "mkdirp dist/main/src/database/migrations && cp src/database/*.sql dist/main/src/database/ && cp src/database/migrations/*.sql dist/main/src/database/migrations/", 12 | "lint": "eslint src --ext .ts", 13 | "typecheck": "tsc --noEmit", 14 | "test": "vitest", 15 | "test:watch": "vitest --watch", 16 | "test:coverage": "vitest --coverage" 17 | }, 18 | "dependencies": { 19 | "@anthropic-ai/claude-code": "^2.0.0", 20 | "@anthropic-ai/sdk": "^0.60.0", 21 | "@homebridge/node-pty-prebuilt-multiarch": "^0.12.0", 22 | "@modelcontextprotocol/sdk": "^1.12.1", 23 | "better-sqlite3": "^11.7.0", 24 | "bull": "^4.16.3", 25 | "dotenv": "^16.4.7", 26 | "electron-store": "^11.0.0", 27 | "electron-updater": "^6.6.8", 28 | "glob": "^11.0.0", 29 | "posthog-node": "^5.11.2", 30 | "web-streams-polyfill": "^3.3.3" 31 | }, 32 | "devDependencies": { 33 | "@electron/rebuild": "^4.0.1", 34 | "@types/better-sqlite3": "^7.6.13", 35 | "@types/bull": "^4.10.0", 36 | "@types/glob": "^9.0.0", 37 | "@types/node": "^22.10.2", 38 | "@typescript-eslint/eslint-plugin": "^8.19.0", 39 | "@typescript-eslint/parser": "^8.19.0", 40 | "@vitest/coverage-v8": "^2.1.8", 41 | "electron": "^37.6.0", 42 | "eslint": "^9.17.0", 43 | "mkdirp": "^3.0.1", 44 | "rimraf": "^6.0.1", 45 | "typescript": "^5.7.2", 46 | "vitest": "^2.1.8" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | .idea/ 3 | .vscode/ 4 | *.swp 5 | *.swo 6 | *~ 7 | 8 | # Dependencies 9 | node_modules/ 10 | .pnpm-store/ 11 | 12 | # Build outputs 13 | dist/ 14 | build/ 15 | !build/entitlements.mac.plist 16 | *.tsbuildinfo 17 | dist-electron/ 18 | main/dist/ 19 | frontend/dist/ 20 | backend/dist/ 21 | 22 | # Logs 23 | logs/ 24 | *.log 25 | npm-debug.log* 26 | pnpm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | 30 | # Runtime data 31 | pids/ 32 | *.pid 33 | *.seed 34 | *.pid.lock 35 | 36 | # Environment variables 37 | .env 38 | .env.local 39 | .env.development.local 40 | .env.test.local 41 | .env.production.local 42 | 43 | # OS 44 | .DS_Store 45 | Thumbs.db 46 | 47 | # Testing 48 | coverage/ 49 | .nyc_output/ 50 | 51 | # Git worktrees (except the main directory) 52 | /worktree-*/ 53 | /worktrees/ 54 | 55 | # Claude Code session data 56 | sessions/ 57 | .claude-sessions/ 58 | .claude-images/ 59 | 60 | # Electron 61 | *.dmg 62 | *.zip 63 | *.app 64 | *.exe 65 | *.deb 66 | *.AppImage 67 | *.snap 68 | *.pacman 69 | electron-builder.yml 70 | electron-builder.json5 71 | 72 | # Electron Builder directories 73 | packaged/ 74 | release/ 75 | 76 | # Local development 77 | .ccc/ 78 | *.sqlite 79 | *.sqlite3 80 | *.db-journal 81 | 82 | # Database files 83 | main/crystal.db 84 | main/*.db 85 | 86 | # Test results and reports 87 | test-results/ 88 | playwright-report/ 89 | test-report/ 90 | 91 | # Temporary test files 92 | test-file.txt 93 | *.test.tmp 94 | 95 | # Crystal user data directory (if accidentally placed in repo) 96 | .crystal/ 97 | 98 | # Generated TypeScript files 99 | frontend/vite.config.d.ts 100 | 101 | # Generated files 102 | # Shared build artifacts 103 | shared/*.js 104 | shared/*.js.map 105 | shared/*.d.ts 106 | shared/*.d.ts.map 107 | -------------------------------------------------------------------------------- /frontend/src/components/ui/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cn } from '../../utils/cn'; 3 | 4 | export interface IconButtonProps extends React.ButtonHTMLAttributes { 5 | variant?: 'primary' | 'secondary' | 'ghost' | 'danger'; 6 | size?: 'sm' | 'md' | 'lg'; 7 | icon: React.ReactNode; 8 | 'aria-label': string; 9 | } 10 | 11 | export const IconButton = React.forwardRef( 12 | ({ 13 | className, 14 | variant = 'ghost', 15 | size = 'md', 16 | icon, 17 | disabled, 18 | ...props 19 | }, ref) => { 20 | const baseStyles = 'inline-flex items-center justify-center font-medium transition-all duration-normal focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bg-primary disabled:cursor-not-allowed disabled:opacity-50 rounded'; 21 | 22 | const variants = { 23 | primary: 'bg-interactive text-white hover:bg-interactive-hover focus:ring-interactive shadow-button hover:shadow-button-hover', 24 | secondary: 'bg-surface-secondary text-text-secondary hover:bg-surface-hover focus:ring-border-primary', 25 | ghost: 'text-text-tertiary hover:text-text-secondary hover:bg-bg-hover focus:ring-border-primary', 26 | danger: 'bg-status-error text-white hover:bg-status-error-hover focus:ring-status-error', 27 | }; 28 | 29 | const sizes = { 30 | sm: 'h-8 w-8 text-sm', 31 | md: 'h-10 w-10 text-base', 32 | lg: 'h-12 w-12 text-lg', 33 | }; 34 | 35 | return ( 36 | 49 | ); 50 | } 51 | ); 52 | 53 | IconButton.displayName = 'IconButton'; -------------------------------------------------------------------------------- /shared/types/models.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Centralized model configurations for OpenAI Codex 3 | * These models became available after GPT-5's release on August 7, 2025 4 | */ 5 | 6 | export type OpenAICodexModel = 7 | | 'auto' 8 | | 'gpt-5' 9 | | 'gpt-5-codex'; 10 | 11 | export interface CodexModelConfig { 12 | id: OpenAICodexModel; 13 | label: string; 14 | description: string; 15 | } 16 | 17 | export const CODEX_MODELS: Record = { 18 | 'auto': { 19 | id: 'auto', 20 | label: 'Auto', 21 | description: 'Let Codex choose the best model automatically' 22 | }, 23 | 'gpt-5': { 24 | id: 'gpt-5', 25 | label: 'GPT-5', 26 | description: 'Standard GPT-5 model for general use' 27 | }, 28 | 'gpt-5-codex': { 29 | id: 'gpt-5-codex', 30 | label: 'GPT-5 Codex', 31 | description: 'GPT-5 optimized for coding tasks' 32 | } 33 | }; 34 | 35 | // Helper function to get model configuration 36 | export function getCodexModelConfig(model: string): CodexModelConfig | undefined { 37 | return CODEX_MODELS[model as OpenAICodexModel]; 38 | } 39 | 40 | // Helper to get the model list as an array 41 | export function getCodexModelList(): CodexModelConfig[] { 42 | return Object.values(CODEX_MODELS); 43 | } 44 | 45 | // Default model if none specified 46 | export const DEFAULT_CODEX_MODEL: OpenAICodexModel = 'gpt-5-codex'; 47 | 48 | // Codex input options interface 49 | export interface CodexInputOptions { 50 | model: OpenAICodexModel; 51 | modelProvider: 'openai'; 52 | sandboxMode: 'read-only' | 'workspace-write' | 'danger-full-access'; 53 | webSearch: boolean; 54 | attachedImages?: Array<{ 55 | id: string; 56 | name: string; 57 | dataUrl: string; 58 | size: number; 59 | type: string; 60 | }>; 61 | attachedTexts?: Array<{ 62 | id: string; 63 | name: string; 64 | content: string; 65 | size: number; 66 | }>; 67 | [key: string]: unknown; 68 | } -------------------------------------------------------------------------------- /main/src/ipc/dialog.ts: -------------------------------------------------------------------------------- 1 | import { IpcMain, dialog } from 'electron'; 2 | import type { AppServices } from './types'; 3 | 4 | export function registerDialogHandlers(ipcMain: IpcMain, { getMainWindow }: AppServices): void { 5 | ipcMain.handle('dialog:open-file', async (_event, options?: Electron.OpenDialogOptions) => { 6 | try { 7 | const mainWindow = getMainWindow(); 8 | if (!mainWindow) { 9 | return { success: false, error: 'No main window available' }; 10 | } 11 | 12 | const defaultOptions: Electron.OpenDialogOptions = { 13 | properties: ['openFile'], 14 | ...options 15 | }; 16 | 17 | const result = await dialog.showOpenDialog(mainWindow, defaultOptions); 18 | 19 | if (result.canceled) { 20 | return { success: true, data: null }; 21 | } 22 | 23 | return { success: true, data: result.filePaths[0] }; 24 | } catch (error) { 25 | console.error('Failed to open file dialog:', error); 26 | return { success: false, error: 'Failed to open file dialog' }; 27 | } 28 | }); 29 | 30 | ipcMain.handle('dialog:open-directory', async (_event, options?: Electron.OpenDialogOptions) => { 31 | try { 32 | const mainWindow = getMainWindow(); 33 | if (!mainWindow) { 34 | return { success: false, error: 'No main window available' }; 35 | } 36 | 37 | const defaultOptions: Electron.OpenDialogOptions = { 38 | properties: ['openDirectory'], 39 | ...options 40 | }; 41 | 42 | const result = await dialog.showOpenDialog(mainWindow, defaultOptions); 43 | 44 | if (result.canceled) { 45 | return { success: true, data: null }; 46 | } 47 | 48 | return { success: true, data: result.filePaths[0] }; 49 | } catch (error) { 50 | console.error('Failed to open directory dialog:', error); 51 | return { success: false, error: 'Failed to open directory dialog' }; 52 | } 53 | }); 54 | } -------------------------------------------------------------------------------- /frontend/src/types/diff.ts: -------------------------------------------------------------------------------- 1 | export interface ExecutionDiff { 2 | id: number; 3 | session_id: string; 4 | prompt_marker_id?: number; 5 | prompt_text?: string; 6 | execution_sequence: number; 7 | git_diff?: string; 8 | files_changed?: string[]; 9 | stats_additions: number; 10 | stats_deletions: number; 11 | stats_files_changed: number; 12 | before_commit_hash?: string; 13 | after_commit_hash?: string; 14 | commit_message?: string; 15 | timestamp: string; 16 | author?: string; 17 | comparison_branch?: string; 18 | history_source?: 'remote' | 'local' | 'branch'; 19 | history_limit_reached?: boolean; 20 | } 21 | 22 | export interface GitDiffStats { 23 | additions: number; 24 | deletions: number; 25 | filesChanged: number; 26 | } 27 | 28 | export interface GitDiffResult { 29 | diff: string; 30 | stats: GitDiffStats; 31 | changedFiles: string[]; 32 | beforeHash?: string; 33 | afterHash?: string; 34 | } 35 | 36 | export interface FileDiff { 37 | path: string; 38 | oldPath: string; 39 | oldValue: string; 40 | newValue: string; 41 | type: 'added' | 'deleted' | 'modified' | 'renamed'; 42 | isBinary: boolean; 43 | additions: number; 44 | deletions: number; 45 | } 46 | 47 | export interface DiffViewerProps { 48 | diff: string; 49 | className?: string; 50 | sessionId?: string; 51 | onFileSave?: (filePath: string) => void; 52 | isAllCommitsSelected?: boolean; 53 | mainBranch?: string; 54 | } 55 | 56 | export interface ExecutionListProps { 57 | sessionId: string; 58 | executions: ExecutionDiff[]; 59 | selectedExecutions: number[]; 60 | onSelectionChange: (selectedIds: number[]) => void; 61 | onCommit?: () => void; 62 | onRevert?: (commitHash: string) => void; 63 | onRestore?: () => void; 64 | historyLimitReached?: boolean; 65 | historyLimit?: number; 66 | } 67 | 68 | export interface CombinedDiffViewProps { 69 | sessionId: string; 70 | selectedExecutions: number[]; 71 | isGitOperationRunning?: boolean; 72 | isMainRepo?: boolean; 73 | isVisible?: boolean; 74 | } 75 | -------------------------------------------------------------------------------- /playwright.ci.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | export default defineConfig({ 4 | testDir: './tests', 5 | // Reduce timeout for CI 6 | timeout: 30 * 1000, 7 | expect: { 8 | // Reduce expect timeout for faster failures 9 | timeout: 5000 10 | }, 11 | // Run tests in files in parallel 12 | fullyParallel: true, 13 | // Fail the build on CI if you accidentally left test.only in the source code 14 | forbidOnly: true, 15 | // Reduce retries to save time 16 | retries: 1, 17 | // Use more workers on CI for parallel execution 18 | workers: 2, 19 | // Reporter optimized for CI 20 | reporter: [ 21 | ['list'], 22 | ['github'], 23 | ['html', { open: 'never' }] 24 | ], 25 | 26 | use: { 27 | // Base URL to use in actions like await page.goto('/') 28 | baseURL: 'http://localhost:4521', 29 | // Collect trace only on failure to save time 30 | trace: 'retain-on-failure', 31 | // Take screenshot on failure 32 | screenshot: 'only-on-failure', 33 | // Reduce viewport for faster rendering 34 | viewport: { width: 1280, height: 720 }, 35 | // Disable animations for faster tests 36 | launchOptions: { 37 | args: ['--disable-gpu', '--disable-dev-shm-usage', '--disable-setuid-sandbox', '--no-sandbox'], 38 | }, 39 | }, 40 | 41 | // Configure projects for major browsers 42 | projects: [ 43 | { 44 | name: 'chromium', 45 | use: { 46 | ...devices['Desktop Chrome'], 47 | // Disable GPU for CI 48 | launchOptions: { 49 | args: ['--disable-gpu', '--disable-dev-shm-usage', '--disable-setuid-sandbox', '--no-sandbox'], 50 | }, 51 | }, 52 | }, 53 | ], 54 | 55 | // Run your local dev server before starting the tests 56 | webServer: { 57 | command: 'pnpm electron-dev', 58 | port: 4521, 59 | reuseExistingServer: false, 60 | timeout: 60 * 1000, // Reduce from 120s to 60s 61 | env: { 62 | DISPLAY: ':99', 63 | ELECTRON_DISABLE_SANDBOX: '1', 64 | }, 65 | }, 66 | }); -------------------------------------------------------------------------------- /docs/troubleshooting/DIFF_VIEWER_CSS.md: -------------------------------------------------------------------------------- 1 | # Diff Viewer CSS Troubleshooting 2 | 3 | ⚠️ **IMPORTANT**: The diff viewer (react-diff-viewer-continued) has specific CSS requirements that can be tricky to debug. 4 | 5 | ## Common Issue: No Scrollbars on Diff Viewer 6 | 7 | If the diff viewer content is cut off and scrollbars don't appear: 8 | 9 | 1. **DO NOT add complex CSS overrides** - This often makes the problem worse 10 | 2. **Check parent containers for `overflow-hidden`** - This is usually the root cause 11 | 3. **Use simple `overflow: 'auto'`** on the immediate diff container 12 | 4. **Remove any forced widths or min-widths** unless absolutely necessary 13 | 14 | ## The Solution That Works 15 | 16 | ```tsx 17 | // In DiffViewer.tsx - Keep it simple! 18 |
19 | 27 |
28 | ``` 29 | 30 | ## What NOT to Do 31 | 32 | - Don't add multiple wrapper divs with conflicting overflow settings 33 | - Don't use CSS-in-JS to override react-diff-viewer's internal styles 34 | - Don't add global CSS selectors targeting generated class names 35 | - Don't use JavaScript hacks to force reflows 36 | 37 | ## Root Cause 38 | 39 | The issue is typically caused by parent containers having `overflow-hidden` which prevents child scrollbars from appearing. Check these files: 40 | 41 | - `SessionView.tsx` - Look for `overflow-hidden` classes 42 | - `CombinedDiffView.tsx` - Check both the main container and flex containers 43 | - `App.tsx` - Sometimes the issue starts at the app root level 44 | 45 | The react-diff-viewer-continued library uses emotion/styled-components internally, which makes CSS overrides unreliable. The best approach is to ensure proper overflow handling in parent containers and keep the diff viewer wrapper simple. 46 | -------------------------------------------------------------------------------- /frontend/src/components/ui/Textarea.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react'; 2 | import { cn } from '../../utils/cn'; 3 | 4 | interface TextareaProps extends React.TextareaHTMLAttributes { 5 | error?: string | null; 6 | label?: string; 7 | description?: string; 8 | helperText?: string; 9 | fullWidth?: boolean; 10 | } 11 | 12 | export const Textarea = forwardRef( 13 | ({ className, error, label, description, helperText, fullWidth, id, ...props }, ref) => { 14 | return ( 15 |
16 | {label && ( 17 | 23 | )} 24 | 25 |