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 |
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 |
13 | Crystal is a desktop application for managing multiple Claude Code instances against a single directory using git worktrees.
14 | It provides a streamlined interface for running parallel Claude Code sessions with different approaches to the same problem.
15 |
16 |
Key features:
17 |
18 |
Run multiple Claude Code instances simultaneously
19 |
Isolated development with git worktrees
20 |
Session persistence and conversation history
21 |
Real-time terminal output with syntax highlighting