Repository: winfunc/opcode Branch: main Commit: 70c16d8a4910 Files: 179 Total size: 1.8 MB Directory structure: gitextract_3l4qo4o3/ ├── .cargo/ │ └── config.toml ├── .github/ │ └── workflows/ │ ├── build-linux.yml │ ├── build-macos.yml │ ├── build-test.yml │ ├── claude-code-review.yml │ ├── claude.yml │ ├── pr-check.yml │ └── release.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── bun.lockb ├── cc_agents/ │ ├── README.md │ ├── git-commit-bot.opcode.json │ ├── security-scanner.opcode.json │ └── unit-tests-bot.opcode.json ├── index.html ├── justfile ├── package.json ├── scripts/ │ └── bump-version.sh ├── shell.nix ├── src/ │ ├── App.tsx │ ├── assets/ │ │ ├── nfo/ │ │ │ └── opcode-nfo.ogg │ │ └── shimmer.css │ ├── components/ │ │ ├── AgentExecution.tsx │ │ ├── AgentExecutionDemo.tsx │ │ ├── AgentRunOutputViewer.tsx │ │ ├── AgentRunView.tsx │ │ ├── AgentRunsList.tsx │ │ ├── Agents.tsx │ │ ├── AgentsModal.tsx │ │ ├── AnalyticsConsent.tsx │ │ ├── AnalyticsErrorBoundary.tsx │ │ ├── App.cleaned.tsx │ │ ├── CCAgents.tsx │ │ ├── CheckpointSettings.tsx │ │ ├── ClaudeBinaryDialog.tsx │ │ ├── ClaudeCodeSession.refactored.tsx │ │ ├── ClaudeCodeSession.tsx │ │ ├── ClaudeFileEditor.tsx │ │ ├── ClaudeMemoriesDropdown.tsx │ │ ├── ClaudeVersionSelector.tsx │ │ ├── CreateAgent.tsx │ │ ├── CustomTitlebar.tsx │ │ ├── ErrorBoundary.tsx │ │ ├── ExecutionControlBar.tsx │ │ ├── FilePicker.optimized.tsx │ │ ├── FilePicker.tsx │ │ ├── FloatingPromptInput.tsx │ │ ├── GitHubAgentBrowser.tsx │ │ ├── HooksEditor.tsx │ │ ├── IconPicker.tsx │ │ ├── ImagePreview.tsx │ │ ├── MCPAddServer.tsx │ │ ├── MCPImportExport.tsx │ │ ├── MCPManager.tsx │ │ ├── MCPServerList.tsx │ │ ├── MarkdownEditor.tsx │ │ ├── NFOCredits.tsx │ │ ├── PreviewPromptDialog.tsx │ │ ├── ProjectList.tsx │ │ ├── ProjectSettings.tsx │ │ ├── ProxySettings.tsx │ │ ├── RunningClaudeSessions.tsx │ │ ├── SessionList.optimized.tsx │ │ ├── SessionList.tsx │ │ ├── SessionOutputViewer.tsx │ │ ├── Settings.tsx │ │ ├── SlashCommandPicker.tsx │ │ ├── SlashCommandsManager.tsx │ │ ├── StartupIntro.tsx │ │ ├── StorageTab.tsx │ │ ├── StreamMessage.tsx │ │ ├── TabContent.tsx │ │ ├── TabManager.tsx │ │ ├── TimelineNavigator.tsx │ │ ├── TokenCounter.tsx │ │ ├── ToolWidgets.new.tsx │ │ ├── ToolWidgets.tsx │ │ ├── Topbar.tsx │ │ ├── UsageDashboard.original.tsx │ │ ├── UsageDashboard.tsx │ │ ├── WebviewPreview.tsx │ │ ├── claude-code-session/ │ │ │ ├── MessageList.tsx │ │ │ ├── PromptQueue.tsx │ │ │ ├── SessionHeader.tsx │ │ │ ├── useCheckpoints.ts │ │ │ └── useClaudeMessages.ts │ │ ├── index.ts │ │ ├── ui/ │ │ │ ├── badge.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── dialog.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── pagination.tsx │ │ │ ├── popover.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── split-pane.tsx │ │ │ ├── switch.tsx │ │ │ ├── tabs.tsx │ │ │ ├── textarea.tsx │ │ │ ├── toast.tsx │ │ │ ├── tooltip-modern.tsx │ │ │ └── tooltip.tsx │ │ └── widgets/ │ │ ├── BashWidget.tsx │ │ ├── LSWidget.tsx │ │ ├── TodoWidget.tsx │ │ └── index.ts │ ├── contexts/ │ │ ├── TabContext.tsx │ │ └── ThemeContext.tsx │ ├── hooks/ │ │ ├── index.ts │ │ ├── useAnalytics.ts │ │ ├── useApiCall.ts │ │ ├── useDebounce.ts │ │ ├── useLoadingState.ts │ │ ├── usePagination.ts │ │ ├── usePerformanceMonitor.ts │ │ ├── useTabState.ts │ │ └── useTheme.ts │ ├── lib/ │ │ ├── analytics/ │ │ │ ├── consent.ts │ │ │ ├── events.ts │ │ │ ├── index.ts │ │ │ ├── resourceMonitor.ts │ │ │ └── types.ts │ │ ├── api-tracker.ts │ │ ├── api.ts │ │ ├── apiAdapter.ts │ │ ├── claudeSyntaxTheme.ts │ │ ├── date-utils.ts │ │ ├── hooksManager.ts │ │ ├── linkDetector.tsx │ │ ├── outputCache.tsx │ │ └── utils.ts │ ├── main.tsx │ ├── services/ │ │ ├── sessionPersistence.ts │ │ └── tabPersistence.ts │ ├── stores/ │ │ ├── README.md │ │ ├── agentStore.ts │ │ └── sessionStore.ts │ ├── styles.css │ ├── types/ │ │ └── hooks.ts │ └── vite-env.d.ts ├── src-tauri/ │ ├── .gitignore │ ├── Cargo.toml │ ├── Info.plist │ ├── build.rs │ ├── capabilities/ │ │ └── default.json │ ├── entitlements.plist │ ├── icons/ │ │ └── icon.icns │ ├── src/ │ │ ├── checkpoint/ │ │ │ ├── manager.rs │ │ │ ├── mod.rs │ │ │ ├── state.rs │ │ │ └── storage.rs │ │ ├── claude_binary.rs │ │ ├── commands/ │ │ │ ├── agents.rs │ │ │ ├── claude.rs │ │ │ ├── mcp.rs │ │ │ ├── mod.rs │ │ │ ├── proxy.rs │ │ │ ├── slash_commands.rs │ │ │ ├── storage.rs │ │ │ └── usage.rs │ │ ├── lib.rs │ │ ├── main.rs │ │ ├── process/ │ │ │ ├── mod.rs │ │ │ └── registry.rs │ │ ├── web_main.rs │ │ └── web_server.rs │ ├── tauri.conf.json │ └── tests/ │ └── TESTS_COMPLETE.md ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── web_server.design.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: .cargo/config.toml ================================================ [target.aarch64-unknown-linux-gnu] linker = "aarch64-linux-gnu-gcc" [env] PKG_CONFIG_ALLOW_CROSS = "1" ================================================ FILE: .github/workflows/build-linux.yml ================================================ name: Build Linux on: workflow_call: workflow_dispatch: push: branches: [main] jobs: build: name: Build Linux x86_64 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install system dependencies run: | sudo apt-get update sudo apt-get install -y \ pkg-config \ libwebkit2gtk-4.1-dev \ libgtk-3-dev \ libssl-dev \ libayatana-appindicator3-dev \ librsvg2-dev - name: Setup Rust uses: dtolnay/rust-toolchain@stable with: targets: x86_64-unknown-linux-gnu - name: Setup Rust cache uses: Swatinem/rust-cache@v2 with: workspaces: src-tauri - name: Setup Bun uses: oven-sh/setup-bun@v2 - name: Install dependencies run: bun install - name: Build Tauri app run: bun run tauri build --target x86_64-unknown-linux-gnu - name: Create artifacts directory run: | mkdir -p dist/linux-x86_64 cp src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb dist/linux-x86_64/ || true cp src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage dist/linux-x86_64/ || true # Generate checksums cd dist/linux-x86_64 sha256sum * > checksums.txt - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: linux-x86_64 path: dist/linux-x86_64/* ================================================ FILE: .github/workflows/build-macos.yml ================================================ name: Build macOS on: workflow_call: secrets: APPLE_CERTIFICATE: required: true APPLE_CERTIFICATE_PASSWORD: required: true KEYCHAIN_PASSWORD: required: true APPLE_SIGNING_IDENTITY: required: true APPLE_ID: required: true APPLE_TEAM_ID: required: true APPLE_PASSWORD: required: true workflow_dispatch: inputs: skip_build: description: 'Skip build and use artifacts from a previous run' required: false default: false type: boolean run_id: description: 'Run ID to download artifacts from (leave empty for latest)' required: false type: string push: branches: [main] jobs: build: name: Build macOS ${{ matrix.target }} if: ${{ !inputs.skip_build }} runs-on: ${{ matrix.os }} strategy: matrix: include: - os: macos-13 # Intel target: x86_64-apple-darwin arch: x86_64 - os: macos-14 # Apple Silicon target: aarch64-apple-darwin arch: aarch64 steps: - uses: actions/checkout@v4 - name: Setup Rust uses: dtolnay/rust-toolchain@stable - name: Setup Rust cache uses: Swatinem/rust-cache@v2 with: workspaces: src-tauri - name: Setup Bun uses: oven-sh/setup-bun@v2 - name: Install dependencies run: bun install - name: Import Apple certificates env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} run: | # Create variables CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db # Import certificate from secrets echo -n "$APPLE_CERTIFICATE" | base64 --decode -o $CERTIFICATE_PATH # Create temporary keychain security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security set-keychain-settings -lut 21600 $KEYCHAIN_PATH security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH # Import certificate to keychain security import $CERTIFICATE_PATH -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security list-keychain -d user -s $KEYCHAIN_PATH - name: Build native env: CI: true run: bun run tauri build - name: Upload architecture-specific artifacts uses: actions/upload-artifact@v4 with: name: macos-${{ matrix.arch }} path: | src-tauri/target/release/bundle/macos/opcode.app src-tauri/target/release/bundle/dmg/*.dmg retention-days: 1 universal: name: Create Universal Binary needs: [build] if: ${{ !cancelled() && (needs.build.result == 'success' || needs.build.result == 'skipped') }} runs-on: macos-latest steps: - uses: actions/checkout@v4 - name: Download artifacts from current workflow if: ${{ !inputs.skip_build }} uses: actions/download-artifact@v4 with: pattern: macos-* path: artifacts - name: Download artifacts from specific run if: ${{ inputs.skip_build && inputs.run_id != '' }} uses: dawidd6/action-download-artifact@v3 with: workflow: build-macos.yml run_id: ${{ inputs.run_id }} name: macos-* path: artifacts - name: Download artifacts from latest run if: ${{ inputs.skip_build && inputs.run_id == '' }} uses: dawidd6/action-download-artifact@v3 with: workflow: build-macos.yml workflow_conclusion: success name: macos-* path: artifacts - name: List downloaded artifacts run: | echo "📁 Artifact structure:" find artifacts -type f -name "*.app" -o -name "*.dmg" | head -20 echo "" echo "📁 Full directory structure:" ls -la artifacts/ ls -la artifacts/macos-aarch64/ || echo "macos-aarch64 directory not found" ls -la artifacts/macos-x86_64/ || echo "macos-x86_64 directory not found" - name: Import Apple certificates env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} run: | # Create variables CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db # Import certificate from secrets echo -n "$APPLE_CERTIFICATE" | base64 --decode -o $CERTIFICATE_PATH # Create temporary keychain security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security set-keychain-settings -lut 21600 $KEYCHAIN_PATH security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH # Import certificate to keychain security import $CERTIFICATE_PATH -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security list-keychain -d user -s $KEYCHAIN_PATH - name: Create universal app run: | # Create temp directory mkdir -p dmg_temp # Extract zip files if they exist if [ -f "artifacts/macos-aarch64.zip" ]; then echo "📦 Extracting macos-aarch64.zip..." unzip -q artifacts/macos-aarch64.zip -d artifacts/macos-aarch64/ fi if [ -f "artifacts/macos-x86_64.zip" ]; then echo "📦 Extracting macos-x86_64.zip..." unzip -q artifacts/macos-x86_64.zip -d artifacts/macos-x86_64/ fi # Find the actual app paths AARCH64_APP=$(find artifacts/macos-aarch64 -name "opcode.app" -type d | head -1) X86_64_APP=$(find artifacts/macos-x86_64 -name "opcode.app" -type d | head -1) if [ -z "$AARCH64_APP" ] || [ -z "$X86_64_APP" ]; then echo "❌ Could not find app bundles" echo "AARCH64_APP: $AARCH64_APP" echo "X86_64_APP: $X86_64_APP" exit 1 fi echo "✅ Found app bundles:" echo " ARM64: $AARCH64_APP" echo " x86_64: $X86_64_APP" # Copy ARM64 app as base cp -R "$AARCH64_APP" dmg_temp/ # Create universal binary using lipo lipo -create -output dmg_temp/opcode.app/Contents/MacOS/opcode \ "$AARCH64_APP/Contents/MacOS/opcode" \ "$X86_64_APP/Contents/MacOS/opcode" # Ensure executable permissions are set chmod +x dmg_temp/opcode.app/Contents/MacOS/opcode echo "✅ Universal binary created" lipo -info dmg_temp/opcode.app/Contents/MacOS/opcode - name: Sign app bundle env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} run: | codesign --sign "$APPLE_SIGNING_IDENTITY" \ --timestamp \ --options runtime \ --force \ --deep \ --entitlements src-tauri/entitlements.plist \ dmg_temp/opcode.app - name: Create DMG run: | hdiutil create -volname "opcode Installer" \ -srcfolder dmg_temp \ -ov -format UDZO opcode.dmg - name: Sign DMG env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} run: | codesign --sign "$APPLE_SIGNING_IDENTITY" \ --timestamp \ --force opcode.dmg - name: Notarize DMG env: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} run: | # Store notarization credentials xcrun notarytool store-credentials "notarytool-profile" \ --apple-id "$APPLE_ID" \ --team-id "$APPLE_TEAM_ID" \ --password "$APPLE_PASSWORD" # Submit for notarization xcrun notarytool submit opcode.dmg \ --keychain-profile "notarytool-profile" \ --wait - name: Staple notarization run: xcrun stapler staple opcode.dmg - name: Verify DMG run: | spctl -a -t open -vvv --context context:primary-signature opcode.dmg echo "✅ DMG verification complete" - name: Create artifacts directory run: | mkdir -p dist/macos-universal cp opcode.dmg dist/macos-universal/ # Also save the app bundle using ditto to preserve permissions and signatures ditto -c -k --sequesterRsrc --keepParent \ dmg_temp/opcode.app dist/macos-universal/opcode.app.zip # Generate checksum shasum -a 256 dist/macos-universal/* > dist/macos-universal/checksums.txt - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: macos-universal path: dist/macos-universal/* - name: Cleanup if: always() run: | echo "🧹 Cleaning up temporary directories..." rm -rf dmg_temp temp_x86 artifacts # Clean up keychain if [ -n "$RUNNER_TEMP" ] && [ -f "$RUNNER_TEMP/app-signing.keychain-db" ]; then security delete-keychain "$RUNNER_TEMP/app-signing.keychain-db" || true fi echo "✅ Cleanup complete" ================================================ FILE: .github/workflows/build-test.yml ================================================ name: Build Test # Trigger on every push and pull request on: push: branches: [ main, develop, 'release/**', 'feature/**' ] pull_request: branches: [ main, develop ] types: [opened, synchronize, reopened] # Cancel in-progress workflows when a new commit is pushed concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 jobs: build-test: name: Build Test (${{ matrix.platform.name }}) strategy: fail-fast: false matrix: platform: - name: Linux os: ubuntu-latest rust-target: x86_64-unknown-linux-gnu - name: Linux ARM64 os: ubuntu-24.04-arm64 rust-target: aarch64-unknown-linux-gnu - name: Windows os: windows-latest rust-target: x86_64-pc-windows-msvc - name: macOS os: macos-latest rust-target: x86_64-apple-darwin runs-on: ${{ matrix.platform.os }} steps: # Checkout the repository - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 # Install system dependencies for Linux - name: Install Linux dependencies if: matrix.platform.os == 'ubuntu-latest' run: | sudo apt-get update sudo apt-get install -y \ libwebkit2gtk-4.1-dev \ libgtk-3-dev \ libayatana-appindicator3-dev \ librsvg2-dev \ libssl-dev \ libglib2.0-dev \ libjavascriptcoregtk-4.1-dev \ libsoup-3.0-dev \ libxdo-dev \ libxcb-shape0-dev \ libxcb-xfixes0-dev # Setup Rust with caching - name: Setup Rust uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.platform.rust-target }} # Cache Rust dependencies - name: Cache Rust dependencies uses: Swatinem/rust-cache@v2 with: workspaces: './src-tauri -> target' key: ${{ matrix.platform.os }}-rust-${{ hashFiles('**/Cargo.lock') }} # Setup Bun - name: Setup Bun uses: oven-sh/setup-bun@v2 with: bun-version: latest # Cache Bun dependencies - name: Cache Bun dependencies uses: actions/cache@v4 with: path: | ~/.bun node_modules key: ${{ matrix.platform.os }}-bun-${{ hashFiles('bun.lockb', 'package.json') }} restore-keys: | ${{ matrix.platform.os }}-bun- # Install frontend dependencies - name: Install frontend dependencies run: bun install --frozen-lockfile # Build frontend - name: Build frontend run: bun run build # Build Tauri application - name: Build Tauri application run: bun run tauri build --no-bundle -d env: TAURI_SIGNING_PRIVATE_KEY: "" TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "" # Upload build artifacts for debugging (optional) - name: Upload build logs on failure if: failure() uses: actions/upload-artifact@v4 with: name: build-logs-${{ matrix.platform.name }} path: | src-tauri/target/release/build/*/output src-tauri/target/debug/build/*/output retention-days: 3 # Summary job to ensure all builds pass build-test-summary: name: Build Test Summary runs-on: ubuntu-latest needs: [build-test] if: always() steps: - name: Check build results run: | if [[ "${{ needs.build-test.result }}" == "failure" ]]; then echo "❌ One or more build tests failed" exit 1 elif [[ "${{ needs.build-test.result }}" == "cancelled" ]]; then echo "⚠️ Build tests were cancelled" exit 1 else echo "✅ All build tests passed successfully" fi - name: Create status comment (PR only) if: github.event_name == 'pull_request' uses: actions/github-script@v7 with: script: | const result = '${{ needs.build-test.result }}'; const emoji = result === 'success' ? '✅' : '❌'; const status = result === 'success' ? 'All build tests passed!' : 'Build tests failed'; // Create a comment summarizing the build status const comment = `## ${emoji} Build Test Results **Status**: ${status} **Commit**: ${{ github.event.pull_request.head.sha || github.sha }} | Platform | Status | |----------|--------| | Linux | ${{ contains(needs.build-test.result, 'success') && '✅' || '❌' }} | | Windows | ${{ contains(needs.build-test.result, 'success') && '✅' || '❌' }} | | macOS | ${{ contains(needs.build-test.result, 'success') && '✅' || '❌' }} | [View full workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})`; // Only post comment if it's a PR if (context.eventName === 'pull_request') { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body: comment }); } ================================================ FILE: .github/workflows/claude-code-review.yml ================================================ name: Claude Code Review on: pull_request: types: [opened, synchronize] # Optional: Only run on specific file changes # paths: # - "src/**/*.ts" # - "src/**/*.tsx" # - "src/**/*.js" # - "src/**/*.jsx" jobs: claude-review: # Optional: Filter by PR author # if: | # github.event.pull_request.user.login == 'external-contributor' || # github.event.pull_request.user.login == 'new-developer' || # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' runs-on: ubuntu-latest permissions: contents: read pull-requests: read issues: read id-token: write steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 1 - name: Run Claude Code Review id: claude-review uses: anthropics/claude-code-action@v1 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} prompt: | REPO: ${{ github.repository }} PR NUMBER: ${{ github.event.pull_request.number }} Please review this pull request and provide feedback on: - Code quality and best practices - Potential bugs or issues - Performance considerations - Security concerns - Test coverage Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://docs.claude.com/en/docs/claude-code/cli-reference for available options claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' ================================================ FILE: .github/workflows/claude.yml ================================================ name: Claude Code on: issue_comment: types: [created] pull_request_review_comment: types: [created] issues: types: [opened, assigned] pull_request_review: types: [submitted] jobs: claude: if: | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) runs-on: ubuntu-latest permissions: contents: read pull-requests: read issues: read id-token: write actions: read # Required for Claude to read CI results on PRs steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 1 - name: Run Claude Code id: claude uses: anthropics/claude-code-action@v1 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # This is an optional setting that allows Claude to read CI results on PRs additional_permissions: | actions: read # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. # prompt: 'Update the pull request description to include a summary of changes.' # Optional: Add claude_args to customize behavior and configuration # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://docs.claude.com/en/docs/claude-code/cli-reference for available options # claude_args: '--allowed-tools Bash(gh pr:*)' ================================================ FILE: .github/workflows/pr-check.yml ================================================ name: PR Checks (bun run check) on: pull_request: types: [opened, synchronize, reopened, ready_for_review] permissions: contents: read pull-requests: read concurrency: group: pr-check-${{ github.workflow }}-${{ github.event.pull_request.head.sha || github.sha }} cancel-in-progress: true jobs: check: name: bun run check runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Bun uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Cache Bun dependencies uses: actions/cache@v4 with: path: | ~/.bun/install/cache node_modules key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} restore-keys: | ${{ runner.os }}-bun- - name: Install JS/TS dependencies run: bun install --frozen-lockfile - name: Set up Rust (stable) uses: dtolnay/rust-toolchain@stable - name: Cache Rust dependencies uses: Swatinem/rust-cache@v2 with: workspaces: | src-tauri -> src-tauri/target cache-on-failure: true - name: Run checks run: bun run check ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: tags: - 'v*' workflow_dispatch: inputs: version: description: 'Version to release (e.g., v1.0.0)' required: true type: string permissions: contents: write jobs: # Build jobs for each platform build-linux: uses: ./.github/workflows/build-linux.yml secrets: inherit build-macos: uses: ./.github/workflows/build-macos.yml secrets: inherit # Create release after all builds complete create-release: name: Create Release needs: [build-linux, build-macos] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Determine version id: version run: | if [ "${{ github.event_name }}" = "push" ]; then VERSION="${GITHUB_REF#refs/tags/}" else VERSION="${{ inputs.version }}" fi echo "version=$VERSION" >> $GITHUB_OUTPUT echo "Version: $VERSION" - name: Download all artifacts uses: actions/download-artifact@v4 with: path: artifacts - name: Prepare release assets run: | mkdir -p release-assets # Linux artifacts if [ -d "artifacts/linux-x86_64" ]; then cp artifacts/linux-x86_64/*.deb release-assets/opcode_${{ steps.version.outputs.version }}_linux_x86_64.deb || true cp artifacts/linux-x86_64/*.AppImage release-assets/opcode_${{ steps.version.outputs.version }}_linux_x86_64.AppImage || true fi # macOS artifacts if [ -d "artifacts/macos-universal" ]; then cp artifacts/macos-universal/opcode.dmg release-assets/opcode_${{ steps.version.outputs.version }}_macos_universal.dmg || true cp artifacts/macos-universal/opcode.app.zip release-assets/opcode_${{ steps.version.outputs.version }}_macos_universal.app.tar.gz || true fi # Create source code archives # Clean version without 'v' prefix for archive names CLEAN_VERSION="${{ steps.version.outputs.version }}" CLEAN_VERSION="${CLEAN_VERSION#v}" # Create source code archives (excluding .git and other unnecessary files) echo "Creating source code archives..." # Create a clean export of the repository git archive --format=tar.gz --prefix=opcode-${CLEAN_VERSION}/ -o release-assets/opcode-${CLEAN_VERSION}.tar.gz HEAD git archive --format=zip --prefix=opcode-${CLEAN_VERSION}/ -o release-assets/opcode-${CLEAN_VERSION}.zip HEAD # Generate signatures for all files cd release-assets for file in *; do if [ -f "$file" ]; then sha256sum "$file" > "$file.sha256" fi done cd .. - name: Create Release uses: softprops/action-gh-release@v1 with: tag_name: ${{ steps.version.outputs.version }} name: opcode ${{ steps.version.outputs.version }} draft: true prerelease: false generate_release_notes: true files: release-assets/* body: |
opcode Logo
## opcode ${{ steps.version.outputs.version }} This release was built and signed by CI. Artifacts for macOS and Linux are attached below. - Auto-generated release notes are included below (commits, PRs, and contributors). - Checksums (`.sha256`) are provided for all assets. ### Downloads - macOS: `.dmg`, `.app.tar.gz` (Universal: Apple Silicon + Intel) - Linux: `.AppImage`, `.deb` ### Installation - macOS: Open the `.dmg` and drag opcode to Applications. - Linux: `chmod +x` the `.AppImage` and run it, or install the `.deb` on Debian/Ubuntu. ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local *.bun-build # Tauri binaries (built executables) src-tauri/binaries/ # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? temp_lib/ .cursor/ AGENTS.md CLAUDE.md *_TASK.md # Claude project-specific files .claude/ .env ================================================ FILE: CONTRIBUTING.md ================================================ # Welcome Contributors We welcome contributions to enhance opcode's capabilities and improve its performance. To report bugs, create a [GitHub issue](https://github.com/getAsterisk/opcode/issues). > Before contributing, read through the existing issues and pull requests to see if someone else is already working on something similar. That way you can avoid duplicating efforts. To contribute, please follow these steps: 1. Fork the opcode repository on GitHub. 2. Create a new branch for your feature or bug fix. 3. Make your changes and ensure that the code passes all tests. 4. Submit a pull request describing your changes and their benefits. ## Pull Request Guidelines When submitting a pull request, please follow these guidelines: 1. **Title**: Please include following prefixes: - `Feature:` for new features - `Fix:` for bug fixes - `Docs:` for documentation changes - `Refactor:` for code refactoring - `Improve:` for performance improvements - `Other:` for other changes For example: - `Feature: added custom agent timeout configuration` - `Fix: resolved session list scrolling issue` 2. **Description**: Provide a clear and detailed description of your changes in the pull request. Explain the problem you are solving, the approach you took, and any potential side effects or limitations of your changes. 3. **Documentation**: Update the relevant documentation to reflect your changes. This includes the README file, code comments, and any other relevant documentation. 4. **Dependencies**: If your changes require new dependencies, ensure that they are properly documented and added to the `package.json` or `Cargo.toml` files. 5. If the pull request does not meet the above guidelines, it may be closed without merging. **Note**: Please ensure that you have the latest version of the code before creating a pull request. If you have an existing fork, just sync your fork with the latest version of the opcode repository. ## Coding Standards ### Frontend (React/TypeScript) - Use TypeScript for all new code - Follow functional components with hooks - Use Tailwind CSS for styling - Add JSDoc comments for exported functions and components ### Backend (Rust) - Follow Rust standard conventions - Use `cargo fmt` for formatting - Use `cargo clippy` for linting - Handle all `Result` types explicitly - Add comprehensive documentation with `///` comments ### Security Requirements - Validate all inputs from the frontend - Use prepared statements for database operations - Never log sensitive data (tokens, passwords, etc.) - Use secure defaults for all configurations ## Testing - Add tests for new functionality - Ensure all existing tests pass - Run `cargo test` for Rust code - Test the application manually before submitting Please adhere to the coding conventions, maintain clear documentation, and provide thorough testing for your contributions. ================================================ FILE: LICENSE ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================ FILE: README.md ================================================
opcode Logo

opcode

A powerful GUI app and Toolkit for Claude Code

Create custom agents, manage interactive Claude Code sessions, run secure background agents, and more.

Features Installation Usage Development Discord

![457013521-6133a738-d0cb-4d3e-8746-c6768c82672c](https://github.com/user-attachments/assets/a028de9e-d881-44d8-bae5-7326ab3558b9) https://github.com/user-attachments/assets/6bceea0f-60b6-4c3e-a745-b891de00b8d0 > [!TIP] > **⭐ Star the repo and follow [@getAsterisk](https://x.com/getAsterisk) on X for early access to `asteria-swe-v0`**. > [!NOTE] > This project is not affiliated with, endorsed by, or sponsored by Anthropic. Claude is a trademark of Anthropic, PBC. This is an independent developer project using Claude. ## 🌟 Overview **opcode** is a powerful desktop application that transforms how you interact with Claude Code. Built with Tauri 2, it provides a beautiful GUI for managing your Claude Code sessions, creating custom agents, tracking usage, and much more. Think of opcode as your command center for Claude Code - bridging the gap between the command-line tool and a visual experience that makes AI-assisted development more intuitive and productive. ## 📋 Table of Contents - [🌟 Overview](#-overview) - [✨ Features](#-features) - [🗂️ Project & Session Management](#️-project--session-management) - [🤖 CC Agents](#-cc-agents) - [📊 Usage Analytics Dashboard](#-usage-analytics-dashboard) - [🔌 MCP Server Management](#-mcp-server-management) - [⏰ Timeline & Checkpoints](#-timeline--checkpoints) - [📝 CLAUDE.md Management](#-claudemd-management) - [📖 Usage](#-usage) - [Getting Started](#getting-started) - [Managing Projects](#managing-projects) - [Creating Agents](#creating-agents) - [Tracking Usage](#tracking-usage) - [Working with MCP Servers](#working-with-mcp-servers) - [🚀 Installation](#-installation) - [🔨 Build from Source](#-build-from-source) - [🛠️ Development](#️-development) - [🔒 Security](#-security) - [🤝 Contributing](#-contributing) - [📄 License](#-license) - [🙏 Acknowledgments](#-acknowledgments) ## ✨ Features ### 🗂️ **Project & Session Management** - **Visual Project Browser**: Navigate through all your Claude Code projects in `~/.claude/projects/` - **Session History**: View and resume past coding sessions with full context - **Smart Search**: Find projects and sessions quickly with built-in search - **Session Insights**: See first messages, timestamps, and session metadata at a glance ### 🤖 **CC Agents** - **Custom AI Agents**: Create specialized agents with custom system prompts and behaviors - **Agent Library**: Build a collection of purpose-built agents for different tasks - **Background Execution**: Run agents in separate processes for non-blocking operations - **Execution History**: Track all agent runs with detailed logs and performance metrics ### 📊 **Usage Analytics Dashboard** - **Cost Tracking**: Monitor your Claude API usage and costs in real-time - **Token Analytics**: Detailed breakdown by model, project, and time period - **Visual Charts**: Beautiful charts showing usage trends and patterns - **Export Data**: Export usage data for accounting and analysis ### 🔌 **MCP Server Management** - **Server Registry**: Manage Model Context Protocol servers from a central UI - **Easy Configuration**: Add servers via UI or import from existing configs - **Connection Testing**: Verify server connectivity before use - **Claude Desktop Import**: Import server configurations from Claude Desktop ### ⏰ **Timeline & Checkpoints** - **Session Versioning**: Create checkpoints at any point in your coding session - **Visual Timeline**: Navigate through your session history with a branching timeline - **Instant Restore**: Jump back to any checkpoint with one click - **Fork Sessions**: Create new branches from existing checkpoints - **Diff Viewer**: See exactly what changed between checkpoints ### 📝 **CLAUDE.md Management** - **Built-in Editor**: Edit CLAUDE.md files directly within the app - **Live Preview**: See your markdown rendered in real-time - **Project Scanner**: Find all CLAUDE.md files in your projects - **Syntax Highlighting**: Full markdown support with syntax highlighting ## 📖 Usage ### Getting Started 1. **Launch opcode**: Open the application after installation 2. **Welcome Screen**: Choose between CC Agents or Projects 3. **First Time Setup**: opcode will automatically detect your `~/.claude` directory ### Managing Projects ``` Projects → Select Project → View Sessions → Resume or Start New ``` - Click on any project to view its sessions - Each session shows the first message and timestamp - Resume sessions directly or start new ones ### Creating Agents ``` CC Agents → Create Agent → Configure → Execute ``` 1. **Design Your Agent**: Set name, icon, and system prompt 2. **Configure Model**: Choose between available Claude models 3. **Set Permissions**: Configure file read/write and network access 4. **Execute Tasks**: Run your agent on any project ### Tracking Usage ``` Menu → Usage Dashboard → View Analytics ``` - Monitor costs by model, project, and date - Export data for reports - Set up usage alerts (coming soon) ### Working with MCP Servers ``` Menu → MCP Manager → Add Server → Configure ``` - Add servers manually or via JSON - Import from Claude Desktop configuration - Test connections before using ## 🚀 Installation ### Prerequisites - **Claude Code CLI**: Install from [Claude's official site](https://claude.ai/code) ### Release Executables Will Be Published Soon ## 🔨 Build from Source ### Prerequisites Before building opcode from source, ensure you have the following installed: #### System Requirements - **Operating System**: Windows 10/11, macOS 11+, or Linux (Ubuntu 20.04+) - **RAM**: Minimum 4GB (8GB recommended) - **Storage**: At least 1GB free space #### Required Tools 1. **Rust** (1.70.0 or later) ```bash # Install via rustup curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh ``` 2. **Bun** (latest version) ```bash # Install bun curl -fsSL https://bun.sh/install | bash ``` 3. **Git** ```bash # Usually pre-installed, but if not: # Ubuntu/Debian: sudo apt install git # macOS: brew install git # Windows: Download from https://git-scm.com ``` 4. **Claude Code CLI** - Download and install from [Claude's official site](https://claude.ai/code) - Ensure `claude` is available in your PATH #### Platform-Specific Dependencies **Linux (Ubuntu/Debian)** ```bash # Install system dependencies sudo apt update sudo apt install -y \ libwebkit2gtk-4.1-dev \ libgtk-3-dev \ libayatana-appindicator3-dev \ librsvg2-dev \ patchelf \ build-essential \ curl \ wget \ file \ libssl-dev \ libxdo-dev \ libsoup-3.0-dev \ libjavascriptcoregtk-4.1-dev ``` **macOS** ```bash # Install Xcode Command Line Tools xcode-select --install # Install additional dependencies via Homebrew (optional) brew install pkg-config ``` **Windows** - Install [Microsoft C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) - Install [WebView2](https://developer.microsoft.com/microsoft-edge/webview2/) (usually pre-installed on Windows 11) ### Build Steps 1. **Clone the Repository** ```bash git clone https://github.com/getAsterisk/opcode.git cd opcode ``` 2. **Install Frontend Dependencies** ```bash bun install ``` 3. **Build the Application** **For Development (with hot reload)** ```bash bun run tauri dev ``` **For Production Build** ```bash # Build the application bun run tauri build # The built executable will be in: # - Linux: src-tauri/target/release/ # - macOS: src-tauri/target/release/ # - Windows: src-tauri/target/release/ ``` 4. **Platform-Specific Build Options** **Debug Build (faster compilation, larger binary)** ```bash bun run tauri build --debug ``` **Universal Binary for macOS (Intel + Apple Silicon)** ```bash bun run tauri build --target universal-apple-darwin ``` ### Troubleshooting #### Common Issues 1. **"cargo not found" error** - Ensure Rust is installed and `~/.cargo/bin` is in your PATH - Run `source ~/.cargo/env` or restart your terminal 2. **Linux: "webkit2gtk not found" error** - Install the webkit2gtk development packages listed above - On newer Ubuntu versions, you might need `libwebkit2gtk-4.0-dev` 3. **Windows: "MSVC not found" error** - Install Visual Studio Build Tools with C++ support - Restart your terminal after installation 4. **"claude command not found" error** - Ensure Claude Code CLI is installed and in your PATH - Test with `claude --version` 5. **Build fails with "out of memory"** - Try building with fewer parallel jobs: `cargo build -j 2` - Close other applications to free up RAM #### Verify Your Build After building, you can verify the application works: ```bash # Run the built executable directly # Linux/macOS ./src-tauri/target/release/opcode # Windows ./src-tauri/target/release/opcode.exe ``` ### Build Artifacts The build process creates several artifacts: - **Executable**: The main opcode application - **Installers** (when using `tauri build`): - `.deb` package (Linux) - `.AppImage` (Linux) - `.dmg` installer (macOS) - `.msi` installer (Windows) - `.exe` installer (Windows) All artifacts are located in `src-tauri/target/release/`. ## 🛠️ Development ### Tech Stack - **Frontend**: React 18 + TypeScript + Vite 6 - **Backend**: Rust with Tauri 2 - **UI Framework**: Tailwind CSS v4 + shadcn/ui - **Database**: SQLite (via rusqlite) - **Package Manager**: Bun ### Project Structure ``` opcode/ ├── src/ # React frontend │ ├── components/ # UI components │ ├── lib/ # API client & utilities │ └── assets/ # Static assets ├── src-tauri/ # Rust backend │ ├── src/ │ │ ├── commands/ # Tauri command handlers │ │ ├── checkpoint/ # Timeline management │ │ └── process/ # Process management │ └── tests/ # Rust test suite └── public/ # Public assets ``` ### Development Commands ```bash # Start development server bun run tauri dev # Run frontend only bun run dev # Type checking bunx tsc --noEmit # Run Rust tests cd src-tauri && cargo test # Format code cd src-tauri && cargo fmt ``` ## 🔒 Security opcode prioritizes your privacy and security: 1. **Process Isolation**: Agents run in separate processes 2. **Permission Control**: Configure file and network access per agent 3. **Local Storage**: All data stays on your machine 4. **No Telemetry**: No data collection or tracking 5. **Open Source**: Full transparency through open source code ## 🤝 Contributing We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details. ### Areas for Contribution - 🐛 Bug fixes and improvements - ✨ New features and enhancements - 📚 Documentation improvements - 🎨 UI/UX enhancements - 🧪 Test coverage - 🌐 Internationalization ## 📄 License This project is licensed under the AGPL License - see the [LICENSE](LICENSE) file for details. ## 🙏 Acknowledgments - Built with [Tauri](https://tauri.app/) - The secure framework for building desktop apps - [Claude](https://claude.ai) by Anthropic ---

Made with ❤️ by the Asterisk

Report Bug · Request Feature

## Star History [![Star History Chart](https://api.star-history.com/svg?repos=getAsterisk/opcode&type=Date)](https://www.star-history.com/#getAsterisk/opcode&Date) ================================================ FILE: cc_agents/README.md ================================================ # 🤖 opcode CC Agents

Pre-built AI agents for opcode powered by Claude Code

Browse AgentsImport GuideExport GuideContribute

--- ## 📦 Available Agents | Agent | Model | Description | Default Task | |-------|-------|-------------|--------------| | **🎯 Git Commit Bot**
🤖 `bot` | Sonnet | **Automate your Git workflow with intelligent commit messages**

Analyzes Git repository changes, generates detailed commit messages following Conventional Commits specification, and pushes changes to remote repository. | "Push all changes." | | **🛡️ Security Scanner**
🛡️ `shield` | Opus | **Advanced AI-powered Static Application Security Testing (SAST)**

Performs comprehensive security audits by spawning specialized sub-agents for: codebase intelligence gathering, threat modeling (STRIDE), vulnerability scanning (OWASP Top 10, CWE), exploit validation, remediation design, and professional report generation. | "Review the codebase for security issues." | | **🧪 Unit Tests Bot**
💻 `code` | Opus | **Automated comprehensive unit test generation for any codebase**

Analyzes codebase and generates comprehensive unit tests by: analyzing code structure, creating test plans, writing tests matching your style, verifying execution, optimizing coverage (>80% overall, 100% critical paths), and generating documentation. | "Generate unit tests for this codebase." | ### Available Icons Choose from these icon options when creating agents: - `bot` - 🤖 General purpose - `shield` - 🛡️ Security related - `code` - 💻 Development - `terminal` - 🖥️ System/CLI - `database` - 🗄️ Data operations - `globe` - 🌐 Network/Web - `file-text` - 📄 Documentation - `git-branch` - 🌿 Version control --- ## 📥 Importing Agents ### Method 1: Import from GitHub (Recommended) 1. In opcode, navigate to **CC Agents** 2. Click the **Import** dropdown button 3. Select **From GitHub** 4. Browse available agents from the official repository 5. Preview agent details and click **Import Agent** ### Method 2: Import from Local File 1. Download a `.opcode.json` file from this repository 2. In opcode, navigate to **CC Agents** 3. Click the **Import** dropdown button 4. Select **From File** 5. Choose the downloaded `.opcode.json` file ## 📤 Exporting Agents ### Export Your Custom Agents 1. In opcode, navigate to **CC Agents** 2. Find your agent in the grid 3. Click the **Export** button 4. Choose where to save the `.opcode.json` file ### Agent File Format All agents are stored in `.opcode.json` format with the following structure: ```json { "version": 1, "exported_at": "2025-01-23T14:29:58.156063+00:00", "agent": { "name": "Your Agent Name", "icon": "bot", "model": "opus|sonnet|haiku", "system_prompt": "Your agent's instructions...", "default_task": "Default task description" } } ``` ## 🔧 Technical Implementation ### How Import/Export Works The agent import/export system is built on a robust architecture: #### Backend (Rust/Tauri) - **Storage**: SQLite database stores agent configurations - **Export**: Serializes agent data to JSON with version control - **Import**: Validates and deduplicates agents on import - **GitHub Integration**: Fetches agents via GitHub API #### Frontend (React/TypeScript) - **UI Components**: - `CCAgents.tsx` - Main agent management interface - `GitHubAgentBrowser.tsx` - GitHub repository browser - `CreateAgent.tsx` - Agent creation/editing form - **File Operations**: Native file dialogs for import/export - **Real-time Updates**: Live agent status and execution monitoring ### Key Features 1. **Version Control**: Each agent export includes version metadata 2. **Duplicate Prevention**: Automatic naming conflict resolution 3. **Model Selection**: Choose between Opus, Sonnet, and Haiku models 4. **GitHub Integration**: Direct import from the official repository ## 🤝 Contributing We welcome agent contributions! Here's how to add your agent: ### 1. Create Your Agent Design and test your agent in opcode with a clear, focused purpose. ### 2. Export Your Agent Export your agent to a `.opcode.json` file with a descriptive name. ### 3. Submit a Pull Request 1. Fork this repository 2. Add your `.opcode.json` file to the `cc_agents` directory 3. Update this README with your agent's details 4. Submit a PR with a description of what your agent does ### Agent Guidelines - **Single Purpose**: Each agent should excel at one specific task - **Clear Documentation**: Write comprehensive system prompts - **Model Choice**: Use Haiku for simple tasks, Sonnet for general purpose, Opus for complex reasoning - **Naming**: Use descriptive names that clearly indicate the agent's function ## 📜 License These agents are provided under the same license as the opcode project. See the main LICENSE file for details. ---
Built with ❤️ by the opcode community
================================================ FILE: cc_agents/git-commit-bot.opcode.json ================================================ { "agent": { "default_task": "Push all changes.", "icon": "bot", "model": "sonnet", "name": "Git Commit Bot", "system_prompt": "\nYou are a Git Commit Push bot. Your task is to analyze changes in a git repository, write a detailed commit message following the Conventional Commits specification, and push the changes to git.\n\n\n# Instructions\n\n\nFirst, check if there are commits in the remote repository that have not been synced locally:\n1. Run `git fetch` to update remote tracking branches\n2. Check if the local branch is behind the remote using `git status` or `git log`\n3. If there are unsynced commits from the remote:\n - Perform a `git pull` to merge remote changes\n - If merge conflicts occur:\n a. Carefully analyze the conflicting changes\n b. Resolve conflicts by keeping the appropriate changes from both versions\n c. Mark conflicts as resolved using `git add`\n d. Complete the merge\n4. Only proceed with the following steps after ensuring the local repository is up-to-date\n\nAnalyze the changes shown in the git diff and status outputs. Pay attention to:\n1. Which files were modified, added, or deleted\n2. The nature of the changes (e.g., bug fixes, new features, refactoring)\n3. The scope of the changes (which part of the project was affected)\n\nBased on your analysis, write a commit message following the Conventional Commits specification:\n1. Use one of the following types: feat, fix, docs, style, refactor, perf, test, or chore\n2. Include a scope in parentheses if applicable\n3. Write a concise description in the present tense\n4. If necessary, add a longer description after a blank line\n5. Include any breaking changes or issues closed\n\nThen finally push the changes to git.\n\n\n# Notes\n\n\n- Replace [branch_name] with the appropriate branch name based on the information in the git log. If you cannot determine the branch name, use \"main\" as the default.\n- Remember to think carefully about the changes and their impact on the project when crafting your commit message. Your goal is to provide a clear and informative record of the changes made to the repository.\n- When resolving merge conflicts, prioritize maintaining functionality and avoiding breaking changes. If unsure about a conflict resolution, prefer a conservative approach that preserves existing behavior.\n" }, "exported_at": "2025-06-23T14:29:58.156063+00:00", "version": 1 } ================================================ FILE: cc_agents/security-scanner.opcode.json ================================================ { "agent": { "default_task": "Review the codebase for security issues.", "icon": "shield", "model": "opus", "name": "Security Scanner", "system_prompt": "# AI SAST Agent - System Prompt\n\n\nYou are an advanced AI-powered Static Application Security Testing (SAST) agent specialized in performing deep, comprehensive security audits of codebases. You identify vulnerabilities with high precision, analyze attack vectors, and produce professional security reports following industry standards. You operate by orchestrating specialized sub-agents for each phase of the security assessment.\n\n\n\n1. Perform thorough static analysis to identify security vulnerabilities\n2. Minimize false positives through contextual analysis and validation\n3. Provide actionable remediation guidance with code examples\n4. Generate professional security reports suitable for development and security teams\n5. Prioritize findings based on exploitability and business impact\n\n\n\nApply a systematic approach combining:\n- **OWASP Top 10** vulnerability patterns\n- **CWE (Common Weakness Enumeration)** classification\n- **STRIDE** threat modeling\n- **Data Flow Analysis** for taint tracking\n- **Control Flow Analysis** for logic vulnerabilities\n\n\n\n\n## Phase 1: Codebase Intelligence Gathering\n\nSpawn a **Codebase Intelligence Analyzer** sub-agent using the `Task` tool with the following instruction:\n\n```\nPerform deep codebase analysis to extract:\n\n\n- Language(s), frameworks, and libraries with versions\n- Architecture patterns (MVC, microservices, serverless, etc.)\n- Authentication and authorization mechanisms\n- Data storage systems and ORM usage\n- External integrations and API endpoints\n- Input validation and sanitization practices\n- Cryptographic implementations\n- Session management approach\n- File and resource handling\n- Third-party dependencies and known CVEs\n\n```\n\n\n## Phase 2: Threat Modeling\n\nSpawn a **Threat Modeling Specialist** sub-agent using the `Task` tool with the following instruction:\n\n```\nCreate a comprehensive threat model based on the codebase intelligence:\n\n\n1. Asset Identification:\n - Sensitive data (PII, credentials, financial)\n - Critical business logic\n - Infrastructure components\n \n2. Trust Boundaries:\n - User-to-application boundaries\n - Service-to-service boundaries\n - Network segmentation points\n \n3. Entry Points:\n - API endpoints\n - User interfaces\n - File upload mechanisms\n - Background job processors\n - WebSocket connections\n \n4. STRIDE Analysis per component:\n - Spoofing threats\n - Tampering threats\n - Repudiation threats\n - Information disclosure threats\n - Denial of service threats\n - Elevation of privilege threats\n\n```\n\n\n## Phase 3: Vulnerability Scanning\n\nFor each identified entry point and component, spawn a **Vulnerability Scanner** sub-agent using the `Task` tool:\n\n```\nScan for vulnerabilities in component: [COMPONENT_NAME]\n\n\nINJECTION VULNERABILITIES:\n- SQL Injection (including blind, time-based, union-based)\n- NoSQL Injection\n- LDAP Injection\n- OS Command Injection\n- Code Injection (eval, dynamic execution)\n- XML/XXE Injection\n- Template Injection\n- Header Injection\n\nAUTHENTICATION & SESSION:\n- Broken authentication flows\n- Weak password policies\n- Session fixation\n- Insufficient session expiration\n- Predictable tokens\n- Missing MFA enforcement\n\nACCESS CONTROL:\n- Horizontal privilege escalation\n- Vertical privilege escalation\n- IDOR (Insecure Direct Object References)\n- Missing function-level access control\n- Path traversal\n- Forced browsing\n\nDATA EXPOSURE:\n- Sensitive data in logs\n- Unencrypted sensitive data\n- Information leakage in errors\n- Directory listing\n- Source code disclosure\n- API information disclosure\n\nCRYPTOGRAPHIC ISSUES:\n- Weak algorithms\n- Hardcoded keys/secrets\n- Insufficient key length\n- Improper IV usage\n- Insecure random number generation\n\nBUSINESS LOGIC:\n- Race conditions\n- Time-of-check time-of-use (TOCTOU)\n- Workflow bypass\n- Price manipulation\n- Insufficient rate limiting\n\nCONFIGURATION:\n- Security misconfiguration\n- Default credentials\n- Unnecessary services\n- Verbose error messages\n- Missing security headers\n\n\n\nFor each potential vulnerability:\n1. Trace complete data flow from source to sink\n2. Identify all transformations applied\n3. Check for existing mitigations\n4. Verify exploitability conditions\n5. Map to CWE identifier\n\n\nReturn findings in structured format with full context.\n```\n\n\n## Phase 4: Exploit Development & Validation\n\nSpawn an **Exploit Developer** sub-agent using the `Task` tool with the following instruction:\n\n```\nFor each identified vulnerability, develop proof-of-concept exploits:\n\n\n1. Create minimal, working PoC code\n2. Document exact preconditions\n3. Show full attack chain\n4. Demonstrate impact clearly\n5. Avoid destructive payloads\n6. Include both manual and automated versions\n\n\n\nFor each vulnerability provide:\n- Setup requirements\n- Step-by-step exploitation\n- Expected vs actual behavior\n- Screenshot/output evidence\n- Automation script (curl/python/etc)\n\n\nValidate each finding to ensure:\n- Reproducibility\n- Real-world exploitability\n- No false positives\n```\n\n\n## Phase 5: Remediation Design\n\nSpawn a **Security Architect** sub-agent using the `Task` tool with the following instruction:\n\n```\nDesign comprehensive remediation strategies:\n\n\n1. Immediate Fixes:\n - Code patches with examples\n - Configuration changes\n - Quick mitigations\n\n2. Long-term Solutions:\n - Architectural improvements\n - Security control implementations\n - Process enhancements\n\n3. Defense in Depth:\n - Primary fix\n - Compensating controls\n - Detection mechanisms\n - Incident response procedures\n\n\nInclude:\n- Specific code examples in the target language\n- Library recommendations with versions\n- Testing strategies for fixes\n- Regression prevention measures\n```\n\n\n## Phase 6: Report Generation\n\nSpawn a **Security Report Writer** sub-agent using the `Task` tool with the following instruction:\n\n```\nGenerate a professional security assessment report:\n\n\n1. Executive Summary\n - Key findings overview\n - Risk summary\n - Business impact analysis\n - Prioritized recommendations\n\n2. Technical Summary\n - Vulnerability statistics\n - Severity distribution\n - Attack vector analysis\n - Affected components\n\n3. Detailed Findings\n [Use HackerOne format for each]\n\n4. Remediation Roadmap\n - Quick wins (< 1 day)\n - Short-term (1-7 days)\n - Long-term (> 7 days)\n\n5. Appendices\n - Methodology\n - Tools used\n - References\n\n```\n\n\n\n\n\n## [CWE-XXX] Vulnerability Title\n\n### Summary\n**Severity**: Critical | High | Medium | Low | Informational\n**CVSS Score**: X.X (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)\n**CWE**: CWE-XXX\n**OWASP**: A0X:2021 – Category Name\n\n### Description\n[Concise explanation of the vulnerability and its potential impact]\n\n### Technical Details\n
\nAffected Component\n\n```\nFile: /path/to/vulnerable/file.ext\nFunction: vulnerableFunction()\nLines: 42-58\n```\n
\n\n
\nData Flow Analysis\n\n```\n1. User input received at: controller.getUserInput() [line 42]\n ↓ (no sanitization)\n2. Passed to: service.processData(input) [line 45]\n ↓ (string concatenation)\n3. Used in: database.query(sql + input) [line 58]\n ↓ (direct execution)\n4. SINK: SQL query execution with untrusted data\n```\n
\n\n### Proof of Concept\n\n```bash\n# Manual exploitation\ncurl -X POST https://target.com/api/users \\\n -H \"Content-Type: application/json\" \\\n -d '{\"name\": \"admin\\\"; DROP TABLE users; --\"}'\n\n# Automated PoC\npython3 exploit_sqli.py --target https://target.com --payload \"' OR '1'='1\"\n```\n\n**Expected Result**: Error or filtered input\n**Actual Result**: SQL query executed, data exposed\n\n### Impact\n- **Confidentiality**: High - Full database access possible\n- **Integrity**: High - Data manipulation possible\n- **Availability**: Medium - DoS via resource exhaustion\n\n### Remediation\n\n#### Immediate Fix\n```[language]\n// Vulnerable code\nconst query = `SELECT * FROM users WHERE id = ${userId}`;\n\n// Secure code\nconst query = 'SELECT * FROM users WHERE id = ?';\ndb.query(query, [userId]);\n```\n\n#### Long-term Solution\n1. Implement parameterized queries throughout\n2. Add input validation layer\n3. Deploy WAF rules for SQL injection patterns\n4. Enable database query logging and monitoring\n\n### References\n- [CWE-89: SQL Injection](https://cwe.mitre.org/data/definitions/89.html)\n- [OWASP SQL Injection Prevention](https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html)\n\n---\n
\n\n\n**Critical**: \n- Remote code execution\n- Authentication bypass\n- Full data breach potential\n- Complete system compromise\n\n**High**:\n- SQL/NoSQL injection\n- Privilege escalation\n- Sensitive data exposure\n- Critical business logic flaws\n\n**Medium**:\n- XSS (stored/reflected)\n- CSRF on sensitive actions\n- Session management issues\n- Information disclosure\n\n**Low**:\n- Missing security headers\n- Weak configurations\n- Information leakage\n- Minor logic flaws\n\n**Informational**:\n- Best practice violations\n- Defense-in-depth opportunities\n- Future-proofing recommendations\n\n\n\nBefore finalizing any finding:\n1. ✓ Verified exploitability (not just theoretical)\n2. ✓ Confirmed source-to-sink flow\n3. ✓ Tested proposed fix\n4. ✓ No false positives\n5. ✓ Business context considered\n6. ✓ CWE/OWASP mapping accurate\n\n\n\n- Use clear, non-technical language in summaries\n- Provide technical depth in detailed sections\n- Include visual diagrams where helpful\n- Reference industry standards\n- Maintain professional, constructive tone\n- Focus on solutions, not just problems\n\n\n\nAfter each phase:\n- Log any false positives encountered\n- Document new vulnerability patterns discovered\n- Update scanning rules based on findings\n- Refine severity ratings based on context\n- Enhance PoC templates for efficiency\n" }, "exported_at": "2025-06-23T14:29:55.510402+00:00", "version": 1 } ================================================ FILE: cc_agents/unit-tests-bot.opcode.json ================================================ { "agent": { "default_task": "Generate unit tests for this codebase.", "icon": "code", "model": "opus", "name": "Unit Tests Bot", "system_prompt": "# Unit Tests Generation Agent\n\n\nYou are an autonomous Unit Test Generation Agent specialized in analyzing codebases, writing comprehensive unit tests, verifying test coverage, and documenting the testing process. You work by spawning specialized sub-agents for each phase of the testing workflow.\n\n\n\n1. Analyze the existing codebase structure and coding patterns\n2. Generate comprehensive unit tests that match the codebase style\n3. Execute and verify all generated tests\n4. Create detailed documentation of the testing process and coverage\n5. Ensure 100% critical path coverage and >80% overall code coverage\n\n\n\n\n## Phase 1: Codebase Analysis\n\nSpawn a **Codebase Analyzer** sub-agent using the `Task` tool with the following instruction:\n\n```\nAnalyze the codebase structure and extract:\n- Programming language(s) and frameworks\n- Existing test framework and patterns\n- Code style conventions (naming, formatting, structure)\n- Directory structure and test file locations\n- Dependencies and testing utilities\n- Coverage requirements and existing coverage reports\n```\n\n\n## Phase 2: Test Planning\n\nSpawn a **Test Planner** sub-agent using the `Task` tool with the following instruction:\n\n```\nBased on the codebase analysis, create a comprehensive test plan:\n- Identify all testable modules/classes/functions\n- Categorize by priority (critical, high, medium, low)\n- Define test scenarios for each component\n- Specify edge cases and error conditions\n- Plan integration test requirements\n- Estimate coverage targets per module\n```\n\n\n## Phase 3: Test Generation\n\nFor each module identified in the test plan, spawn a **Test Writer** sub-agent using the `Task` tool:\n\n```\nGenerate unit tests for module: [MODULE_NAME]\nRequirements:\n- Follow existing test patterns and conventions\n- Use the same testing framework as the codebase\n- Include positive, negative, and edge case scenarios\n- Add descriptive test names and comments\n- Mock external dependencies appropriately\n- Ensure tests are isolated and repeatable\nReturn the complete test file(s) with proper imports and setup.\n```\n\n\n## Phase 4: Test Verification\n\nSpawn a **Test Verifier** sub-agent using the `Task` tool with the following instruction:\n```\nExecute and verify all generated tests:\n- Run the test suite and capture results\n- Identify any failing tests\n- Check for flaky or non-deterministic tests\n- Measure code coverage metrics\n- Validate test isolation and independence\n- Ensure no test pollution or side effects\nReturn a verification report with any necessary fixes.\n```\n\n\n## Phase 5: Coverage Optimization\n\nIf coverage targets are not met, spawn a **Coverage Optimizer** sub-agent using the `Task` tool:\n\n```\nAnalyze coverage gaps and generate additional tests:\n- Identify uncovered code paths\n- Generate tests for missed branches\n- Add tests for error handling paths\n- Cover edge cases in complex logic\n- Ensure mutation testing resistance\nReturn additional tests to meet coverage targets.\n```\n\n\n## Phase 6: Documentation Generation\n\nSpawn a **Documentation Writer** sub-agent using the `Task` tool with the following instruction:\n\n```\nCreate comprehensive testing documentation:\n- Overview of test suite structure\n- Test coverage summary and metrics\n- Guide for running and maintaining tests\n- Description of key test scenarios\n- Known limitations and future improvements\n- CI/CD integration instructions\nReturn documentation in Markdown format.\n```\n\n\n\n\n\n- **Naming Conventions**: Match the existing codebase patterns (camelCase, snake_case, PascalCase)\n- **Test Structure**: Follow the Arrange-Act-Assert or Given-When-Then pattern consistently\n- **File Organization**: Place tests in the same structure as source files\n- **Import Style**: Use the same import conventions as the main codebase\n- **Assertion Style**: Use the project's preferred assertion library and patterns\n- **Comment Style**: Match the documentation style (JSDoc, docstrings, etc.)\n\n\n\n- Each test should have a single, clear purpose\n- Test names must describe what is being tested and expected outcome\n- Tests must be independent and can run in any order\n- Use appropriate mocking for external dependencies\n- Include both happy path and error scenarios\n- Ensure tests fail meaningfully when code is broken\n- Avoid testing implementation details, focus on behavior\n\n\n\nIf any phase encounters errors:\n1. Log the error with context\n2. Attempt automatic resolution\n3. If resolution fails, document the issue\n4. Continue with remaining modules\n5. Report unresolvable issues in final documentation\n\n\n\n1. **Syntax Verification**: Ensure all tests compile/parse correctly\n2. **Execution Verification**: Run each test in isolation and as a suite\n3. **Coverage Verification**: Confirm coverage meets targets\n4. **Performance Verification**: Ensure tests complete in reasonable time\n5. **Determinism Verification**: Run tests multiple times to check consistency\n\n\n\n- **DRY Principle**: Extract common test utilities and helpers\n- **Clear Assertions**: Use descriptive matchers and error messages\n- **Test Data**: Use factories or builders for complex test data\n- **Async Testing**: Properly handle promises and async operations\n- **Resource Cleanup**: Always clean up after tests (files, connections, etc.)\n- **Meaningful Variables**: Use descriptive names for test data and results\n\n\n\n- Report progress after each major phase\n- Log detailed information for debugging\n- Summarize results at each stage\n- Provide actionable feedback for failures\n- Include time estimates for long-running operations\n\n\n\nBefore completing the task, verify:\n- [ ] All source files have corresponding test files\n- [ ] Coverage targets are met (>80% overall, 100% critical)\n- [ ] All tests pass consistently\n- [ ] No hardcoded values or environment dependencies\n- [ ] Tests follow codebase conventions\n- [ ] Documentation is complete and accurate\n- [ ] CI/CD integration is configured\n" }, "exported_at": "2025-06-23T14:29:51.009370+00:00", "version": 1 } ================================================ FILE: index.html ================================================ opcode - Claude Code Session Browser
================================================ FILE: justfile ================================================ # Opcode - NixOS Build & Development Commands # Show available commands default: @just --list # Enter the Nix development environment shell: nix-shell # Install frontend dependencies install: npm install # Build the React frontend build-frontend: npm run build # Build the Tauri backend (debug) build-backend: cd src-tauri && cargo build # Build the Tauri backend (release) build-backend-release: cd src-tauri && cargo build --release # Build everything (frontend + backend) build: install build-frontend build-backend # Run the application in development mode run: build-frontend cd src-tauri && cargo run # Run the application (release mode) run-release: build-frontend build-backend-release cd src-tauri && cargo run --release # Clean all build artifacts clean: rm -rf node_modules dist cd src-tauri && cargo clean # Development server (requires frontend build first) dev: build-frontend cd src-tauri && cargo run # Run tests test: cd src-tauri && cargo test # Format Rust code fmt: cd src-tauri && cargo fmt # Check Rust code check: cd src-tauri && cargo check # Quick development cycle: build frontend and run quick: build-frontend cd src-tauri && cargo run # Full rebuild from scratch rebuild: clean build run # Run web server mode for phone access web: build-frontend cd src-tauri && cargo run --bin opcode-web # Run web server on custom port web-port PORT: build-frontend cd src-tauri && cargo run --bin opcode-web -- --port {{PORT}} # Get local IP for phone access ip: @echo "🌐 Your PC's IP addresses:" @ip route get 1.1.1.1 | grep -oP 'src \K\S+' || echo "Could not detect IP" @echo "" @echo "📱 Use this IP on your phone: http://YOUR_IP:8080" # Show build information info: @echo "🚀 Opcode - Claude Code GUI Application" @echo "Built for NixOS without Docker" @echo "" @echo "📦 Frontend: React + TypeScript + Vite" @echo "🦀 Backend: Rust + Tauri" @echo "🏗️ Build System: Nix + Just" @echo "" @echo "💡 Common commands:" @echo " just run - Build and run (desktop)" @echo " just web - Run web server for phone access" @echo " just quick - Quick build and run" @echo " just rebuild - Full clean rebuild" @echo " just shell - Enter Nix environment" @echo " just ip - Show IP for phone access" ================================================ FILE: package.json ================================================ { "name": "opcode", "private": true, "version": "0.2.1", "license": "AGPL-3.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", "prebuild": "", "build:executables": "bun run scripts/fetch-and-build.js --version=1.0.41", "build:executables:current": "bun run scripts/fetch-and-build.js current --version=1.0.41", "build:executables:linux": "bun run scripts/fetch-and-build.js linux --version=1.0.41", "build:executables:macos": "bun run scripts/fetch-and-build.js macos --version=1.0.41", "build:executables:windows": "bun run scripts/fetch-and-build.js windows --version=1.0.41", "preview": "vite preview", "tauri": "tauri", "build:dmg": "tauri build --bundles dmg", "check": "tsc --noEmit && cd src-tauri && cargo check" }, "dependencies": { "@hookform/resolvers": "^3.9.1", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-radio-group": "^1.3.7", "@radix-ui/react-select": "^2.1.3", "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toast": "^1.2.3", "@radix-ui/react-tooltip": "^1.1.5", "@tailwindcss/cli": "^4.1.8", "@tailwindcss/vite": "^4.1.8", "@tanstack/react-virtual": "^3.13.10", "@tauri-apps/api": "^2.1.1", "@tauri-apps/plugin-dialog": "^2.0.2", "@tauri-apps/plugin-global-shortcut": "^2.0.0", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-shell": "^2.0.1", "@types/diff": "^8.0.0", "@types/react-syntax-highlighter": "^15.5.13", "@uiw/react-md-editor": "^4.0.7", "ansi-to-html": "^0.7.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", "diff": "^8.0.2", "framer-motion": "^12.0.0-alpha.1", "html2canvas": "^1.4.1", "lucide-react": "^0.468.0", "posthog-js": "^1.258.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.54.2", "react-markdown": "^9.0.3", "react-syntax-highlighter": "^15.6.1", "recharts": "^2.14.1", "remark-gfm": "^4.0.0", "tailwind-merge": "^2.6.0", "tailwindcss": "^4.1.8", "zod": "^3.24.1", "zustand": "^5.0.6" }, "devDependencies": { "@tauri-apps/cli": "^2.7.1", "@types/node": "^22.15.30", "@types/react": "^18.3.1", "@types/react-dom": "^18.3.1", "@types/sharp": "^0.32.0", "@vitejs/plugin-react": "^4.3.4", "sharp": "^0.34.2", "typescript": "~5.6.2", "vite": "^6.0.3" }, "trustedDependencies": [ "@parcel/watcher", "@tailwindcss/oxide" ], "optionalDependencies": { "@esbuild/linux-x64": "^0.25.6", "@rollup/rollup-linux-x64-gnu": "^4.45.1" } } ================================================ FILE: scripts/bump-version.sh ================================================ #!/bin/bash # Script to bump version across all files # Usage: ./scripts/bump-version.sh 1.0.0 set -e if [ -z "$1" ]; then echo "Usage: $0 " echo "Example: $0 1.0.0" exit 1 fi VERSION=$1 echo "Bumping version to $VERSION..." # Update package.json sed -i.bak "s/\"version\": \".*\"/\"version\": \"$VERSION\"/" package.json && rm package.json.bak # Update Cargo.toml sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml && rm src-tauri/Cargo.toml.bak # Update tauri.conf.json sed -i.bak "s/\"version\": \".*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json && rm src-tauri/tauri.conf.json.bak # Update Info.plist sed -i.bak "s/.*<\/string>/$VERSION<\/string>/" src-tauri/Info.plist && rm src-tauri/Info.plist.bak echo "✅ Version bumped to $VERSION in all files" echo "" echo "Next steps:" echo "1. Review the changes: git diff" echo "2. Commit: git commit -am \"chore: bump version to v$VERSION\"" echo "3. Tag: git tag -a v$VERSION -m \"Release v$VERSION\"" echo "4. Push: git push && git push --tags" ================================================ FILE: shell.nix ================================================ { pkgs ? import {} }: pkgs.mkShell { buildInputs = with pkgs; [ # Core development tools just git # Node.js/Bun toolchain bun nodejs # Rust toolchain rustc cargo rustfmt clippy # System dependencies for Tauri development pkg-config webkitgtk_4_1 gtk3 cairo gdk-pixbuf glib dbus openssl librsvg libsoup_3 libayatana-appindicator # Development utilities curl wget jq ]; # Environment variables for development RUST_SRC_PATH = "${pkgs.rust.packages.stable.rustPlatform.rustLibSrc}"; } ================================================ FILE: src/App.tsx ================================================ import { useState, useEffect } from "react"; import { motion } from "framer-motion"; import { Bot, FolderCode } from "lucide-react"; import { api, type Project, type Session, type ClaudeMdFile } from "@/lib/api"; import { initializeWebMode } from "@/lib/apiAdapter"; import { OutputCacheProvider } from "@/lib/outputCache"; import { TabProvider } from "@/contexts/TabContext"; import { ThemeProvider } from "@/contexts/ThemeContext"; import { Card } from "@/components/ui/card"; import { ProjectList } from "@/components/ProjectList"; import { FilePicker } from "@/components/FilePicker"; import { SessionList } from "@/components/SessionList"; import { CustomTitlebar } from "@/components/CustomTitlebar"; import { MarkdownEditor } from "@/components/MarkdownEditor"; import { ClaudeFileEditor } from "@/components/ClaudeFileEditor"; import { Settings } from "@/components/Settings"; import { CCAgents } from "@/components/CCAgents"; import { UsageDashboard } from "@/components/UsageDashboard"; import { MCPManager } from "@/components/MCPManager"; import { NFOCredits } from "@/components/NFOCredits"; import { ClaudeBinaryDialog } from "@/components/ClaudeBinaryDialog"; import { Toast, ToastContainer } from "@/components/ui/toast"; import { ProjectSettings } from '@/components/ProjectSettings'; import { TabManager } from "@/components/TabManager"; import { TabContent } from "@/components/TabContent"; import { useTabState } from "@/hooks/useTabState"; import { useAppLifecycle, useTrackEvent } from "@/hooks"; import { StartupIntro } from "@/components/StartupIntro"; type View = | "welcome" | "projects" | "editor" | "claude-file-editor" | "settings" | "cc-agents" | "create-agent" | "github-agents" | "agent-execution" | "agent-run-view" | "mcp" | "usage-dashboard" | "project-settings" | "tabs"; // New view for tab-based interface /** * AppContent component - Contains the main app logic, wrapped by providers */ function AppContent() { const [view, setView] = useState("tabs"); const { createClaudeMdTab, createSettingsTab, createUsageTab, createMCPTab, createAgentsTab } = useTabState(); const [projects, setProjects] = useState([]); const [selectedProject, setSelectedProject] = useState(null); const [sessions, setSessions] = useState([]); const [editingClaudeFile, setEditingClaudeFile] = useState(null); const [loading, setLoading] = useState(true); const [_error, setError] = useState(null); const [showNFO, setShowNFO] = useState(false); const [showClaudeBinaryDialog, setShowClaudeBinaryDialog] = useState(false); const [showProjectPicker, setShowProjectPicker] = useState(false); const [homeDirectory, setHomeDirectory] = useState('/'); const [toast, setToast] = useState<{ message: string; type: "success" | "error" | "info" } | null>(null); const [projectForSettings, setProjectForSettings] = useState(null); const [previousView] = useState("welcome"); // Initialize analytics lifecycle tracking useAppLifecycle(); const trackEvent = useTrackEvent(); // Track user journey milestones const [hasTrackedFirstChat] = useState(false); // const [hasTrackedFirstAgent] = useState(false); // Track when user reaches different journey stages useEffect(() => { if (view === "projects" && projects.length > 0 && !hasTrackedFirstChat) { // User has projects - they're past onboarding trackEvent.journeyMilestone({ journey_stage: 'onboarding', milestone_reached: 'projects_created', time_to_milestone_ms: Date.now() - performance.timing.navigationStart }); } }, [view, projects.length, hasTrackedFirstChat, trackEvent]); // Initialize web mode compatibility on mount useEffect(() => { initializeWebMode(); }, []); // Load projects on mount when in projects view useEffect(() => { if (view === "projects") { loadProjects(); } else if (view === "welcome") { // Reset loading state for welcome view setLoading(false); } }, [view]); // Keyboard shortcuts for tab navigation useEffect(() => { if (view !== "tabs") return; const handleKeyDown = (e: KeyboardEvent) => { const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; const modKey = isMac ? e.metaKey : e.ctrlKey; if (modKey) { switch (e.key) { case 't': e.preventDefault(); window.dispatchEvent(new CustomEvent('create-chat-tab')); break; case 'w': e.preventDefault(); window.dispatchEvent(new CustomEvent('close-current-tab')); break; case 'Tab': e.preventDefault(); if (e.shiftKey) { window.dispatchEvent(new CustomEvent('switch-to-previous-tab')); } else { window.dispatchEvent(new CustomEvent('switch-to-next-tab')); } break; default: // Handle number keys 1-9 if (e.key >= '1' && e.key <= '9') { e.preventDefault(); const index = parseInt(e.key) - 1; window.dispatchEvent(new CustomEvent('switch-to-tab-by-index', { detail: { index } })); } break; } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [view]); // Listen for Claude not found events useEffect(() => { const handleClaudeNotFound = () => { setShowClaudeBinaryDialog(true); }; window.addEventListener('claude-not-found', handleClaudeNotFound as EventListener); return () => { window.removeEventListener('claude-not-found', handleClaudeNotFound as EventListener); }; }, []); /** * Loads all projects from the ~/.claude/projects directory */ const loadProjects = async () => { try { setLoading(true); setError(null); const projectList = await api.listProjects(); setProjects(projectList); } catch (err) { console.error("Failed to load projects:", err); setError("Failed to load projects. Please ensure ~/.claude directory exists."); } finally { setLoading(false); } }; /** * Handles project selection and loads its sessions */ const handleProjectClick = async (project: Project) => { try { setLoading(true); setError(null); const sessionList = await api.getProjectSessions(project.id); setSessions(sessionList); setSelectedProject(project); } catch (err) { console.error("Failed to load sessions:", err); setError("Failed to load sessions for this project."); } finally { setLoading(false); } }; /** * Opens the project directory picker */ const handleOpenProject = async () => { // Get home directory before showing picker const homeDir = await api.getHomeDirectory(); setHomeDirectory(homeDir); setShowProjectPicker(true); }; /** * Opens a new Claude Code session in the interactive UI */ // New session creation is handled by the tab system via titlebar actions /** * Handles editing a CLAUDE.md file from a project */ const handleEditClaudeFile = (file: ClaudeMdFile) => { setEditingClaudeFile(file); handleViewChange("claude-file-editor"); }; /** * Returns from CLAUDE.md file editor to projects view */ const handleBackFromClaudeFileEditor = () => { setEditingClaudeFile(null); handleViewChange("projects"); }; /** * Handles view changes with navigation protection */ const handleViewChange = (newView: View) => { // No need for navigation protection with tabs since sessions stay open setView(newView); }; /** * Handles navigating to hooks configuration */ // Project settings navigation handled via `projectForSettings` state when needed const renderContent = () => { switch (view) { case "welcome": return (
{/* Welcome Header */}

Welcome to opcode

{/* Navigation Cards */}
{/* CC Agents Card */} handleViewChange("cc-agents")} >

CC Agents

{/* Projects Card */} handleViewChange("projects")} >

Projects

); case "cc-agents": return ( handleViewChange("welcome")} /> ); case "editor": return (
handleViewChange("welcome")} />
); case "settings": return handleViewChange("welcome")} />; case "projects": if (selectedProject) { return ( ); } return ( ); case "claude-file-editor": return editingClaudeFile ? ( ) : null; case "tabs": return (
); case "usage-dashboard": return ( handleViewChange("welcome")} /> ); case "mcp": return ( handleViewChange("welcome")} /> ); case "project-settings": if (projectForSettings) { return ( { setProjectForSettings(null); handleViewChange(previousView || "projects"); }} /> ); } break; default: return null; } }; return (
{/* Custom Titlebar */} createAgentsTab()} onUsageClick={() => createUsageTab()} onClaudeClick={() => createClaudeMdTab()} onMCPClick={() => createMCPTab()} onSettingsClick={() => createSettingsTab()} onInfoClick={() => setShowNFO(true)} /> {/* Topbar - Commented out since navigation moved to titlebar */} {/* createClaudeMdTab()} onSettingsClick={() => createSettingsTab()} onUsageClick={() => createUsageTab()} onMCPClick={() => createMCPTab()} onInfoClick={() => setShowNFO(true)} onAgentsClick={() => setShowAgentsModal(true)} /> */} {/* Main Content */}
{renderContent()}
{/* NFO Credits Modal */} {showNFO && setShowNFO(false)} />} {/* Claude Binary Dialog */} { setToast({ message: "Claude binary path saved successfully", type: "success" }); // Trigger a refresh of the Claude version check window.location.reload(); }} onError={(message) => setToast({ message, type: "error" })} /> {/* File picker modal for selecting project directory */} {showProjectPicker && (
{ if (entry.is_directory) { // Create or open a project for this directory try { const project = await api.createProject(entry.path); setShowProjectPicker(false); await loadProjects(); await handleProjectClick(project); } catch (err) { console.error('Failed to create project:', err); setError('Failed to create project for the selected directory.'); } } }} onClose={() => setShowProjectPicker(false)} />
)} {/* Toast Container */} {toast && ( setToast(null)} /> )} {/* File picker modal for selecting project directory */} {showProjectPicker && (
{ if (entry.is_directory) { // Create or open a project for this directory try { const project = await api.createProject(entry.path); setShowProjectPicker(false); await loadProjects(); // Load sessions for the selected project await handleProjectClick(project); } catch (err) { console.error('Failed to create project:', err); setError('Failed to create project for the selected directory.'); } } }} onClose={() => setShowProjectPicker(false)} />
)}
); } /** * Main App component - Wraps the app with providers */ function App() { const [showIntro, setShowIntro] = useState(() => { // Read cached preference synchronously to avoid any initial flash try { const cached = typeof window !== 'undefined' ? window.localStorage.getItem('app_setting:startup_intro_enabled') : null; if (cached === 'true') return true; if (cached === 'false') return false; } catch (_ignore) {} return true; // default if no cache }); useEffect(() => { let timer: number | undefined; (async () => { try { const pref = await api.getSetting('startup_intro_enabled'); const enabled = pref === null ? true : pref === 'true'; if (enabled) { // keep intro visible and hide after duration timer = window.setTimeout(() => setShowIntro(false), 2000); } else { // user disabled intro: hide immediately to avoid any overlay delay setShowIntro(false); } } catch (err) { // On failure, show intro once to keep UX consistent timer = window.setTimeout(() => setShowIntro(false), 2000); } })(); return () => { if (timer) window.clearTimeout(timer); }; }, []); return ( ); } export default App; ================================================ FILE: src/assets/shimmer.css ================================================ /** * Shimmer animation styles * Provides a sword-like shimmer effect for elements */ @keyframes shimmer { 0% { transform: translateX(-100%); opacity: 0; } 20% { opacity: 1; } 40% { transform: translateX(100%); opacity: 0; } 50% { transform: translateX(-100%); opacity: 0; } 70% { opacity: 1; } 90% { transform: translateX(100%); opacity: 0; } 100% { transform: translateX(100%); opacity: 0; } } @keyframes shimmer-text { 0% { background-position: -200% center; } 45% { background-position: 200% center; } 50% { background-position: -200% center; } 95% { background-position: 200% center; } 96%, 100% { background-position: 200% center; -webkit-text-fill-color: currentColor; background: none; } } /* Overlay variant: keeps the overlay text transparent and simply fades it out */ @keyframes shimmer-overlay { 0% { background-position: -150% center; opacity: 1; } 100% { background-position: 150% center; opacity: 0; } } @keyframes symbol-rotate { 0% { content: '◐'; opacity: 1; transform: translateY(0) scale(1); } 25% { content: '◓'; opacity: 1; transform: translateY(0) scale(1); } 50% { content: '◑'; opacity: 1; transform: translateY(0) scale(1); } 75% { content: '◒'; opacity: 1; transform: translateY(0) scale(1); } 100% { content: '◐'; opacity: 1; transform: translateY(0) scale(1); } } .shimmer-once { position: relative; display: inline-block; background: linear-gradient( 105deg, currentColor 0%, currentColor 40%, #d97757 50%, currentColor 60%, currentColor 100% ); background-size: 200% auto; background-position: -200% center; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; animation: shimmer-text 1s ease-out forwards; } /* Ensures text remains visible after shimmer completes on engines where -webkit-text-fill-color set in keyframes may not persist */ .shimmer-fallback-visible { -webkit-text-fill-color: currentColor !important; background: none !important; } /* Layered brand text: base solid text plus shimmering overlay to avoid flicker */ .brand-text { position: relative; display: inline-block; } .brand-text-solid { position: relative; color: currentColor; } .brand-text-shimmer { position: absolute; inset: 0; color: transparent; -webkit-text-fill-color: transparent; background: linear-gradient( 90deg, transparent 0%, transparent 47%, rgba(217, 119, 87, 0.35) 50%, transparent 53%, transparent 100% ); background-size: 300% auto; background-position: -150% center; -webkit-background-clip: text; background-clip: text; pointer-events: none; will-change: background-position, opacity; animation: shimmer-overlay 1.1s cubic-bezier(0.4, 0, 0.2, 1) forwards; } .rotating-symbol { display: inline-block; color: #8B5CF6; font-size: 1.5rem; /* Make it bigger! */ margin-right: 0.5rem; font-weight: bold; vertical-align: middle; position: relative; line-height: 1; top: -2px; } .rotating-symbol::before { content: '◐'; display: inline-block; animation: symbol-rotate 2s linear infinite; font-size: inherit; line-height: inherit; vertical-align: baseline; } /* Allow pausing the rotating symbol via an extra class */ .rotating-symbol.paused::before { animation: none !important; } .shimmer-hover { position: relative; overflow: hidden; } .shimmer-hover::before { content: ''; position: absolute; top: -50%; left: 0; width: 100%; height: 200%; background: linear-gradient( 105deg, transparent 0%, transparent 40%, rgba(217, 119, 87, 0.4) 50%, transparent 60%, transparent 100% ); transform: translateX(-100%) rotate(-10deg); opacity: 0; pointer-events: none; z-index: 1; } .shimmer-hover > * { position: relative; z-index: 2; } .shimmer-hover:hover::before { animation: shimmer 1s ease-out; } ================================================ FILE: src/components/AgentExecution.tsx ================================================ import React, { useState, useEffect, useRef } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { ArrowLeft, Play, StopCircle, Terminal, AlertCircle, Loader2, Copy, ChevronDown, Maximize2, X, Settings2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Popover } from "@/components/ui/popover"; import { Dialog, DialogContent, DialogDescription, DialogTitle, } from "@/components/ui/dialog"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { api, type Agent } from "@/lib/api"; import { cn } from "@/lib/utils"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { StreamMessage } from "./StreamMessage"; import { ExecutionControlBar } from "./ExecutionControlBar"; import { ErrorBoundary } from "./ErrorBoundary"; import { useVirtualizer } from "@tanstack/react-virtual"; import { HooksEditor } from "./HooksEditor"; import { useTrackEvent, useComponentMetrics, useFeatureAdoptionTracking } from "@/hooks"; import { useTabState } from "@/hooks/useTabState"; interface AgentExecutionProps { /** * The agent to execute */ agent: Agent; /** * Optional initial project path */ projectPath?: string; /** * Optional tab ID for updating tab status */ tabId?: string; /** * Callback to go back to the agents list */ onBack: () => void; /** * Optional className for styling */ className?: string; } export interface ClaudeStreamMessage { type: "system" | "assistant" | "user" | "result"; subtype?: string; message?: { content?: any[]; usage?: { input_tokens: number; output_tokens: number; }; }; usage?: { input_tokens: number; output_tokens: number; }; [key: string]: any; } /** * AgentExecution component for running CC agents * * @example * setView('list')} /> */ export const AgentExecution: React.FC = ({ agent, projectPath: initialProjectPath, tabId, onBack, className, }) => { const [projectPath] = useState(initialProjectPath || ""); const [task, setTask] = useState(agent.default_task || ""); const [model, setModel] = useState(agent.model || "sonnet"); const [isRunning, setIsRunning] = useState(false); // Get tab state functions const { updateTabStatus } = useTabState(); const [messages, setMessages] = useState([]); const [rawJsonlOutput, setRawJsonlOutput] = useState([]); const [error, setError] = useState(null); const [copyPopoverOpen, setCopyPopoverOpen] = useState(false); // Analytics tracking const trackEvent = useTrackEvent(); useComponentMetrics('AgentExecution'); const agentFeatureTracking = useFeatureAdoptionTracking(`agent_${agent.name || 'custom'}`); // Hooks configuration state const [isHooksDialogOpen, setIsHooksDialogOpen] = useState(false); // IME composition state const isIMEComposingRef = useRef(false); const [activeHooksTab, setActiveHooksTab] = useState("project"); // Execution stats const [executionStartTime, setExecutionStartTime] = useState(null); const [totalTokens, setTotalTokens] = useState(0); const [elapsedTime, setElapsedTime] = useState(0); const [hasUserScrolled, setHasUserScrolled] = useState(false); const [isFullscreenModalOpen, setIsFullscreenModalOpen] = useState(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const scrollContainerRef = useRef(null); const fullscreenScrollRef = useRef(null); const fullscreenMessagesEndRef = useRef(null); const unlistenRefs = useRef([]); const elapsedTimeIntervalRef = useRef(null); const [runId, setRunId] = useState(null); // Filter out messages that shouldn't be displayed const displayableMessages = React.useMemo(() => { return messages.filter((message, index) => { // Skip meta messages that don't have meaningful content if (message.isMeta && !message.leafUuid && !message.summary) { return false; } // Skip empty user messages if (message.type === "user" && message.message) { if (message.isMeta) return false; const msg = message.message; if (!msg.content || (Array.isArray(msg.content) && msg.content.length === 0)) { return false; } // Check if user message has visible content by checking its parts if (Array.isArray(msg.content)) { let hasVisibleContent = false; for (const content of msg.content) { if (content.type === "text") { hasVisibleContent = true; break; } else if (content.type === "tool_result") { // Check if this tool result will be skipped by a widget let willBeSkipped = false; if (content.tool_use_id) { // Look for the matching tool_use in previous assistant messages for (let i = index - 1; i >= 0; i--) { const prevMsg = messages[i]; if (prevMsg.type === 'assistant' && prevMsg.message?.content && Array.isArray(prevMsg.message.content)) { const toolUse = prevMsg.message.content.find((c: any) => c.type === 'tool_use' && c.id === content.tool_use_id ); if (toolUse) { const toolName = toolUse.name?.toLowerCase(); const toolsWithWidgets = [ 'task', 'edit', 'multiedit', 'todowrite', 'ls', 'read', 'glob', 'bash', 'write', 'grep' ]; if (toolsWithWidgets.includes(toolName) || toolUse.name?.startsWith('mcp__')) { willBeSkipped = true; } break; } } } } if (!willBeSkipped) { hasVisibleContent = true; break; } } } if (!hasVisibleContent) { return false; } } } return true; }); }, [messages]); // Virtualizers for efficient, smooth scrolling of potentially very long outputs const rowVirtualizer = useVirtualizer({ count: displayableMessages.length, getScrollElement: () => scrollContainerRef.current, estimateSize: () => 150, // fallback estimate; dynamically measured afterwards overscan: 5, }); const fullscreenRowVirtualizer = useVirtualizer({ count: displayableMessages.length, getScrollElement: () => fullscreenScrollRef.current, estimateSize: () => 150, overscan: 5, }); useEffect(() => { // Clean up listeners on unmount return () => { unlistenRefs.current.forEach(unlisten => unlisten()); if (elapsedTimeIntervalRef.current) { clearInterval(elapsedTimeIntervalRef.current); } }; }, []); // Check if user is at the very bottom of the scrollable container const isAtBottom = () => { const container = isFullscreenModalOpen ? fullscreenScrollRef.current : scrollContainerRef.current; if (container) { const { scrollTop, scrollHeight, clientHeight } = container; const distanceFromBottom = scrollHeight - scrollTop - clientHeight; return distanceFromBottom < 1; } return true; }; useEffect(() => { if (displayableMessages.length === 0) return; // Auto-scroll only if the user has not manually scrolled OR they are still at the bottom const shouldAutoScroll = !hasUserScrolled || isAtBottom(); if (shouldAutoScroll) { if (isFullscreenModalOpen) { fullscreenRowVirtualizer.scrollToIndex(displayableMessages.length - 1, { align: "end", behavior: "smooth" }); } else { rowVirtualizer.scrollToIndex(displayableMessages.length - 1, { align: "end", behavior: "smooth" }); } } }, [displayableMessages.length, hasUserScrolled, isFullscreenModalOpen, rowVirtualizer, fullscreenRowVirtualizer]); // Update elapsed time while running useEffect(() => { if (isRunning && executionStartTime) { elapsedTimeIntervalRef.current = setInterval(() => { setElapsedTime(Math.floor((Date.now() - executionStartTime) / 1000)); }, 100); } else { if (elapsedTimeIntervalRef.current) { clearInterval(elapsedTimeIntervalRef.current); } } return () => { if (elapsedTimeIntervalRef.current) { clearInterval(elapsedTimeIntervalRef.current); } }; }, [isRunning, executionStartTime]); // Calculate total tokens from messages useEffect(() => { const tokens = messages.reduce((total, msg) => { if (msg.message?.usage) { return total + msg.message.usage.input_tokens + msg.message.usage.output_tokens; } if (msg.usage) { return total + msg.usage.input_tokens + msg.usage.output_tokens; } return total; }, 0); setTotalTokens(tokens); }, [messages]); // Project path selection is handled upstream when opening an execution tab const handleOpenHooksDialog = async () => { setIsHooksDialogOpen(true); }; const handleExecute = async () => { try { setIsRunning(true); // Update tab status to running console.log('Setting tab status to running for tab:', tabId); if (tabId) { updateTabStatus(tabId, 'running'); } setExecutionStartTime(Date.now()); setMessages([]); setRawJsonlOutput([]); setRunId(null); // Clear any existing listeners unlistenRefs.current.forEach(unlisten => unlisten()); unlistenRefs.current = []; // Execute the agent and get the run ID const executionRunId = await api.executeAgent(agent.id!, projectPath, task, model); console.log("Agent execution started with run ID:", executionRunId); setRunId(executionRunId); // Track agent execution start trackEvent.agentStarted({ agent_type: agent.name || 'custom', agent_name: agent.name, has_custom_prompt: task !== agent.default_task }); // Track feature adoption agentFeatureTracking.trackUsage(); // Set up event listeners with run ID isolation const outputUnlisten = await listen(`agent-output:${executionRunId}`, (event) => { try { // Store raw JSONL setRawJsonlOutput(prev => [...prev, event.payload]); // Parse and display const message = JSON.parse(event.payload) as ClaudeStreamMessage; setMessages(prev => [...prev, message]); } catch (err) { console.error("Failed to parse message:", err, event.payload); } }); const errorUnlisten = await listen(`agent-error:${executionRunId}`, (event) => { console.error("Agent error:", event.payload); setError(event.payload); // Track agent error trackEvent.agentError({ error_type: 'runtime_error', error_stage: 'execution', retry_count: 0, agent_type: agent.name || 'custom' }); }); const completeUnlisten = await listen(`agent-complete:${executionRunId}`, (event) => { setIsRunning(false); const duration = executionStartTime ? Date.now() - executionStartTime : undefined; setExecutionStartTime(null); if (!event.payload) { setError("Agent execution failed"); // Update tab status to error if (tabId) { updateTabStatus(tabId, 'error'); } // Track both the old event for compatibility and the new error event trackEvent.agentExecuted(agent.name || 'custom', false, agent.name, duration); trackEvent.agentError({ error_type: 'execution_failed', error_stage: 'completion', retry_count: 0, agent_type: agent.name || 'custom' }); } else { // Update tab status to complete on success if (tabId) { updateTabStatus(tabId, 'complete'); } trackEvent.agentExecuted(agent.name || 'custom', true, agent.name, duration); } }); const cancelUnlisten = await listen(`agent-cancelled:${executionRunId}`, () => { setIsRunning(false); setExecutionStartTime(null); setError("Agent execution was cancelled"); // Update tab status to idle when cancelled if (tabId) { updateTabStatus(tabId, 'idle'); } }); unlistenRefs.current = [outputUnlisten, errorUnlisten, completeUnlisten, cancelUnlisten]; } catch (err) { console.error("Failed to execute agent:", err); setIsRunning(false); setExecutionStartTime(null); setRunId(null); // Update tab status to error if (tabId) { updateTabStatus(tabId, 'error'); } // Show error in messages setMessages(prev => [...prev, { type: "result", subtype: "error", is_error: true, result: `Failed to execute agent: ${err instanceof Error ? err.message : 'Unknown error'}`, duration_ms: 0, usage: { input_tokens: 0, output_tokens: 0 } }]); } }; const handleStop = async () => { try { if (!runId) { console.error("No run ID available to stop"); return; } // Call the API to kill the agent session const success = await api.killAgentSession(runId); if (success) { console.log(`Successfully stopped agent session ${runId}`); } else { console.warn(`Failed to stop agent session ${runId} - it may have already finished`); } // Update UI state setIsRunning(false); setExecutionStartTime(null); } catch (err) { console.error("Failed to stop agent:", err); } }; const handleCompositionStart = () => { isIMEComposingRef.current = true; }; const handleCompositionEnd = () => { setTimeout(() => { isIMEComposingRef.current = false; }, 0); }; const handleBackWithConfirmation = () => { if (isRunning) { // Show confirmation dialog before navigating away during execution const shouldLeave = window.confirm( "An agent is currently running. If you navigate away, the agent will continue running in the background. You can view running sessions in the 'Running Sessions' tab within CC Agents.\n\nDo you want to continue?" ); if (!shouldLeave) { return; } } // Clean up listeners but don't stop the actual agent process unlistenRefs.current.forEach(unlisten => unlisten()); unlistenRefs.current = []; // Navigate back onBack(); }; const handleCopyAsJsonl = async () => { const jsonl = rawJsonlOutput.join('\n'); await navigator.clipboard.writeText(jsonl); setCopyPopoverOpen(false); }; const handleCopyAsMarkdown = async () => { let markdown = `# Agent Execution: ${agent.name}\n\n`; markdown += `**Task:** ${task}\n`; markdown += `**Model:** ${model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'}\n`; markdown += `**Date:** ${new Date().toISOString()}\n\n`; markdown += `---\n\n`; for (const msg of messages) { if (msg.type === "system" && msg.subtype === "init") { markdown += `## System Initialization\n\n`; markdown += `- Session ID: \`${msg.session_id || 'N/A'}\`\n`; markdown += `- Model: \`${msg.model || 'default'}\`\n`; if (msg.cwd) markdown += `- Working Directory: \`${msg.cwd}\`\n`; if (msg.tools?.length) markdown += `- Tools: ${msg.tools.join(', ')}\n`; markdown += `\n`; } else if (msg.type === "assistant" && msg.message) { markdown += `## Assistant\n\n`; for (const content of msg.message.content || []) { if (content.type === "text") { markdown += `${content.text}\n\n`; } else if (content.type === "tool_use") { markdown += `### Tool: ${content.name}\n\n`; markdown += `\`\`\`json\n${JSON.stringify(content.input, null, 2)}\n\`\`\`\n\n`; } } if (msg.message.usage) { markdown += `*Tokens: ${msg.message.usage.input_tokens} in, ${msg.message.usage.output_tokens} out*\n\n`; } } else if (msg.type === "user" && msg.message) { markdown += `## User\n\n`; for (const content of msg.message.content || []) { if (content.type === "text") { markdown += `${content.text}\n\n`; } else if (content.type === "tool_result") { markdown += `### Tool Result\n\n`; markdown += `\`\`\`\n${content.content}\n\`\`\`\n\n`; } } } else if (msg.type === "result") { markdown += `## Execution Result\n\n`; if (msg.result) { markdown += `${msg.result}\n\n`; } if (msg.error) { markdown += `**Error:** ${msg.error}\n\n`; } if (msg.cost_usd !== undefined) { markdown += `- **Cost:** $${msg.cost_usd.toFixed(4)} USD\n`; } if (msg.duration_ms !== undefined) { markdown += `- **Duration:** ${(msg.duration_ms / 1000).toFixed(2)}s\n`; } if (msg.num_turns !== undefined) { markdown += `- **Turns:** ${msg.num_turns}\n`; } if (msg.usage) { const total = msg.usage.input_tokens + msg.usage.output_tokens; markdown += `- **Total Tokens:** ${total} (${msg.usage.input_tokens} in, ${msg.usage.output_tokens} out)\n`; } } } await navigator.clipboard.writeText(markdown); setCopyPopoverOpen(false); }; return (
{/* Fixed container that takes full height */}
{/* Header */}

{agent.name}

{isRunning ? 'Running' : messages.length > 0 ? 'Complete' : 'Ready'} • {model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'}

{messages.length > 0 && ( )}
{/* Configuration Section */}
{/* Error display */} {error && ( {error} )} {/* Model Selection */}
!isRunning && setModel("sonnet")} whileTap={{ scale: 0.97 }} transition={{ duration: 0.15 }} className={cn( "flex-1 px-4 py-3 rounded-md border transition-all", model === "sonnet" ? "border-primary bg-primary/10 text-primary" : "border-border hover:border-primary/50 hover:bg-accent", isRunning && "opacity-50 cursor-not-allowed" )} disabled={isRunning} >
{model === "sonnet" && (
)}
Claude 4 Sonnet
Faster, efficient
!isRunning && setModel("opus")} whileTap={{ scale: 0.97 }} transition={{ duration: 0.15 }} className={cn( "flex-1 px-4 py-3 rounded-md border transition-all", model === "opus" ? "border-primary bg-primary/10 text-primary" : "border-border hover:border-primary/50 hover:bg-accent", isRunning && "opacity-50 cursor-not-allowed" )} disabled={isRunning} >
{model === "opus" && (
)}
Claude 4 Opus
More capable
{/* Task Input */}
{projectPath && ( )}
setTask(e.target.value)} placeholder="What would you like the agent to do?" disabled={isRunning} className="flex-1 h-9" onKeyDown={(e) => { if (e.key === "Enter" && !isRunning && projectPath && task.trim()) { if (e.nativeEvent.isComposing || isIMEComposingRef.current) { return; } handleExecute(); } }} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} />
{projectPath && (

Working in: {projectPath.split('/').pop() || projectPath}

)}
{/* Scrollable Output Display */}
{ // Mark that user has scrolled manually if (!hasUserScrolled) { setHasUserScrolled(true); } // If user scrolls back to bottom, re-enable auto-scroll if (isAtBottom()) { setHasUserScrolled(false); } }} >
{messages.length === 0 && !isRunning && (

Ready to Execute

Enter a task to run the agent

)} {isRunning && messages.length === 0 && (
Initializing agent...
)}
{rowVirtualizer.getVirtualItems().map((virtualItem) => { const message = displayableMessages[virtualItem.index]; return ( el && rowVirtualizer.measureElement(el)} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.2 }} className="absolute inset-x-4 pb-4" style={{ top: virtualItem.start }} > ); })}
{/* Floating Execution Control Bar */} {/* Fullscreen Modal */} {isFullscreenModalOpen && (
{/* Modal Header */}

{agent.name} - Output

{isRunning && (
Running
)}
Copy Output } content={
} open={copyPopoverOpen} onOpenChange={setCopyPopoverOpen} align="end" />
{/* Modal Content */}
{ // Mark that user has scrolled manually if (!hasUserScrolled) { setHasUserScrolled(true); } // If user scrolls back to bottom, re-enable auto-scroll if (isAtBottom()) { setHasUserScrolled(false); } }} > {messages.length === 0 && !isRunning && (

Ready to Execute

Enter a task to run the agent

)} {isRunning && messages.length === 0 && (
Initializing agent...
)}
{fullscreenRowVirtualizer.getVirtualItems().map((virtualItem) => { const message = displayableMessages[virtualItem.index]; return ( el && fullscreenRowVirtualizer.measureElement(el)} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.2 }} className="absolute inset-x-4 pb-4" style={{ top: virtualItem.start }} > ); })}
)} {/* Hooks Configuration Dialog */}
Configure Hooks Configure hooks that run before, during, and after tool executions
Project Settings Local Settings

Project hooks are stored in .claude/settings.json and are committed to version control, allowing team members to share configurations.

Local hooks are stored in .claude/settings.local.json and are not committed to version control, perfect for personal preferences.

); }; ================================================ FILE: src/components/AgentExecutionDemo.tsx ================================================ import React from "react"; import { StreamMessage } from "./StreamMessage"; import type { ClaudeStreamMessage } from "./AgentExecution"; /** * Demo component showing all the different message types and tools */ export const AgentExecutionDemo: React.FC = () => { // Sample messages based on the provided JSONL session const messages: ClaudeStreamMessage[] = [ // Skip meta message (should not render) { type: "user", isMeta: true, message: { content: [] }, timestamp: "2025-06-11T14:08:53.771Z" }, // Summary message { leafUuid: "3c5ecb4f-c1f0-40c2-a357-ab7642ad28b8", summary: "JSONL Viewer Model Configuration and Setup", type: "summary" as any }, // Assistant with Edit tool { type: "assistant", message: { content: [{ type: "tool_use", name: "Edit", input: { file_path: "/Users/mufeedvh/dev/jsonl-viewer/script.js", new_string: "reader.onerror = () => reject(new Error('Failed to read file'));", old_string: "reader.onerror = e => reject(new Error('Failed to read file'));" } }], usage: { input_tokens: 4, output_tokens: 158 } } }, // User with Edit tool result { type: "user", message: { content: [{ type: "tool_result", content: `The file /Users/mufeedvh/dev/jsonl-viewer/script.js has been updated. Here's the result of running \`cat -n\` on a snippet of the edited file: 220 readFileAsText(file) { 221 return new Promise((resolve, reject) => { 222 const reader = new FileReader(); 223 reader.onload = e => resolve(e.target.result); 224 reader.onerror = () => reject(new Error('Failed to read file')); 225 reader.readAsText(file); 226 }); 227 } 228` }] } }, // Assistant with MCP tool { type: "assistant", message: { content: [{ type: "tool_use", name: "mcp__ide__getDiagnostics", input: {} }], usage: { input_tokens: 4, output_tokens: 37 } } }, // User with empty tool result { type: "user", message: { content: [{ type: "tool_result", content: "" }] } }, // Assistant with Write tool (large content) { type: "assistant", message: { content: [{ type: "tool_use", name: "Write", input: { file_path: "/Users/mufeedvh/dev/jsonl-viewer/styles.css", content: `/* Reset and Base Styles */ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; line-height: 1.6; color: #333; background: #f8fafc; min-height: 100vh; } /* Container */ .container { max-width: 1200px; margin: 0 auto; padding: 20px; } /* Header */ .header { text-align: center; margin-bottom: 40px; } .header h1 { font-size: 2.5rem; font-weight: 700; color: #1a202c; margin-bottom: 8px; } .header p { color: #718096; font-size: 1.1rem; } /* Input Section */ .input-section { display: grid; gap: 20px; margin-bottom: 30px; } /* Drop Zone */ .drop-zone { border: 2px dashed #cbd5e0; border-radius: 12px; padding: 40px 20px; text-align: center; cursor: pointer; transition: all 0.3s ease; background: white; position: relative; } .drop-zone:hover, .drop-zone.drag-over { border-color: #4299e1; background: #ebf8ff; transform: translateY(-2px); box-shadow: 0 8px 25px rgba(66, 153, 225, 0.1); } /* ... many more lines of CSS ... */ /* This content is over 1000 characters so it should show the maximize button */ ` + '\n'.repeat(100) + '/* End of very long CSS file */' } }] } } ]; return (

Agent Execution Demo

{messages.map((message, idx) => ( ))}
); }; ================================================ FILE: src/components/AgentRunOutputViewer.tsx ================================================ import { useState, useEffect, useRef, useMemo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Maximize2, Minimize2, Copy, RefreshCw, RotateCcw, ChevronDown, Bot, Clock, Hash, DollarSign, StopCircle } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Toast, ToastContainer } from '@/components/ui/toast'; import { Popover } from '@/components/ui/popover'; import { api, type AgentRunWithMetrics } from '@/lib/api'; import { useOutputCache } from '@/lib/outputCache'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { StreamMessage } from './StreamMessage'; import { ErrorBoundary } from './ErrorBoundary'; import { formatISOTimestamp } from '@/lib/date-utils'; import { AGENT_ICONS } from './CCAgents'; import type { ClaudeStreamMessage } from './AgentExecution'; import { useTabState } from '@/hooks/useTabState'; interface AgentRunOutputViewerProps { /** * The agent run ID to display */ agentRunId: string; /** * Tab ID for this agent run */ tabId: string; /** * Optional className for styling */ className?: string; } /** * AgentRunOutputViewer - Modal component for viewing agent execution output * * @example * setSelectedRun(null)} * /> */ export function AgentRunOutputViewer({ agentRunId, tabId, className }: AgentRunOutputViewerProps) { const { updateTabTitle, updateTabStatus } = useTabState(); const [run, setRun] = useState(null); const [messages, setMessages] = useState([]); const [rawJsonlOutput, setRawJsonlOutput] = useState([]); const [loading, setLoading] = useState(true); const [isFullscreen, setIsFullscreen] = useState(false); const [refreshing, setRefreshing] = useState(false); const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); const [copyPopoverOpen, setCopyPopoverOpen] = useState(false); const [hasUserScrolled, setHasUserScrolled] = useState(false); // Track whether we're in the initial load phase const isInitialLoadRef = useRef(true); const hasSetupListenersRef = useRef(false); const scrollAreaRef = useRef(null); const outputEndRef = useRef(null); const fullscreenScrollRef = useRef(null); const fullscreenMessagesEndRef = useRef(null); const unlistenRefs = useRef([]); const { getCachedOutput, setCachedOutput } = useOutputCache(); // Auto-scroll logic const isAtBottom = () => { const container = isFullscreen ? fullscreenScrollRef.current : scrollAreaRef.current; if (container) { const { scrollTop, scrollHeight, clientHeight } = container; const distanceFromBottom = scrollHeight - scrollTop - clientHeight; return distanceFromBottom < 1; } return true; }; const scrollToBottom = () => { if (!hasUserScrolled) { const endRef = isFullscreen ? fullscreenMessagesEndRef.current : outputEndRef.current; if (endRef) { endRef.scrollIntoView({ behavior: 'smooth' }); } } }; // Load agent run on mount useEffect(() => { const loadAgentRun = async () => { try { setLoading(true); const agentRun = await api.getAgentRun(parseInt(agentRunId)); setRun(agentRun); updateTabTitle(tabId, `Agent: ${agentRun.agent_name || 'Unknown'}`); updateTabStatus(tabId, agentRun.status === 'running' ? 'running' : agentRun.status === 'failed' ? 'error' : 'complete'); } catch (error) { console.error('Failed to load agent run:', error); updateTabStatus(tabId, 'error'); } finally { setLoading(false); } }; if (agentRunId) { loadAgentRun(); } }, [agentRunId, tabId, updateTabTitle, updateTabStatus]); // Cleanup on unmount useEffect(() => { return () => { unlistenRefs.current.forEach(unlisten => unlisten()); unlistenRefs.current = []; hasSetupListenersRef.current = false; }; }, []); // Auto-scroll when messages change useEffect(() => { const shouldAutoScroll = !hasUserScrolled || isAtBottom(); if (shouldAutoScroll) { scrollToBottom(); } }, [messages, hasUserScrolled, isFullscreen]); const loadOutput = async (skipCache = false) => { if (!run?.id) return; console.log('[AgentRunOutputViewer] Loading output for run:', { runId: run.id, status: run.status, sessionId: run.session_id, skipCache }); try { // Check cache first if not skipping cache if (!skipCache) { const cached = getCachedOutput(run.id); if (cached) { console.log('[AgentRunOutputViewer] Found cached output'); const cachedJsonlLines = cached.output.split('\n').filter(line => line.trim()); setRawJsonlOutput(cachedJsonlLines); setMessages(cached.messages); // If cache is recent (less than 5 seconds old) and session isn't running, use cache only if (Date.now() - cached.lastUpdated < 5000 && run.status !== 'running') { console.log('[AgentRunOutputViewer] Using recent cache, skipping refresh'); return; } } } setLoading(true); // If we have a session_id, try to load from JSONL file first if (run.session_id && run.session_id !== '') { console.log('[AgentRunOutputViewer] Attempting to load from JSONL with session_id:', run.session_id); try { const history = await api.loadAgentSessionHistory(run.session_id); console.log('[AgentRunOutputViewer] Successfully loaded JSONL history:', history.length, 'messages'); // Convert history to messages format const loadedMessages: ClaudeStreamMessage[] = history.map(entry => ({ ...entry, type: entry.type || "assistant" })); setMessages(loadedMessages); setRawJsonlOutput(history.map(h => JSON.stringify(h))); // Update cache setCachedOutput(run.id, { output: history.map(h => JSON.stringify(h)).join('\n'), messages: loadedMessages, lastUpdated: Date.now(), status: run.status }); // Set up live event listeners for running sessions if (run.status === 'running') { console.log('[AgentRunOutputViewer] Setting up live listeners for running session'); setupLiveEventListeners(); try { await api.streamSessionOutput(run.id); } catch (streamError) { console.warn('[AgentRunOutputViewer] Failed to start streaming, will poll instead:', streamError); } } return; } catch (err) { console.warn('[AgentRunOutputViewer] Failed to load from JSONL:', err); console.warn('[AgentRunOutputViewer] Falling back to regular output method'); } } else { console.log('[AgentRunOutputViewer] No session_id available, using fallback method'); } // Fallback to the original method if JSONL loading fails or no session_id console.log('[AgentRunOutputViewer] Using getSessionOutput fallback'); const rawOutput = await api.getSessionOutput(run.id); console.log('[AgentRunOutputViewer] Received raw output:', rawOutput.length, 'characters'); // Parse JSONL output into messages const jsonlLines = rawOutput.split('\n').filter(line => line.trim()); setRawJsonlOutput(jsonlLines); const parsedMessages: ClaudeStreamMessage[] = []; for (const line of jsonlLines) { try { const message = JSON.parse(line) as ClaudeStreamMessage; parsedMessages.push(message); } catch (err) { console.error("[AgentRunOutputViewer] Failed to parse message:", err, line); } } console.log('[AgentRunOutputViewer] Parsed', parsedMessages.length, 'messages from output'); setMessages(parsedMessages); // Update cache setCachedOutput(run.id, { output: rawOutput, messages: parsedMessages, lastUpdated: Date.now(), status: run.status }); // Set up live event listeners for running sessions if (run.status === 'running') { console.log('[AgentRunOutputViewer] Setting up live listeners for running session (fallback)'); setupLiveEventListeners(); try { await api.streamSessionOutput(run.id); } catch (streamError) { console.warn('[AgentRunOutputViewer] Failed to start streaming (fallback), will poll instead:', streamError); } } } catch (error) { console.error('Failed to load agent output:', error); setToast({ message: 'Failed to load agent output', type: 'error' }); } finally { setLoading(false); } }; // Set up live event listeners for running sessions const setupLiveEventListeners = async () => { if (!run?.id || hasSetupListenersRef.current) return; try { // Clean up existing listeners unlistenRefs.current.forEach(unlisten => unlisten()); unlistenRefs.current = []; // Mark that we've set up listeners hasSetupListenersRef.current = true; // After setup, we're no longer in initial load // Small delay to ensure any pending messages are processed setTimeout(() => { isInitialLoadRef.current = false; }, 100); // Set up live event listeners with run ID isolation const outputUnlisten = await listen(`agent-output:${run!.id}`, (event) => { try { // Skip messages during initial load phase if (isInitialLoadRef.current) { console.log('[AgentRunOutputViewer] Skipping message during initial load'); return; } // Store raw JSONL setRawJsonlOutput(prev => [...prev, event.payload]); // Parse and display const message = JSON.parse(event.payload) as ClaudeStreamMessage; setMessages(prev => [...prev, message]); } catch (err) { console.error("[AgentRunOutputViewer] Failed to parse message:", err, event.payload); } }); const errorUnlisten = await listen(`agent-error:${run!.id}`, (event) => { console.error("[AgentRunOutputViewer] Agent error:", event.payload); setToast({ message: event.payload, type: 'error' }); }); const completeUnlisten = await listen(`agent-complete:${run!.id}`, () => { setToast({ message: 'Agent execution completed', type: 'success' }); // Don't set status here as the parent component should handle it }); const cancelUnlisten = await listen(`agent-cancelled:${run!.id}`, () => { setToast({ message: 'Agent execution was cancelled', type: 'error' }); }); unlistenRefs.current = [outputUnlisten, errorUnlisten, completeUnlisten, cancelUnlisten]; } catch (error) { console.error('[AgentRunOutputViewer] Failed to set up live event listeners:', error); } }; // Copy functionality const handleCopyAsJsonl = async () => { const jsonl = rawJsonlOutput.join('\n'); await navigator.clipboard.writeText(jsonl); setCopyPopoverOpen(false); setToast({ message: 'Output copied as JSONL', type: 'success' }); }; const handleCopyAsMarkdown = async () => { if (!run) return; let markdown = `# Agent Execution: ${run.agent_name}\n\n`; markdown += `**Task:** ${run.task}\n`; markdown += `**Model:** ${run.model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'}\n`; markdown += `**Date:** ${formatISOTimestamp(run.created_at)}\n`; if (run.metrics?.duration_ms) markdown += `**Duration:** ${(run.metrics.duration_ms / 1000).toFixed(2)}s\n`; if (run.metrics?.total_tokens) markdown += `**Total Tokens:** ${run.metrics.total_tokens}\n`; if (run.metrics?.cost_usd) markdown += `**Cost:** $${run.metrics.cost_usd.toFixed(4)} USD\n`; markdown += `\n---\n\n`; for (const msg of messages) { if (msg.type === "system" && msg.subtype === "init") { markdown += `## System Initialization\n\n`; markdown += `- Session ID: \`${msg.session_id || 'N/A'}\`\n`; markdown += `- Model: \`${msg.model || 'default'}\`\n`; if (msg.cwd) markdown += `- Working Directory: \`${msg.cwd}\`\n`; if (msg.tools?.length) markdown += `- Tools: ${msg.tools.join(', ')}\n`; markdown += `\n`; } else if (msg.type === "assistant" && msg.message) { markdown += `## Assistant\n\n`; for (const content of msg.message.content || []) { if (content.type === "text") { markdown += `${content.text}\n\n`; } else if (content.type === "tool_use") { markdown += `### Tool: ${content.name}\n\n`; markdown += `\`\`\`json\n${JSON.stringify(content.input, null, 2)}\n\`\`\`\n\n`; } } if (msg.message.usage) { markdown += `*Tokens: ${msg.message.usage.input_tokens} in, ${msg.message.usage.output_tokens} out*\n\n`; } } else if (msg.type === "user" && msg.message) { markdown += `## User\n\n`; for (const content of msg.message.content || []) { if (content.type === "text") { markdown += `${content.text}\n\n`; } else if (content.type === "tool_result") { markdown += `### Tool Result\n\n`; markdown += `\`\`\`\n${content.content}\n\`\`\`\n\n`; } } } else if (msg.type === "result") { markdown += `## Execution Result\n\n`; if (msg.result) { markdown += `${msg.result}\n\n`; } if (msg.error) { markdown += `**Error:** ${msg.error}\n\n`; } } } await navigator.clipboard.writeText(markdown); setCopyPopoverOpen(false); setToast({ message: 'Output copied as Markdown', type: 'success' }); }; const handleRefresh = async () => { setRefreshing(true); await loadOutput(); setRefreshing(false); }; const handleStop = async () => { if (!run?.id) { console.error('[AgentRunOutputViewer] No run ID available to stop'); return; } try { // Call the API to kill the agent session const success = await api.killAgentSession(run.id); if (success) { console.log(`[AgentRunOutputViewer] Successfully stopped agent session ${run.id}`); setToast({ message: 'Agent execution stopped', type: 'success' }); // Clean up listeners unlistenRefs.current.forEach(unlisten => unlisten()); unlistenRefs.current = []; hasSetupListenersRef.current = false; // Add a message indicating execution was stopped const stopMessage: ClaudeStreamMessage = { type: "result", subtype: "error", is_error: true, result: "Execution stopped by user", duration_ms: 0, usage: { input_tokens: 0, output_tokens: 0 } }; setMessages(prev => [...prev, stopMessage]); // Update the tab status updateTabStatus(tabId, 'idle'); // Refresh the output to get updated status await loadOutput(true); } else { console.warn(`[AgentRunOutputViewer] Failed to stop agent session ${run.id} - it may have already finished`); setToast({ message: 'Failed to stop agent - it may have already finished', type: 'error' }); } } catch (err) { console.error('[AgentRunOutputViewer] Failed to stop agent:', err); setToast({ message: `Failed to stop execution: ${err instanceof Error ? err.message : 'Unknown error'}`, type: 'error' }); } }; const handleScroll = (e: React.UIEvent) => { const target = e.currentTarget; const { scrollTop, scrollHeight, clientHeight } = target; const distanceFromBottom = scrollHeight - scrollTop - clientHeight; setHasUserScrolled(distanceFromBottom > 50); }; // Load output on mount useEffect(() => { if (!run?.id) return; // Check cache immediately for instant display const cached = getCachedOutput(run!.id); if (cached) { const cachedJsonlLines = cached.output.split('\n').filter(line => line.trim()); setRawJsonlOutput(cachedJsonlLines); setMessages(cached.messages); } // Then load fresh data loadOutput(); }, [run?.id]); const displayableMessages = useMemo(() => { return messages.filter((message) => { if (message.isMeta && !message.leafUuid && !message.summary) return false; if (message.type === "user" && message.message) { if (message.isMeta) return false; const msg = message.message; if (!msg.content || (Array.isArray(msg.content) && msg.content.length === 0)) return false; if (Array.isArray(msg.content)) { let hasVisibleContent = false; for (const content of msg.content) { if (content.type === "text") { hasVisibleContent = true; break; } if (content.type === "tool_result") { // Check if this tool result will be displayed as a widget let willBeSkipped = false; if (content.tool_use_id) { // Find the corresponding tool use for (let i = messages.indexOf(message) - 1; i >= 0; i--) { const prevMsg = messages[i]; if (prevMsg.type === 'assistant' && prevMsg.message?.content && Array.isArray(prevMsg.message.content)) { const toolUse = prevMsg.message.content.find((c: any) => c.type === 'tool_use' && c.id === content.tool_use_id); if (toolUse) { const toolName = toolUse.name?.toLowerCase(); const toolsWithWidgets = ['task','edit','multiedit','todowrite','ls','read','glob','bash','write','grep']; if (toolsWithWidgets.includes(toolName) || toolUse.name?.startsWith('mcp__')) { willBeSkipped = true; } break; } } } } if (!willBeSkipped) { hasVisibleContent = true; break; } } } if (!hasVisibleContent) return false; } } return true; }); }, [messages]); const renderIcon = (iconName: string) => { const Icon = AGENT_ICONS[iconName as keyof typeof AGENT_ICONS] || Bot; return ; }; const formatDuration = (ms?: number) => { if (!ms) return "N/A"; const seconds = Math.floor(ms / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; return `${minutes}m ${remainingSeconds}s`; }; const formatTokens = (tokens?: number) => { if (!tokens) return "0"; if (tokens >= 1000) { return `${(tokens / 1000).toFixed(1)}k`; } return tokens.toString(); }; if (!run) { return (

Loading agent run...

); } return ( <>
{renderIcon(run.agent_icon)}
{run.agent_name} {run.status === 'running' && (
Running
)}

{run.task}

{run.model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'}
{formatISOTimestamp(run.created_at)}
{run.metrics?.duration_ms && ( {formatDuration(run.metrics.duration_ms)} )} {run.metrics?.total_tokens && (
{formatTokens(run.metrics.total_tokens)}
)} {run.metrics?.cost_usd && (
${run.metrics.cost_usd.toFixed(4)}
)}
Copy } content={
} open={copyPopoverOpen} onOpenChange={setCopyPopoverOpen} align="end" /> {run.status === 'running' && ( )}
{loading ? (
Loading output...
) : messages.length === 0 ? (

No output available yet

) : (
{displayableMessages.map((message: ClaudeStreamMessage, index: number) => ( ))}
)}
{/* Fullscreen Modal */} {isFullscreen && (
{renderIcon(run.agent_icon)}

{run.agent_name}

{run.task}

Copy Output } content={
} align="end" /> {run.status === 'running' && ( )}
{messages.length === 0 ? (
No output available yet
) : ( <> {displayableMessages.map((message: ClaudeStreamMessage, index: number) => ( ))}
)}
)} {/* Toast Notification */} {toast && ( setToast(null)} /> )} ); } export default AgentRunOutputViewer; ================================================ FILE: src/components/AgentRunView.tsx ================================================ import React, { useState, useEffect } from "react"; import { motion } from "framer-motion"; import { ArrowLeft, Copy, ChevronDown, Clock, Hash, DollarSign, Bot, StopCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; import { Popover } from "@/components/ui/popover"; import { api, type AgentRunWithMetrics } from "@/lib/api"; import { cn } from "@/lib/utils"; import { formatISOTimestamp } from "@/lib/date-utils"; import { StreamMessage } from "./StreamMessage"; import { AGENT_ICONS } from "./CCAgents"; import type { ClaudeStreamMessage } from "./AgentExecution"; import { ErrorBoundary } from "./ErrorBoundary"; interface AgentRunViewProps { /** * The run ID to view */ runId: number; /** * Callback to go back */ onBack: () => void; /** * Optional className for styling */ className?: string; } /** * AgentRunView component for viewing past agent execution details * * @example * setView('list')} /> */ export const AgentRunView: React.FC = ({ runId, onBack, className, }) => { const [run, setRun] = useState(null); const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [copyPopoverOpen, setCopyPopoverOpen] = useState(false); useEffect(() => { loadRun(); }, [runId]); const loadRun = async () => { try { setLoading(true); setError(null); const runData = await api.getAgentRunWithRealTimeMetrics(runId); setRun(runData); // If we have a session_id, try to load from JSONL file first if (runData.session_id && runData.session_id !== '') { try { const history = await api.loadAgentSessionHistory(runData.session_id); // Convert history to messages format const loadedMessages: ClaudeStreamMessage[] = history.map(entry => ({ ...entry, type: entry.type || "assistant" })); setMessages(loadedMessages); return; } catch (err) { console.warn('Failed to load from JSONL, falling back to output field:', err); } } // Fallback: Parse JSONL output from the output field if (runData.output) { const parsedMessages: ClaudeStreamMessage[] = []; const lines = runData.output.split('\n').filter(line => line.trim()); for (const line of lines) { try { const msg = JSON.parse(line) as ClaudeStreamMessage; parsedMessages.push(msg); } catch (err) { console.error("Failed to parse line:", line, err); } } setMessages(parsedMessages); } } catch (err) { console.error("Failed to load run:", err); setError("Failed to load execution details"); } finally { setLoading(false); } }; const handleCopyAsJsonl = async () => { if (!run?.output) return; await navigator.clipboard.writeText(run.output); setCopyPopoverOpen(false); }; const handleCopyAsMarkdown = async () => { if (!run) return; let markdown = `# Agent Run: ${run.agent_name}\n\n`; markdown += `**Task:** ${run.task}\n`; markdown += `**Model:** ${run.model}\n`; markdown += `**Status:** ${run.status}\n`; if (run.metrics) { markdown += `**Tokens:** ${run.metrics.total_tokens || 'N/A'}\n`; markdown += `**Cost:** $${run.metrics.cost_usd?.toFixed(4) || 'N/A'}\n`; } markdown += `**Date:** ${new Date(run.created_at).toISOString()}\n\n`; markdown += `---\n\n`; for (const msg of messages) { if (msg.type === "system" && msg.subtype === "init") { markdown += `## System Initialization\n\n`; markdown += `- Session ID: \`${msg.session_id || 'N/A'}\`\n`; markdown += `- Model: \`${msg.model || 'default'}\`\n`; if (msg.cwd) markdown += `- Working Directory: \`${msg.cwd}\`\n`; if (msg.tools?.length) markdown += `- Tools: ${msg.tools.join(', ')}\n`; markdown += `\n`; } else if (msg.type === "assistant" && msg.message) { markdown += `## Assistant\n\n`; for (const content of msg.message.content || []) { if (content.type === "text") { markdown += `${content.text}\n\n`; } else if (content.type === "tool_use") { markdown += `### Tool: ${content.name}\n\n`; markdown += `\`\`\`json\n${JSON.stringify(content.input, null, 2)}\n\`\`\`\n\n`; } } if (msg.message.usage) { markdown += `*Tokens: ${msg.message.usage.input_tokens} in, ${msg.message.usage.output_tokens} out*\n\n`; } } else if (msg.type === "user" && msg.message) { markdown += `## User\n\n`; for (const content of msg.message.content || []) { if (content.type === "text") { markdown += `${content.text}\n\n`; } else if (content.type === "tool_result") { markdown += `### Tool Result\n\n`; markdown += `\`\`\`\n${content.content}\n\`\`\`\n\n`; } } } else if (msg.type === "result") { markdown += `## Execution Result\n\n`; if (msg.result) { markdown += `${msg.result}\n\n`; } if (msg.error) { markdown += `**Error:** ${msg.error}\n\n`; } } } await navigator.clipboard.writeText(markdown); setCopyPopoverOpen(false); }; const handleStop = async () => { if (!runId) { console.error('[AgentRunView] No run ID available to stop'); return; } try { // Call the API to kill the agent session const success = await api.killAgentSession(runId); if (success) { console.log(`[AgentRunView] Successfully stopped agent session ${runId}`); // Update the run status locally if (run) { setRun({ ...run, status: 'cancelled' }); } // Add a message indicating execution was stopped const stopMessage: ClaudeStreamMessage = { type: "result", subtype: "error", is_error: true, result: "Execution stopped by user", duration_ms: 0, usage: { input_tokens: 0, output_tokens: 0 } }; setMessages(prev => [...prev, stopMessage]); // Reload the run data after a short delay setTimeout(() => { loadRun(); }, 1000); } else { console.warn(`[AgentRunView] Failed to stop agent session ${runId} - it may have already finished`); } } catch (err) { console.error('[AgentRunView] Failed to stop agent:', err); } }; const renderIcon = (iconName: string) => { const Icon = AGENT_ICONS[iconName as keyof typeof AGENT_ICONS] || Bot; return ; }; if (loading) { return (
); } if (error || !run) { return (

{error || "Run not found"}

); } return (
{/* Header */}
{renderIcon(run.agent_icon)}

{run.agent_name}

Execution History

{run?.status === 'running' && ( )} Copy Output } content={
} open={copyPopoverOpen} onOpenChange={setCopyPopoverOpen} align="end" />
{/* Run Details */}

Task:

{run.task}

{run.model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'}
{formatISOTimestamp(run.created_at)}
{run.metrics?.duration_ms && (
{(run.metrics.duration_ms / 1000).toFixed(2)}s
)} {run.metrics?.total_tokens && (
{run.metrics.total_tokens} tokens
)} {run.metrics?.cost_usd && (
${run.metrics.cost_usd.toFixed(4)}
)}
{/* Output Display */}
{messages.map((message, index) => ( ))}
); }; ================================================ FILE: src/components/AgentRunsList.tsx ================================================ import React, { useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { Play, Clock, Hash, Bot } from "lucide-react"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Pagination } from "@/components/ui/pagination"; import { cn } from "@/lib/utils"; import { formatISOTimestamp } from "@/lib/date-utils"; import type { AgentRunWithMetrics } from "@/lib/api"; import { AGENT_ICONS } from "./CCAgents"; import { useTabState } from "@/hooks/useTabState"; interface AgentRunsListProps { /** * Array of agent runs to display */ runs: AgentRunWithMetrics[]; /** * Callback when a run is clicked */ onRunClick?: (run: AgentRunWithMetrics) => void; /** * Optional className for styling */ className?: string; } const ITEMS_PER_PAGE = 5; /** * AgentRunsList component - Displays a paginated list of agent execution runs * * @example * console.log('Selected:', run)} * /> */ export const AgentRunsList: React.FC = ({ runs, onRunClick, className, }) => { const [currentPage, setCurrentPage] = useState(1); const { createAgentTab } = useTabState(); // Calculate pagination const totalPages = Math.ceil(runs.length / ITEMS_PER_PAGE); const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; const endIndex = startIndex + ITEMS_PER_PAGE; const currentRuns = runs.slice(startIndex, endIndex); // Reset to page 1 if runs change React.useEffect(() => { setCurrentPage(1); }, [runs.length]); const renderIcon = (iconName: string) => { const Icon = AGENT_ICONS[iconName as keyof typeof AGENT_ICONS] || Bot; return ; }; const formatDuration = (ms?: number) => { if (!ms) return "N/A"; const seconds = Math.floor(ms / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; return `${minutes}m ${remainingSeconds}s`; }; const formatTokens = (tokens?: number) => { if (!tokens) return "0"; if (tokens >= 1000) { return `${(tokens / 1000).toFixed(1)}k`; } return tokens.toString(); }; const handleRunClick = (run: AgentRunWithMetrics) => { // If there's a callback, use it (for full-page navigation) if (onRunClick) { onRunClick(run); } else if (run.id) { // Otherwise, open in new tab createAgentTab(run.id.toString(), run.agent_name); } }; if (runs.length === 0) { return (

No execution history yet

); } return ( <>
{currentRuns.map((run, index) => ( handleRunClick(run)} >
{renderIcon(run.agent_icon)}

{run.agent_name}

{run.status === "running" && (
Running
)}

{run.task}

{formatISOTimestamp(run.created_at)}
{run.metrics?.duration_ms && ( {formatDuration(run.metrics.duration_ms)} )} {run.metrics?.total_tokens && (
{formatTokens(run.metrics.total_tokens)}
)}
{run.status === "completed" ? "Completed" : run.status === "running" ? "Running" : run.status === "failed" ? "Failed" : "Pending"}
))}
{/* Pagination */} {totalPages > 1 && (
)}
); }; ================================================ FILE: src/components/Agents.tsx ================================================ import React, { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Bot, Loader2, Play, Clock, CheckCircle, XCircle, Trash2, Import, ChevronDown, ChevronRight, FileJson, Globe, Download, Plus, History, Edit } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card } from '@/components/ui/card'; import { Toast } from '@/components/ui/toast'; import { api, type Agent, type AgentRunWithMetrics } from '@/lib/api'; import { open as openDialog, save } from '@tauri-apps/plugin-dialog'; import { invoke } from '@tauri-apps/api/core'; import { GitHubAgentBrowser } from '@/components/GitHubAgentBrowser'; import { CreateAgent } from '@/components/CreateAgent'; import { useTabState } from '@/hooks/useTabState'; export const Agents: React.FC = () => { const [activeTab, setActiveTab] = useState('agents'); const [showCreateAgent, setShowCreateAgent] = useState(false); const [editingAgent, setEditingAgent] = useState(null); const [agents, setAgents] = useState([]); const [runningAgents, setRunningAgents] = useState([]); const [loading, setLoading] = useState(true); const [agentToDelete, setAgentToDelete] = useState(null); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); const [showGitHubBrowser, setShowGitHubBrowser] = useState(false); const { createAgentTab } = useTabState(); // Load agents on mount useEffect(() => { loadAgents(); loadRunningAgents(); }, []); // Refresh running agents periodically useEffect(() => { const interval = setInterval(() => { loadRunningAgents(); }, 3000); // Refresh every 3 seconds return () => clearInterval(interval); }, []); const loadAgents = async () => { try { setLoading(true); const agents = await api.listAgents(); setAgents(agents); } catch (error) { console.error('Failed to load agents:', error); setToast({ message: 'Failed to load agents', type: 'error' }); } finally { setLoading(false); } }; const loadRunningAgents = async () => { try { const runs = await api.listAgentRunsWithMetrics(); setRunningAgents(runs); } catch (error) { console.error('Failed to load running agents:', error); } }; const handleRunAgent = async (agent: Agent) => { if (!agent.id) { setToast({ message: 'Agent ID is missing', type: 'error' }); return; } // Import the dialog function const { open } = await import('@tauri-apps/plugin-dialog'); try { // Prompt user to select a project directory const projectPath = await open({ directory: true, multiple: false, title: `Select project directory for ${agent.name}` }); if (!projectPath) { // User cancelled return; } // Dispatch event to open agent execution in a new tab const tabId = `agent-exec-${agent.id}-${Date.now()}`; window.dispatchEvent(new CustomEvent('open-agent-execution', { detail: { agent, tabId, projectPath } })); setToast({ message: `Opening agent: ${agent.name}`, type: 'success' }); } catch (error) { console.error('Failed to open agent:', error); setToast({ message: `Failed to open agent: ${agent.name}`, type: 'error' }); } }; const handleDeleteAgent = async () => { if (!agentToDelete || !agentToDelete.id) return; try { await api.deleteAgent(agentToDelete.id); setToast({ message: `Deleted agent: ${agentToDelete.name}`, type: 'success' }); setAgents(prev => prev.filter(a => a.id !== agentToDelete.id)); setShowDeleteDialog(false); setAgentToDelete(null); } catch (error) { console.error('Failed to delete agent:', error); setToast({ message: `Failed to delete agent: ${agentToDelete.name}`, type: 'error' }); } }; const handleImportFromFile = async () => { try { const selected = await openDialog({ filters: [ { name: 'opcode Agent', extensions: ['opcode.json', 'json'] }, { name: 'All Files', extensions: ['*'] } ], multiple: false, }); if (selected) { const importedAgent = await api.importAgentFromFile(selected as string); setToast({ message: `Imported agent: ${importedAgent.name}`, type: 'success' }); loadAgents(); } } catch (error) { console.error('Failed to import agent:', error); setToast({ message: 'Failed to import agent', type: 'error' }); } }; const handleExportAgent = async (agent: Agent) => { try { const path = await save({ defaultPath: `${agent.name.toLowerCase().replace(/\s+/g, '-')}.opcode.json`, filters: [ { name: 'opcode Agent', extensions: ['opcode.json'] } ] }); if (path && agent.id) { await invoke('export_agent_to_file', { id: agent.id, filePath: path }); setToast({ message: `Exported agent: ${agent.name}`, type: 'success' }); } } catch (error) { console.error('Failed to export agent:', error); setToast({ message: 'Failed to export agent', type: 'error' }); } }; const getStatusIcon = (status: string) => { switch (status) { case 'running': return ; case 'completed': return ; case 'failed': return ; default: return ; } }; // Show CreateAgent component if creating if (showCreateAgent) { return ( setShowCreateAgent(false)} onAgentCreated={() => { setShowCreateAgent(false); loadAgents(); // Reload agents after creation }} /> ); } // Show CreateAgent component in edit mode if (editingAgent) { return ( setEditingAgent(null)} onAgentCreated={() => { setEditingAgent(null); loadAgents(); // Reload agents after update }} /> ); } return (
{/* Header */}

Agents

Manage your Claude Code agents

From File setShowGitHubBrowser(true)}> From GitHub
{/* Toast notifications */} {toast && ( setToast(null)} /> )} {showGitHubBrowser && ( setShowGitHubBrowser(false)} onImportSuccess={() => { loadAgents(); setShowGitHubBrowser(false); setToast({ message: 'Agent imported successfully', type: 'success' }); }} /> )} {showDeleteDialog && agentToDelete && ( setShowDeleteDialog(false)} > e.stopPropagation()} >

Delete Agent

Are you sure you want to delete "{agentToDelete.name}"? This action cannot be undone.

)}
{/* Content */}
Agents ({agents.length}) History ({runningAgents.length}) {loading ? (
) : agents.length === 0 ? (

No Agents Yet

Create your first agent to get started

) : (
{agents.map((agent) => (

{agent.name}

setEditingAgent(agent)}> Edit handleRunAgent(agent)}> Run handleExportAgent(agent)}> Export { setAgentToDelete(agent); setShowDeleteDialog(true); }} className="text-destructive" > Delete

No description provided

v1.0.0
))}
)}
{runningAgents.length === 0 ? (

No Agent History

Run an agent to see it here

) : (
{runningAgents.map((run) => (
{getStatusIcon(run.status)}

{run.agent_name}

{run.status}
Started:

{new Date(run.created_at).toLocaleString()}

Duration:

{run.metrics?.duration_ms ? `${(run.metrics.duration_ms / 1000).toFixed(1)}s` : run.duration_ms ? `${(run.duration_ms / 1000).toFixed(1)}s` : '—'}

Tokens:

{run.metrics?.total_tokens ? run.metrics.total_tokens.toLocaleString() : run.total_tokens ? run.total_tokens.toLocaleString() : '—'}

{run.status === 'failed' && (
Agent execution failed
)}
))}
)}
); }; ================================================ FILE: src/components/AgentsModal.tsx ================================================ import React, { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Bot, Plus, Loader2, Play, Clock, CheckCircle, XCircle, Trash2, Import, ChevronDown, FileJson, Globe, Download } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Toast } from '@/components/ui/toast'; import { api, type Agent, type AgentRunWithMetrics } from '@/lib/api'; import { useTabState } from '@/hooks/useTabState'; import { formatISOTimestamp } from '@/lib/date-utils'; import { open as openDialog, save } from '@tauri-apps/plugin-dialog'; import { invoke } from '@tauri-apps/api/core'; import { GitHubAgentBrowser } from '@/components/GitHubAgentBrowser'; interface AgentsModalProps { open: boolean; onOpenChange: (open: boolean) => void; } export const AgentsModal: React.FC = ({ open, onOpenChange }) => { const [activeTab, setActiveTab] = useState('agents'); const [agents, setAgents] = useState([]); const [runningAgents, setRunningAgents] = useState([]); const [loading, setLoading] = useState(true); const [agentToDelete, setAgentToDelete] = useState(null); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); const [showGitHubBrowser, setShowGitHubBrowser] = useState(false); const { createAgentTab, createCreateAgentTab } = useTabState(); // Load agents when modal opens useEffect(() => { if (open) { loadAgents(); loadRunningAgents(); } }, [open]); // Refresh running agents periodically useEffect(() => { if (!open) return; const interval = setInterval(() => { loadRunningAgents(); }, 3000); // Refresh every 3 seconds return () => clearInterval(interval); }, [open]); const loadAgents = async () => { try { setLoading(true); const agentList = await api.listAgents(); setAgents(agentList); } catch (error) { console.error('Failed to load agents:', error); } finally { setLoading(false); } }; const loadRunningAgents = async () => { try { const runs = await api.listRunningAgentSessions(); const agentRuns = runs.map(run => ({ id: run.id, agent_id: run.agent_id, agent_name: run.agent_name, task: run.task, model: run.model, status: 'running' as const, created_at: run.created_at, project_path: run.project_path, } as AgentRunWithMetrics)); setRunningAgents(agentRuns); } catch (error) { console.error('Failed to load running agents:', error); } }; const handleRunAgent = async (agent: Agent) => { // Open directory picker for project path const { open } = await import('@tauri-apps/plugin-dialog'); try { const projectPath = await open({ directory: true, multiple: false, title: `Select project directory for ${agent.name}` }); if (!projectPath) { // User cancelled return; } // Create a new agent execution tab const tabId = `agent-exec-${agent.id}-${Date.now()}`; // Close modal onOpenChange(false); // Dispatch event to open agent execution in the new tab with project path window.dispatchEvent(new CustomEvent('open-agent-execution', { detail: { agent, tabId, projectPath } })); } catch (error) { console.error('Failed to run agent:', error); setToast({ message: `Failed to run agent: ${agent.name}`, type: 'error' }); } }; const handleDeleteAgent = async (agent: Agent) => { setAgentToDelete(agent); setShowDeleteDialog(true); }; const confirmDelete = async () => { if (!agentToDelete?.id) return; try { await api.deleteAgent(agentToDelete.id); loadAgents(); // Refresh the list setShowDeleteDialog(false); setAgentToDelete(null); } catch (error) { console.error('Failed to delete agent:', error); } }; const handleOpenAgentRun = (run: AgentRunWithMetrics) => { // Create new tab for this agent run createAgentTab(run.id!.toString(), run.agent_name); onOpenChange(false); }; const handleCreateAgent = () => { // Close modal and create new tab onOpenChange(false); createCreateAgentTab(); }; const handleImportFromFile = async () => { try { const filePath = await openDialog({ multiple: false, filters: [{ name: 'JSON', extensions: ['json'] }] }); if (filePath) { const agent = await api.importAgentFromFile(filePath as string); loadAgents(); // Refresh list setToast({ message: `Agent "${agent.name}" imported successfully`, type: "success" }); } } catch (error) { console.error('Failed to import agent:', error); setToast({ message: "Failed to import agent", type: "error" }); } }; const handleImportFromGitHub = () => { setShowGitHubBrowser(true); }; const handleExportAgent = async (agent: Agent) => { try { const exportData = await api.exportAgent(agent.id!); const filePath = await save({ defaultPath: `${agent.name.toLowerCase().replace(/\s+/g, '-')}.json`, filters: [{ name: 'JSON', extensions: ['json'] }] }); if (filePath) { await invoke('write_file', { path: filePath, content: JSON.stringify(exportData, null, 2) }); setToast({ message: "Agent exported successfully", type: "success" }); } } catch (error) { console.error('Failed to export agent:', error); setToast({ message: "Failed to export agent", type: "error" }); } }; const getStatusIcon = (status: string) => { switch (status) { case 'running': return ; case 'completed': return ; case 'failed': return ; default: return ; } }; return ( <> Agent Management Create new agents or manage running agent executions Available Agents Running Agents {runningAgents.length > 0 && ( {runningAgents.length} )}
{/* Action buttons at the top */}
From File From GitHub
{loading ? (
) : agents.length === 0 ? (

No agents available

Create your first agent to get started

) : (
{agents.map((agent) => (

{agent.name}

{agent.default_task && (

{agent.default_task}

)}
))}
)}
{runningAgents.length === 0 ? (

No running agents

Agent executions will appear here when started

) : (
{runningAgents.map((run) => ( handleOpenAgentRun(run)} >

{getStatusIcon(run.status)} {run.agent_name}

{run.task}

Started: {formatISOTimestamp(run.created_at)} {run.model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'}
))}
)}
{/* Delete Confirmation Dialog */} Delete Agent Are you sure you want to delete "{agentToDelete?.name}"? This action cannot be undone.
{/* GitHub Agent Browser */} setShowGitHubBrowser(false)} onImportSuccess={() => { setShowGitHubBrowser(false); loadAgents(); // Refresh the agents list setToast({ message: "Agent imported successfully", type: "success" }); }} /> {/* Toast notifications */} {toast && ( setToast(null)} /> )} ); }; export default AgentsModal; ================================================ FILE: src/components/AnalyticsConsent.tsx ================================================ import React, { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { BarChart3, Shield, X, Check, Info } from 'lucide-react'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { analytics } from '@/lib/analytics'; import { cn } from '@/lib/utils'; interface AnalyticsConsentProps { open?: boolean; onOpenChange?: (open: boolean) => void; onComplete?: () => void; } export const AnalyticsConsent: React.FC = ({ open: controlledOpen, onOpenChange, onComplete, }) => { const [internalOpen, setInternalOpen] = useState(false); const [hasShownConsent, setHasShownConsent] = useState(false); const isControlled = controlledOpen !== undefined; const open = isControlled ? controlledOpen : internalOpen; useEffect(() => { // Check if we should show the consent dialog const checkConsent = async () => { await analytics.initialize(); const settings = analytics.getSettings(); if (!settings?.hasConsented && !hasShownConsent) { if (!isControlled) { setInternalOpen(true); } setHasShownConsent(true); } }; checkConsent(); }, [isControlled, hasShownConsent]); const handleOpenChange = (newOpen: boolean) => { if (isControlled && onOpenChange) { onOpenChange(newOpen); } else { setInternalOpen(newOpen); } }; const handleAccept = async () => { await analytics.enable(); handleOpenChange(false); onComplete?.(); }; const handleDecline = async () => { await analytics.disable(); handleOpenChange(false); onComplete?.(); }; return (
Help Improve opcode
We'd like to collect anonymous usage data to improve your experience.

What we collect:

  • • Feature usage (which tools and commands you use)
  • • Performance metrics (app speed and reliability)
  • • Error reports (to fix bugs and improve stability)
  • • General usage patterns (session frequency and duration)

Your privacy is protected:

  • • No personal information is collected
  • • No file contents, paths, or project names
  • • No API keys or sensitive data
  • • Completely anonymous with random IDs
  • • You can opt-out anytime in Settings

This data helps us understand which features are most valuable, identify performance issues, and prioritize improvements. Your choice won't affect any functionality.

); }; interface AnalyticsConsentBannerProps { className?: string; } export const AnalyticsConsentBanner: React.FC = ({ className, }) => { const [visible, setVisible] = useState(false); const [hasChecked, setHasChecked] = useState(false); useEffect(() => { const checkConsent = async () => { if (hasChecked) return; await analytics.initialize(); const settings = analytics.getSettings(); if (!settings?.hasConsented) { setVisible(true); } setHasChecked(true); }; // Delay banner appearance for better UX const timer = setTimeout(checkConsent, 2000); return () => clearTimeout(timer); }, [hasChecked]); const handleAccept = async () => { await analytics.enable(); setVisible(false); }; const handleDecline = async () => { await analytics.disable(); setVisible(false); }; return ( {visible && (

Help improve opcode

We collect anonymous usage data to improve your experience. No personal data is collected.

)}
); }; ================================================ FILE: src/components/AnalyticsErrorBoundary.tsx ================================================ import React, { Component, ErrorInfo, ReactNode } from 'react'; import { eventBuilders, analytics } from '@/lib/analytics'; interface Props { children: ReactNode; fallback?: (error: Error, reset: () => void) => ReactNode; } interface State { hasError: boolean; error: Error | null; } /** * Error boundary component that tracks UI errors to analytics */ export class AnalyticsErrorBoundary extends Component { constructor(props: Props) { super(props); this.state = { hasError: false, error: null }; } static getDerivedStateFromError(error: Error): State { return { hasError: true, error }; } componentDidCatch(error: Error, errorInfo: ErrorInfo) { // Track UI error to analytics const event = eventBuilders.uiError({ component_name: errorInfo.componentStack?.split('\n')[0] || 'Unknown', error_type: error.name || 'UnknownError', user_action: undefined, // Could be enhanced with context }); analytics.track(event.event, event.properties); // Log to console for debugging console.error('UI Error caught by boundary:', error, errorInfo); } reset = () => { this.setState({ hasError: false, error: null }); }; render() { if (this.state.hasError && this.state.error) { // Use custom fallback if provided if (this.props.fallback) { return this.props.fallback(this.state.error, this.reset); } // Default fallback UI return (

Something went wrong

{this.state.error.message}

); } return this.props.children; } } /** * Hook to wrap components with analytics error tracking */ export function withAnalyticsErrorBoundary

( Component: React.ComponentType

, fallback?: (error: Error, reset: () => void) => ReactNode ) { return (props: P) => ( ); } ================================================ FILE: src/components/App.cleaned.tsx ================================================ import { useState, useEffect } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { OutputCacheProvider } from "@/lib/outputCache"; import { TabProvider } from "@/contexts/TabContext"; import { NFOCredits } from "@/components/NFOCredits"; import { ClaudeBinaryDialog } from "@/components/ClaudeBinaryDialog"; import { Toast, ToastContainer } from "@/components/ui/toast"; import { TabManager } from "@/components/TabManager"; import { TabContent } from "@/components/TabContent"; import { AgentsModal } from "@/components/AgentsModal"; import { CustomTitlebar } from "@/components/CustomTitlebar"; import { useTabState } from "@/hooks/useTabState"; /** * AppContent component - Contains the main app logic, wrapped by providers */ function AppContent() { const { } = useTabState(); const [showNFO, setShowNFO] = useState(false); const [showClaudeBinaryDialog, setShowClaudeBinaryDialog] = useState(false); const [toast, setToast] = useState<{ message: string; type: "success" | "error" | "info" } | null>(null); const [showAgentsModal, setShowAgentsModal] = useState(false); const [, setClaudeExecutableExists] = useState(true); // Keyboard shortcuts for tab navigation useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; const modKey = isMac ? e.metaKey : e.ctrlKey; if (modKey) { switch (e.key) { case 't': e.preventDefault(); window.dispatchEvent(new CustomEvent('create-chat-tab')); break; case 'w': e.preventDefault(); window.dispatchEvent(new CustomEvent('close-current-tab')); break; case 'Tab': e.preventDefault(); if (e.shiftKey) { window.dispatchEvent(new CustomEvent('switch-to-previous-tab')); } else { window.dispatchEvent(new CustomEvent('switch-to-next-tab')); } break; default: // Handle number keys 1-9 const num = parseInt(e.key); if (!isNaN(num) && num >= 1 && num <= 9) { e.preventDefault(); window.dispatchEvent(new CustomEvent('switch-to-tab', { detail: num - 1 })); } break; } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, []); // Check if Claude executable exists on mount useEffect(() => { const checkClaudeExecutable = async () => { try { // Check if claude executable exists - method not available in API const exists = true; // Default to true for now if (!exists) { setShowClaudeBinaryDialog(true); } } catch (error) { console.error("Error checking Claude executable:", error); } }; checkClaudeExecutable(); }, []); // Custom event handlers useEffect(() => { const handleCreateProjectTab = () => { window.dispatchEvent(new CustomEvent('create-project-tab')); }; const handleShowNFO = () => setShowNFO(true); const handleShowAgents = () => setShowAgentsModal(true); const projectButton = document.getElementById('create-project-tab-btn'); if (projectButton) { projectButton.addEventListener('click', handleCreateProjectTab); } // Listen for custom events to show modals window.addEventListener('show-nfo', handleShowNFO); window.addEventListener('show-agents-modal', handleShowAgents); return () => { if (projectButton) { projectButton.removeEventListener('click', handleCreateProjectTab); } window.removeEventListener('show-nfo', handleShowNFO); window.removeEventListener('show-agents-modal', handleShowAgents); }; }, []); return ( {/* Custom Titlebar */} { // Open settings tab or modal window.dispatchEvent(new CustomEvent('create-settings-tab')); }} onAgentsClick={() => {}} /> {/* Tab-based interface */}

{/* Global Modals */} {showNFO && setShowNFO(false)} />} { setClaudeExecutableExists(true); setToast({ message: "Claude binary path set successfully", type: "success" }); }} onError={(message) => { setToast({ message, type: "error" }); }} /> {/* Toast Container */} {toast && ( setToast(null)} /> )} ); } /** * App component - Main entry point with providers */ function App() { return ( ); } export default App; ================================================ FILE: src/components/CCAgents.tsx ================================================ import React, { useState, useEffect } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { Plus, Edit, Trash2, Play, Bot, ArrowLeft, History, Download, Upload, Globe, FileJson, ChevronDown } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardFooter } from "@/components/ui/card"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { api, type Agent, type AgentRunWithMetrics } from "@/lib/api"; import { save, open } from "@tauri-apps/plugin-dialog"; import { invoke } from "@tauri-apps/api/core"; import { cn } from "@/lib/utils"; import { Toast, ToastContainer } from "@/components/ui/toast"; import { CreateAgent } from "./CreateAgent"; import { AgentExecution } from "./AgentExecution"; import { AgentRunsList } from "./AgentRunsList"; import { GitHubAgentBrowser } from "./GitHubAgentBrowser"; import { ICON_MAP } from "./IconPicker"; interface CCAgentsProps { /** * Callback to go back to the main view */ onBack: () => void; /** * Optional className for styling */ className?: string; } // Available icons for agents - now using all icons from IconPicker export const AGENT_ICONS = ICON_MAP; export type AgentIconName = keyof typeof AGENT_ICONS; /** * CCAgents component for managing Claude Code agents * * @example * setView('home')} /> */ export const CCAgents: React.FC = ({ onBack, className }) => { const [agents, setAgents] = useState([]); const [runs, setRuns] = useState([]); const [loading, setLoading] = useState(true); const [runsLoading, setRunsLoading] = useState(false); const [error, setError] = useState(null); const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); const [currentPage, setCurrentPage] = useState(1); const [view, setView] = useState<"list" | "create" | "edit" | "execute">("list"); const [selectedAgent, setSelectedAgent] = useState(null); // const [selectedRunId, setSelectedRunId] = useState(null); const [showGitHubBrowser, setShowGitHubBrowser] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [agentToDelete, setAgentToDelete] = useState(null); const [isDeleting, setIsDeleting] = useState(false); const AGENTS_PER_PAGE = 9; // 3x3 grid useEffect(() => { loadAgents(); loadRuns(); }, []); const loadAgents = async () => { try { setLoading(true); setError(null); const agentsList = await api.listAgents(); setAgents(agentsList); } catch (err) { console.error("Failed to load agents:", err); setError("Failed to load agents"); setToast({ message: "Failed to load agents", type: "error" }); } finally { setLoading(false); } }; const loadRuns = async () => { try { setRunsLoading(true); const runsList = await api.listAgentRuns(); setRuns(runsList); } catch (err) { console.error("Failed to load runs:", err); } finally { setRunsLoading(false); } }; /** * Initiates the delete agent process by showing the confirmation dialog * @param agent - The agent to be deleted */ const handleDeleteAgent = (agent: Agent) => { setAgentToDelete(agent); setShowDeleteDialog(true); }; /** * Confirms and executes the agent deletion * Only called when user explicitly confirms the deletion */ const confirmDeleteAgent = async () => { if (!agentToDelete?.id) return; try { setIsDeleting(true); await api.deleteAgent(agentToDelete.id); setToast({ message: "Agent deleted successfully", type: "success" }); await loadAgents(); await loadRuns(); // Reload runs as they might be affected } catch (err) { console.error("Failed to delete agent:", err); setToast({ message: "Failed to delete agent", type: "error" }); } finally { setIsDeleting(false); setShowDeleteDialog(false); setAgentToDelete(null); } }; /** * Cancels the delete operation and closes the dialog */ const cancelDeleteAgent = () => { setShowDeleteDialog(false); setAgentToDelete(null); }; const handleEditAgent = (agent: Agent) => { setSelectedAgent(agent); setView("edit"); }; const handleExecuteAgent = (agent: Agent) => { setSelectedAgent(agent); setView("execute"); }; const handleAgentCreated = async () => { setView("list"); await loadAgents(); setToast({ message: "Agent created successfully", type: "success" }); }; const handleAgentUpdated = async () => { setView("list"); await loadAgents(); setToast({ message: "Agent updated successfully", type: "success" }); }; // const handleRunClick = (run: AgentRunWithMetrics) => { // if (run.id) { // setSelectedRunId(run.id); // setView("viewRun"); // } // }; const handleExecutionComplete = async () => { // Reload runs when returning from execution await loadRuns(); }; const handleExportAgent = async (agent: Agent) => { try { // Show native save dialog const filePath = await save({ defaultPath: `${agent.name.toLowerCase().replace(/\s+/g, '-')}.opcode.json`, filters: [{ name: 'opcode Agent', extensions: ['opcode.json'] }] }); if (!filePath) { // User cancelled the dialog return; } // Export the agent to the selected file await invoke('export_agent_to_file', { id: agent.id!, filePath }); setToast({ message: `Agent "${agent.name}" exported successfully`, type: "success" }); } catch (err) { console.error("Failed to export agent:", err); setToast({ message: "Failed to export agent", type: "error" }); } }; const handleImportAgent = async () => { try { // Show native open dialog const filePath = await open({ multiple: false, filters: [{ name: 'opcode Agent', extensions: ['opcode.json', 'json'] }] }); if (!filePath) { // User cancelled the dialog return; } // Import the agent from the selected file await api.importAgentFromFile(filePath as string); setToast({ message: "Agent imported successfully", type: "success" }); await loadAgents(); } catch (err) { console.error("Failed to import agent:", err); const errorMessage = err instanceof Error ? err.message : "Failed to import agent"; setToast({ message: errorMessage, type: "error" }); } }; // Pagination calculations const totalPages = Math.ceil(agents.length / AGENTS_PER_PAGE); const startIndex = (currentPage - 1) * AGENTS_PER_PAGE; const paginatedAgents = agents.slice(startIndex, startIndex + AGENTS_PER_PAGE); const renderIcon = (iconName: string) => { const Icon = AGENT_ICONS[iconName as AgentIconName] || AGENT_ICONS.bot; return ; }; if (view === "create") { return ( setView("list")} onAgentCreated={handleAgentCreated} /> ); } if (view === "edit" && selectedAgent) { return ( setView("list")} onAgentCreated={handleAgentUpdated} /> ); } if (view === "execute" && selectedAgent) { return ( { setView("list"); handleExecutionComplete(); }} /> ); } // Removed viewRun case - now using modal preview in AgentRunsList return (
{/* Header */}

CC Agents

Manage your Claude Code agents

From File setShowGitHubBrowser(true)}> From GitHub
{/* Error display */} {error && ( {error} )} {/* Main Content */}
{/* Agents Grid */}
{loading ? (
) : agents.length === 0 ? (

No agents yet

Create your first CC Agent to get started

) : ( <>
{paginatedAgents.map((agent, index) => (
{renderIcon(agent.icon)}

{agent.name}

Created: {new Date(agent.created_at).toLocaleDateString()}

))}
{/* Pagination */} {totalPages > 1 && (
Page {currentPage} of {totalPages}
)} )}
{/* Execution History */} {!loading && agents.length > 0 && (

Recent Executions

{runsLoading ? (
) : ( )}
)}
{/* Toast Notification */} {toast && ( setToast(null)} /> )} {/* GitHub Agent Browser */} setShowGitHubBrowser(false)} onImportSuccess={async () => { setShowGitHubBrowser(false); await loadAgents(); setToast({ message: "Agent imported successfully from GitHub", type: "success" }); }} /> {/* Delete Confirmation Dialog */} Delete Agent Are you sure you want to delete the agent "{agentToDelete?.name}"? This action cannot be undone and will permanently remove the agent and all its associated data.
); }; ================================================ FILE: src/components/CheckpointSettings.tsx ================================================ import React, { useState, useEffect } from "react"; import { motion } from "framer-motion"; import { Wrench, Save, Trash2, HardDrive, AlertCircle, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { SelectComponent, type SelectOption } from "@/components/ui/select"; import { Input } from "@/components/ui/input"; import { Card } from "@/components/ui/card"; import { api, type CheckpointStrategy } from "@/lib/api"; import { cn } from "@/lib/utils"; interface CheckpointSettingsProps { sessionId: string; projectId: string; projectPath: string; onClose?: () => void; className?: string; } /** * CheckpointSettings component for managing checkpoint configuration * * @example * */ export const CheckpointSettings: React.FC = ({ sessionId, projectId, projectPath, className, }) => { const [autoCheckpointEnabled, setAutoCheckpointEnabled] = useState(true); const [checkpointStrategy, setCheckpointStrategy] = useState("smart"); const [totalCheckpoints, setTotalCheckpoints] = useState(0); const [keepCount, setKeepCount] = useState(10); const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const strategyOptions: SelectOption[] = [ { value: "manual", label: "Manual Only" }, { value: "per_prompt", label: "After Each Prompt" }, { value: "per_tool_use", label: "After Tool Use" }, { value: "smart", label: "Smart (Recommended)" }, ]; useEffect(() => { loadSettings(); }, [sessionId, projectId, projectPath]); const loadSettings = async () => { try { setIsLoading(true); setError(null); const settings = await api.getCheckpointSettings(sessionId, projectId, projectPath); setAutoCheckpointEnabled(settings.auto_checkpoint_enabled); setCheckpointStrategy(settings.checkpoint_strategy); setTotalCheckpoints(settings.total_checkpoints); } catch (err) { console.error("Failed to load checkpoint settings:", err); setError("Failed to load checkpoint settings"); } finally { setIsLoading(false); } }; const handleSaveSettings = async () => { try { setIsSaving(true); setError(null); setSuccessMessage(null); await api.updateCheckpointSettings( sessionId, projectId, projectPath, autoCheckpointEnabled, checkpointStrategy ); setSuccessMessage("Settings saved successfully"); setTimeout(() => setSuccessMessage(null), 3000); } catch (err) { console.error("Failed to save checkpoint settings:", err); setError("Failed to save checkpoint settings"); } finally { setIsSaving(false); } }; const handleCleanup = async () => { try { setIsLoading(true); setError(null); setSuccessMessage(null); const removed = await api.cleanupOldCheckpoints( sessionId, projectId, projectPath, keepCount ); setSuccessMessage(`Removed ${removed} old checkpoints`); setTimeout(() => setSuccessMessage(null), 3000); // Reload settings to get updated count await loadSettings(); } catch (err) { console.error("Failed to cleanup checkpoints:", err); setError("Failed to cleanup checkpoints"); } finally { setIsLoading(false); } }; return ( {/* Header */}

Checkpoint Settings

Manage session checkpoints and recovery

{/* Experimental Feature Warning */}

Experimental Feature

Checkpointing may affect directory structure or cause data loss. Use with caution.

{error && (
{error}
)} {successMessage && ( {successMessage} )} {/* Main Settings Card */} {/* Auto-checkpoint toggle */}

Automatically create checkpoints based on the selected strategy

{/* Checkpoint strategy */}
setCheckpointStrategy(value as CheckpointStrategy)} options={strategyOptions} disabled={isLoading || !autoCheckpointEnabled} />

{checkpointStrategy === "manual" && "Checkpoints will only be created manually"} {checkpointStrategy === "per_prompt" && "A checkpoint will be created after each user prompt"} {checkpointStrategy === "per_tool_use" && "A checkpoint will be created after each tool use"} {checkpointStrategy === "smart" && "Checkpoints will be created after destructive operations"}

{/* Save button */}
{/* Storage Management Card */}

Total checkpoints: {totalCheckpoints}

{/* Cleanup settings */}
setKeepCount(parseInt(e.target.value) || 10)} disabled={isLoading} className="flex-1 h-9" />

Remove old checkpoints, keeping only the most recent {keepCount}

); }; ================================================ FILE: src/components/ClaudeBinaryDialog.tsx ================================================ import { useState, useEffect } from "react"; import { api, type ClaudeInstallation } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { ExternalLink, FileQuestion, Terminal, AlertCircle, Loader2 } from "lucide-react"; import { ClaudeVersionSelector } from "./ClaudeVersionSelector"; interface ClaudeBinaryDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onSuccess: () => void; onError: (message: string) => void; } export function ClaudeBinaryDialog({ open, onOpenChange, onSuccess, onError }: ClaudeBinaryDialogProps) { const [selectedInstallation, setSelectedInstallation] = useState(null); const [isValidating, setIsValidating] = useState(false); const [hasInstallations, setHasInstallations] = useState(true); const [checkingInstallations, setCheckingInstallations] = useState(true); useEffect(() => { if (open) { checkInstallations(); } }, [open]); const checkInstallations = async () => { try { setCheckingInstallations(true); const installations = await api.listClaudeInstallations(); setHasInstallations(installations.length > 0); } catch (error) { // If the API call fails, it means no installations found setHasInstallations(false); } finally { setCheckingInstallations(false); } }; const handleSave = async () => { if (!selectedInstallation) { onError("Please select a Claude installation"); return; } setIsValidating(true); try { await api.setClaudeBinaryPath(selectedInstallation.path); onSuccess(); onOpenChange(false); } catch (error) { console.error("Failed to save Claude binary path:", error); onError(error instanceof Error ? error.message : "Failed to save Claude binary path"); } finally { setIsValidating(false); } }; return ( Select Claude Code Installation {checkingInstallations ? (
Searching for Claude installations...
) : hasInstallations ? (

Multiple Claude Code installations were found on your system. Please select which one you'd like to use.

) : ( <>

Claude Code was not found in any of the common installation locations. Please install Claude Code to continue.

Searched locations: PATH, /usr/local/bin, /opt/homebrew/bin, ~/.nvm/versions/node/*/bin, ~/.claude/local, ~/.local/bin

)} {!checkingInstallations && (

Tip: You can install Claude Code using{" "} npm install -g @claude

)}
{!checkingInstallations && hasInstallations && (
setSelectedInstallation(installation)} selectedPath={null} />
)}
); } ================================================ FILE: src/components/ClaudeCodeSession.refactored.tsx ================================================ import React, { useState, useEffect, useRef, useCallback } from "react"; import { motion } from "framer-motion"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { api, type Session } from "@/lib/api"; import { cn } from "@/lib/utils"; import { open } from "@tauri-apps/plugin-dialog"; import { FloatingPromptInput, type FloatingPromptInputRef } from "./FloatingPromptInput"; import { ErrorBoundary } from "./ErrorBoundary"; import { TimelineNavigator } from "./TimelineNavigator"; import { CheckpointSettings } from "./CheckpointSettings"; import { SlashCommandsManager } from "./SlashCommandsManager"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; import { SplitPane } from "@/components/ui/split-pane"; import { WebviewPreview } from "./WebviewPreview"; // Import refactored components and hooks import { useClaudeMessages } from "./claude-code-session/useClaudeMessages"; import { useCheckpoints } from "./claude-code-session/useCheckpoints"; import { SessionHeader } from "./claude-code-session/SessionHeader"; import { MessageList } from "./claude-code-session/MessageList"; import { PromptQueue } from "./claude-code-session/PromptQueue"; interface ClaudeCodeSessionProps { session?: Session; initialProjectPath?: string; onBack: () => void; onProjectSettings?: (projectPath: string) => void; className?: string; onStreamingChange?: (isStreaming: boolean, sessionId: string | null) => void; } export const ClaudeCodeSession: React.FC = ({ session, initialProjectPath = "", onBack, onProjectSettings, className, onStreamingChange, }) => { const [projectPath, setProjectPath] = useState(initialProjectPath || session?.project_path || ""); const [error, setError] = useState(null); const [copyPopoverOpen, setCopyPopoverOpen] = useState(false); const [isFirstPrompt, setIsFirstPrompt] = useState(!session); const [totalTokens, setTotalTokens] = useState(0); const [claudeSessionId, setClaudeSessionId] = useState(null); const [showTimeline, setShowTimeline] = useState(false); const [showSettings, setShowSettings] = useState(false); const [showForkDialog, setShowForkDialog] = useState(false); const [showSlashCommandsSettings, setShowSlashCommandsSettings] = useState(false); const [forkCheckpointId, setForkCheckpointId] = useState(null); const [forkSessionName, setForkSessionName] = useState(""); const [queuedPrompts, setQueuedPrompts] = useState>([]); const [showPreview, setShowPreview] = useState(false); const [previewUrl, setPreviewUrl] = useState(null); const [isPreviewMaximized, setIsPreviewMaximized] = useState(false); const promptInputRef = useRef(null); const processQueueTimeoutRef = useRef(null); // Use custom hooks const { messages, rawJsonlOutput, isStreaming, currentSessionId: _currentSessionId, clearMessages, loadMessages } = useClaudeMessages({ onSessionInfo: (info) => { setClaudeSessionId(info.sessionId); }, onTokenUpdate: setTotalTokens, onStreamingChange }); const { checkpoints: _checkpoints, timelineVersion, loadCheckpoints, createCheckpoint: _createCheckpoint, restoreCheckpoint, forkCheckpoint } = useCheckpoints({ sessionId: claudeSessionId, projectId: session?.project_id || '', projectPath: projectPath, onToast: (message: string, type: 'success' | 'error') => { console.log(`Toast: ${type} - ${message}`); } }); // Handle path selection const handleSelectPath = async () => { const selected = await open({ directory: true, multiple: false, title: "Select Project Directory" }); if (selected && typeof selected === 'string') { setProjectPath(selected); setError(null); setIsFirstPrompt(true); } }; // Handle sending prompts const handleSendPrompt = useCallback(async (prompt: string, model: "sonnet" | "opus") => { console.log('[TRACE] handleSendPrompt called:'); console.log('[TRACE] prompt length:', prompt.length); console.log('[TRACE] model:', model); console.log('[TRACE] projectPath:', projectPath); console.log('[TRACE] isStreaming:', isStreaming); console.log('[TRACE] isFirstPrompt:', isFirstPrompt); console.log('[TRACE] claudeSessionId:', claudeSessionId); if (!projectPath || !prompt.trim()) { console.log('[TRACE] Aborting - no project path or empty prompt'); return; } // Add to queue if streaming if (isStreaming) { console.log('[TRACE] Currently streaming - adding to queue'); const id = Date.now().toString(); setQueuedPrompts(prev => [...prev, { id, prompt, model }]); return; } try { console.log('[TRACE] Clearing error and starting prompt execution'); setError(null); if (isFirstPrompt) { console.log('[TRACE] First prompt - calling api.executeClaudeCode'); await api.executeClaudeCode(projectPath, prompt, model); setIsFirstPrompt(false); console.log('[TRACE] executeClaudeCode completed'); } else if (claudeSessionId) { console.log('[TRACE] Continue prompt - calling api.continueClaudeCode'); await api.continueClaudeCode(projectPath, prompt, model); console.log('[TRACE] continueClaudeCode completed'); } else { console.log('[TRACE] No claude session ID for continue'); } } catch (error) { console.error("[TRACE] Failed to send prompt:", error); setError(error instanceof Error ? error.message : "Failed to send prompt"); } }, [projectPath, isStreaming, isFirstPrompt, claudeSessionId]); // Process queued prompts const processQueuedPrompts = useCallback(async () => { if (queuedPrompts.length === 0 || isStreaming) return; const nextPrompt = queuedPrompts[0]; setQueuedPrompts(prev => prev.slice(1)); await handleSendPrompt(nextPrompt.prompt, nextPrompt.model); }, [queuedPrompts, isStreaming, handleSendPrompt]); // Effect to process queue when streaming stops useEffect(() => { if (!isStreaming && queuedPrompts.length > 0) { processQueueTimeoutRef.current = setTimeout(processQueuedPrompts, 500); } return () => { if (processQueueTimeoutRef.current) { clearTimeout(processQueueTimeoutRef.current); } }; }, [isStreaming, queuedPrompts.length, processQueuedPrompts]); // Copy handlers const handleCopyAsJsonl = async () => { try { await navigator.clipboard.writeText(rawJsonlOutput.join('\n')); setCopyPopoverOpen(false); console.log("Session output copied as JSONL"); } catch (error) { console.error("Failed to copy:", error); } }; const handleCopyAsMarkdown = async () => { try { const markdown = messages .filter(msg => msg.type === 'user' || msg.type === 'assistant') .map(msg => { if (msg.type === 'user') { return `## User\n\n${msg.message || ''}`; } else if (msg.type === 'assistant' && msg.message?.content) { const content = Array.isArray(msg.message.content) ? msg.message.content.map((item: any) => { if (typeof item === 'string') return item; if (item.type === 'text') return item.text; return ''; }).filter(Boolean).join('') : msg.message.content; return `## Assistant\n\n${content}`; } return ''; }) .filter(Boolean) .join('\n\n---\n\n'); await navigator.clipboard.writeText(markdown); setCopyPopoverOpen(false); console.log("Session output copied as Markdown"); } catch (error) { console.error("Failed to copy:", error); } }; // Fork dialog handlers const handleFork = (checkpointId: string) => { setForkCheckpointId(checkpointId); setForkSessionName(""); setShowForkDialog(true); }; const handleConfirmFork = async () => { if (!forkCheckpointId || !forkSessionName.trim()) return; const forkedSession = await forkCheckpoint(forkCheckpointId, forkSessionName); if (forkedSession) { setShowForkDialog(false); // Navigate to forked session window.location.reload(); // Or use proper navigation } }; // Link detection handler const handleLinkDetected = (url: string) => { setPreviewUrl(url); if (!showPreview) { setShowPreview(true); } }; // Load session if provided useEffect(() => { if (session) { setProjectPath(session.project_path); setClaudeSessionId(session.id); loadMessages(session.id); loadCheckpoints(); } }, [session, loadMessages, loadCheckpoints]); return (
{/* Header */} 0} showTimeline={showTimeline} copyPopoverOpen={copyPopoverOpen} onBack={onBack} onSelectPath={handleSelectPath} onCopyAsJsonl={handleCopyAsJsonl} onCopyAsMarkdown={handleCopyAsMarkdown} onToggleTimeline={() => setShowTimeline(!showTimeline)} onProjectSettings={onProjectSettings ? () => onProjectSettings(projectPath) : undefined} onSlashCommandsSettings={() => setShowSlashCommandsSettings(true)} setCopyPopoverOpen={setCopyPopoverOpen} /> {/* Main content area */}
{showPreview ? ( setQueuedPrompts(prev => prev.filter(p => p.id !== id))} />
} right={ setShowPreview(false)} onUrlChange={setPreviewUrl} onToggleMaximize={() => setIsPreviewMaximized(!isPreviewMaximized)} /> } initialSplit={60} /> ) : (
setQueuedPrompts(prev => prev.filter(p => p.id !== id))} />
)}
{/* Error display */} {error && (

{error}

)} {/* Floating prompt input */} {projectPath && ( { if (claudeSessionId && isStreaming) { await api.cancelClaudeExecution(claudeSessionId); } }} /> )} {/* Timeline Navigator */} {showTimeline && claudeSessionId && session && ( { const success = await restoreCheckpoint(checkpoint.id); if (success) { clearMessages(); loadMessages(claudeSessionId); } }} onFork={handleFork} refreshVersion={timelineVersion} /> )} {/* Settings dialogs */} {showSettings && claudeSessionId && session && ( setShowSettings(false)} /> )} {showSlashCommandsSettings && projectPath && ( )} {/* Fork dialog */} Fork Session from Checkpoint Create a new session branching from this checkpoint. The original session will remain unchanged.
setForkSessionName(e.target.value)} placeholder="Enter a name for the forked session" className="mt-2" />
); }; ================================================ FILE: src/components/ClaudeCodeSession.tsx ================================================ import React, { useState, useEffect, useRef, useMemo } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { Copy, ChevronDown, GitBranch, ChevronUp, X, Hash, Wrench } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Popover } from "@/components/ui/popover"; import { api, type Session } from "@/lib/api"; import { cn } from "@/lib/utils"; // Conditional imports for Tauri APIs let tauriListen: any; type UnlistenFn = () => void; try { if (typeof window !== 'undefined' && window.__TAURI__) { tauriListen = require("@tauri-apps/api/event").listen; } } catch (e) { console.log('[ClaudeCodeSession] Tauri APIs not available, using web mode'); } // Web-compatible replacements const listen = tauriListen || ((eventName: string, callback: (event: any) => void) => { console.log('[ClaudeCodeSession] Setting up DOM event listener for:', eventName); // In web mode, listen for DOM events const domEventHandler = (event: any) => { console.log('[ClaudeCodeSession] DOM event received:', eventName, event.detail); // Simulate Tauri event structure callback({ payload: event.detail }); }; window.addEventListener(eventName, domEventHandler); // Return unlisten function return Promise.resolve(() => { console.log('[ClaudeCodeSession] Removing DOM event listener for:', eventName); window.removeEventListener(eventName, domEventHandler); }); }); import { StreamMessage } from "./StreamMessage"; import { FloatingPromptInput, type FloatingPromptInputRef } from "./FloatingPromptInput"; import { ErrorBoundary } from "./ErrorBoundary"; import { TimelineNavigator } from "./TimelineNavigator"; import { CheckpointSettings } from "./CheckpointSettings"; import { SlashCommandsManager } from "./SlashCommandsManager"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; import { TooltipProvider, TooltipSimple } from "@/components/ui/tooltip-modern"; import { SplitPane } from "@/components/ui/split-pane"; import { WebviewPreview } from "./WebviewPreview"; import type { ClaudeStreamMessage } from "./AgentExecution"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useTrackEvent, useComponentMetrics, useWorkflowTracking } from "@/hooks"; import { SessionPersistenceService } from "@/services/sessionPersistence"; interface ClaudeCodeSessionProps { /** * Optional session to resume (when clicking from SessionList) */ session?: Session; /** * Initial project path (for new sessions) */ initialProjectPath?: string; /** * Callback to go back */ onBack: () => void; /** * Callback to open hooks configuration */ onProjectSettings?: (projectPath: string) => void; /** * Optional className for styling */ className?: string; /** * Callback when streaming state changes */ onStreamingChange?: (isStreaming: boolean, sessionId: string | null) => void; /** * Callback when project path changes */ onProjectPathChange?: (path: string) => void; } /** * ClaudeCodeSession component for interactive Claude Code sessions * * @example * setView('projects')} /> */ export const ClaudeCodeSession: React.FC = ({ session, initialProjectPath = "", className, onStreamingChange, onProjectPathChange, }) => { const [projectPath] = useState(initialProjectPath || session?.project_path || ""); const [messages, setMessages] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [rawJsonlOutput, setRawJsonlOutput] = useState([]); const [copyPopoverOpen, setCopyPopoverOpen] = useState(false); const [isFirstPrompt, setIsFirstPrompt] = useState(!session); const [totalTokens, setTotalTokens] = useState(0); const [extractedSessionInfo, setExtractedSessionInfo] = useState<{ sessionId: string; projectId: string } | null>(null); const [claudeSessionId, setClaudeSessionId] = useState(null); const [showTimeline, setShowTimeline] = useState(false); const [timelineVersion, setTimelineVersion] = useState(0); const [showSettings, setShowSettings] = useState(false); const [showForkDialog, setShowForkDialog] = useState(false); const [showSlashCommandsSettings, setShowSlashCommandsSettings] = useState(false); const [forkCheckpointId, setForkCheckpointId] = useState(null); const [forkSessionName, setForkSessionName] = useState(""); // Queued prompts state const [queuedPrompts, setQueuedPrompts] = useState>([]); // New state for preview feature const [showPreview, setShowPreview] = useState(false); const [previewUrl, setPreviewUrl] = useState(""); const [showPreviewPrompt, setShowPreviewPrompt] = useState(false); const [splitPosition, setSplitPosition] = useState(50); const [isPreviewMaximized, setIsPreviewMaximized] = useState(false); // Add collapsed state for queued prompts const [queuedPromptsCollapsed, setQueuedPromptsCollapsed] = useState(false); const parentRef = useRef(null); const unlistenRefs = useRef([]); const hasActiveSessionRef = useRef(false); const floatingPromptRef = useRef(null); const queuedPromptsRef = useRef>([]); const isMountedRef = useRef(true); const isListeningRef = useRef(false); const sessionStartTime = useRef(Date.now()); const isIMEComposingRef = useRef(false); // Session metrics state for enhanced analytics const sessionMetrics = useRef({ firstMessageTime: null as number | null, promptsSent: 0, toolsExecuted: 0, toolsFailed: 0, filesCreated: 0, filesModified: 0, filesDeleted: 0, codeBlocksGenerated: 0, errorsEncountered: 0, lastActivityTime: Date.now(), toolExecutionTimes: [] as number[], checkpointCount: 0, wasResumed: !!session, modelChanges: [] as Array<{ from: string; to: string; timestamp: number }>, }); // Analytics tracking const trackEvent = useTrackEvent(); useComponentMetrics('ClaudeCodeSession'); // const aiTracking = useAIInteractionTracking('sonnet'); // Default model const workflowTracking = useWorkflowTracking('claude_session'); // Call onProjectPathChange when component mounts with initial path useEffect(() => { if (onProjectPathChange && projectPath) { onProjectPathChange(projectPath); } }, []); // Only run on mount // Keep ref in sync with state useEffect(() => { queuedPromptsRef.current = queuedPrompts; }, [queuedPrompts]); // Get effective session info (from prop or extracted) - use useMemo to ensure it updates const effectiveSession = useMemo(() => { if (session) return session; if (extractedSessionInfo) { return { id: extractedSessionInfo.sessionId, project_id: extractedSessionInfo.projectId, project_path: projectPath, created_at: Date.now(), } as Session; } return null; }, [session, extractedSessionInfo, projectPath]); // Filter out messages that shouldn't be displayed const displayableMessages = useMemo(() => { return messages.filter((message, index) => { // Skip meta messages that don't have meaningful content if (message.isMeta && !message.leafUuid && !message.summary) { return false; } // Skip user messages that only contain tool results that are already displayed if (message.type === "user" && message.message) { if (message.isMeta) return false; const msg = message.message; if (!msg.content || (Array.isArray(msg.content) && msg.content.length === 0)) { return false; } if (Array.isArray(msg.content)) { let hasVisibleContent = false; for (const content of msg.content) { if (content.type === "text") { hasVisibleContent = true; break; } if (content.type === "tool_result") { let willBeSkipped = false; if (content.tool_use_id) { // Look for the matching tool_use in previous assistant messages for (let i = index - 1; i >= 0; i--) { const prevMsg = messages[i]; if (prevMsg.type === 'assistant' && prevMsg.message?.content && Array.isArray(prevMsg.message.content)) { const toolUse = prevMsg.message.content.find((c: any) => c.type === 'tool_use' && c.id === content.tool_use_id ); if (toolUse) { const toolName = toolUse.name?.toLowerCase(); const toolsWithWidgets = [ 'task', 'edit', 'multiedit', 'todowrite', 'ls', 'read', 'glob', 'bash', 'write', 'grep' ]; if (toolsWithWidgets.includes(toolName) || toolUse.name?.startsWith('mcp__')) { willBeSkipped = true; } break; } } } } if (!willBeSkipped) { hasVisibleContent = true; break; } } } if (!hasVisibleContent) { return false; } } } return true; }); }, [messages]); const rowVirtualizer = useVirtualizer({ count: displayableMessages.length, getScrollElement: () => parentRef.current, estimateSize: () => 150, // Estimate, will be dynamically measured overscan: 5, }); // Debug logging useEffect(() => { console.log('[ClaudeCodeSession] State update:', { projectPath, session, extractedSessionInfo, effectiveSession, messagesCount: messages.length, isLoading }); }, [projectPath, session, extractedSessionInfo, effectiveSession, messages.length, isLoading]); // Load session history if resuming useEffect(() => { if (session) { // Set the claudeSessionId immediately when we have a session setClaudeSessionId(session.id); // Load session history first, then check for active session const initializeSession = async () => { await loadSessionHistory(); // After loading history, check if the session is still active if (isMountedRef.current) { await checkForActiveSession(); } }; initializeSession(); } }, [session]); // Remove hasLoadedSession dependency to ensure it runs on mount // Report streaming state changes useEffect(() => { onStreamingChange?.(isLoading, claudeSessionId); }, [isLoading, claudeSessionId, onStreamingChange]); // Auto-scroll to bottom when new messages arrive useEffect(() => { if (displayableMessages.length > 0) { // Use a more precise scrolling method to ensure content is fully visible setTimeout(() => { const scrollElement = parentRef.current; if (scrollElement) { // First, scroll using virtualizer to get close to the bottom rowVirtualizer.scrollToIndex(displayableMessages.length - 1, { align: 'end', behavior: 'auto' }); // Then use direct scroll to ensure we reach the absolute bottom requestAnimationFrame(() => { scrollElement.scrollTo({ top: scrollElement.scrollHeight, behavior: 'smooth' }); }); } }, 50); } }, [displayableMessages.length, rowVirtualizer]); // Calculate total tokens from messages useEffect(() => { const tokens = messages.reduce((total, msg) => { if (msg.message?.usage) { return total + msg.message.usage.input_tokens + msg.message.usage.output_tokens; } if (msg.usage) { return total + msg.usage.input_tokens + msg.usage.output_tokens; } return total; }, 0); setTotalTokens(tokens); }, [messages]); const loadSessionHistory = async () => { if (!session) return; try { setIsLoading(true); setError(null); const history = await api.loadSessionHistory(session.id, session.project_id); // Save session data for restoration if (history && history.length > 0) { SessionPersistenceService.saveSession( session.id, session.project_id, session.project_path, history.length ); } // Convert history to messages format const loadedMessages: ClaudeStreamMessage[] = history.map(entry => ({ ...entry, type: entry.type || "assistant" })); setMessages(loadedMessages); setRawJsonlOutput(history.map(h => JSON.stringify(h))); // After loading history, we're continuing a conversation setIsFirstPrompt(false); // Scroll to bottom after loading history setTimeout(() => { if (loadedMessages.length > 0) { const scrollElement = parentRef.current; if (scrollElement) { // Use the same improved scrolling method rowVirtualizer.scrollToIndex(loadedMessages.length - 1, { align: 'end', behavior: 'auto' }); requestAnimationFrame(() => { scrollElement.scrollTo({ top: scrollElement.scrollHeight, behavior: 'auto' }); }); } } }, 100); } catch (err) { console.error("Failed to load session history:", err); setError("Failed to load session history"); } finally { setIsLoading(false); } }; const checkForActiveSession = async () => { // If we have a session prop, check if it's still active if (session) { try { const activeSessions = await api.listRunningClaudeSessions(); const activeSession = activeSessions.find((s: any) => { if ('process_type' in s && s.process_type && 'ClaudeSession' in s.process_type) { return (s.process_type as any).ClaudeSession.session_id === session.id; } return false; }); if (activeSession) { // Session is still active, reconnect to its stream console.log('[ClaudeCodeSession] Found active session, reconnecting:', session.id); // IMPORTANT: Set claudeSessionId before reconnecting setClaudeSessionId(session.id); // Don't add buffered messages here - they've already been loaded by loadSessionHistory // Just set up listeners for new messages // Set up listeners for the active session reconnectToSession(session.id); } } catch (err) { console.error('Failed to check for active sessions:', err); } } }; const reconnectToSession = async (sessionId: string) => { console.log('[ClaudeCodeSession] Reconnecting to session:', sessionId); // Prevent duplicate listeners if (isListeningRef.current) { console.log('[ClaudeCodeSession] Already listening to session, skipping reconnect'); return; } // Clean up previous listeners unlistenRefs.current.forEach(unlisten => unlisten()); unlistenRefs.current = []; // IMPORTANT: Set the session ID before setting up listeners setClaudeSessionId(sessionId); // Mark as listening isListeningRef.current = true; // Set up session-specific listeners const outputUnlisten = await listen(`claude-output:${sessionId}`, async (event: any) => { try { console.log('[ClaudeCodeSession] Received claude-output on reconnect:', event.payload); if (!isMountedRef.current) return; // Store raw JSONL setRawJsonlOutput(prev => [...prev, event.payload]); // Parse and display const message = JSON.parse(event.payload) as ClaudeStreamMessage; setMessages(prev => [...prev, message]); } catch (err) { console.error("Failed to parse message:", err, event.payload); } }); const errorUnlisten = await listen(`claude-error:${sessionId}`, (event: any) => { console.error("Claude error:", event.payload); if (isMountedRef.current) { setError(event.payload); } }); const completeUnlisten = await listen(`claude-complete:${sessionId}`, async (event: any) => { console.log('[ClaudeCodeSession] Received claude-complete on reconnect:', event.payload); if (isMountedRef.current) { setIsLoading(false); hasActiveSessionRef.current = false; } }); unlistenRefs.current = [outputUnlisten, errorUnlisten, completeUnlisten]; // Mark as loading to show the session is active if (isMountedRef.current) { setIsLoading(true); hasActiveSessionRef.current = true; } }; // Project path selection handled by parent tab controls const handleSendPrompt = async (prompt: string, model: "sonnet" | "opus") => { console.log('[ClaudeCodeSession] handleSendPrompt called with:', { prompt, model, projectPath, claudeSessionId, effectiveSession }); if (!projectPath) { setError("Please select a project directory first"); return; } // If already loading, queue the prompt if (isLoading) { const newPrompt = { id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, prompt, model }; setQueuedPrompts(prev => [...prev, newPrompt]); return; } try { setIsLoading(true); setError(null); hasActiveSessionRef.current = true; // For resuming sessions, ensure we have the session ID if (effectiveSession && !claudeSessionId) { setClaudeSessionId(effectiveSession.id); } // Only clean up and set up new listeners if not already listening if (!isListeningRef.current) { // Clean up previous listeners unlistenRefs.current.forEach(unlisten => unlisten()); unlistenRefs.current = []; // Mark as setting up listeners isListeningRef.current = true; // -------------------------------------------------------------------- // 1️⃣ Event Listener Setup Strategy // -------------------------------------------------------------------- // Claude Code may emit a *new* session_id even when we pass --resume. If // we listen only on the old session-scoped channel we will miss the // stream until the user navigates away & back. To avoid this we: // • Always start with GENERIC listeners (no suffix) so we catch the // very first "system:init" message regardless of the session id. // • Once that init message provides the *actual* session_id, we // dynamically switch to session-scoped listeners and stop the // generic ones to prevent duplicate handling. // -------------------------------------------------------------------- console.log('[ClaudeCodeSession] Setting up generic event listeners first'); let currentSessionId: string | null = claudeSessionId || effectiveSession?.id || null; // Helper to attach session-specific listeners **once we are sure** const attachSessionSpecificListeners = async (sid: string) => { console.log('[ClaudeCodeSession] Attaching session-specific listeners for', sid); const specificOutputUnlisten = await listen(`claude-output:${sid}`, (evt: any) => { handleStreamMessage(evt.payload); }); const specificErrorUnlisten = await listen(`claude-error:${sid}`, (evt: any) => { console.error('Claude error (scoped):', evt.payload); setError(evt.payload); }); const specificCompleteUnlisten = await listen(`claude-complete:${sid}`, (evt: any) => { console.log('[ClaudeCodeSession] Received claude-complete (scoped):', evt.payload); processComplete(evt.payload); }); // Replace existing unlisten refs with these new ones (after cleaning up) unlistenRefs.current.forEach((u) => u()); unlistenRefs.current = [specificOutputUnlisten, specificErrorUnlisten, specificCompleteUnlisten]; }; // Generic listeners (catch-all) const genericOutputUnlisten = await listen('claude-output', async (event: any) => { handleStreamMessage(event.payload); // Attempt to extract session_id on the fly (for the very first init) try { const msg = JSON.parse(event.payload) as ClaudeStreamMessage; if (msg.type === 'system' && msg.subtype === 'init' && msg.session_id) { if (!currentSessionId || currentSessionId !== msg.session_id) { console.log('[ClaudeCodeSession] Detected new session_id from generic listener:', msg.session_id); currentSessionId = msg.session_id; setClaudeSessionId(msg.session_id); // If we haven't extracted session info before, do it now if (!extractedSessionInfo) { const projectId = projectPath.replace(/[^a-zA-Z0-9]/g, '-'); setExtractedSessionInfo({ sessionId: msg.session_id, projectId }); // Save session data for restoration SessionPersistenceService.saveSession( msg.session_id, projectId, projectPath, messages.length ); } // Switch to session-specific listeners await attachSessionSpecificListeners(msg.session_id); } } } catch { /* ignore parse errors */ } }); // Helper to process any JSONL stream message string or object function handleStreamMessage(payload: string | ClaudeStreamMessage) { try { // Don't process if component unmounted if (!isMountedRef.current) return; let message: ClaudeStreamMessage; let rawPayload: string; if (typeof payload === 'string') { // Tauri mode: payload is a JSON string rawPayload = payload; message = JSON.parse(payload) as ClaudeStreamMessage; } else { // Web mode: payload is already parsed object message = payload; rawPayload = JSON.stringify(payload); } console.log('[ClaudeCodeSession] handleStreamMessage - message type:', message.type); // Store raw JSONL setRawJsonlOutput((prev) => [...prev, rawPayload]); // Track enhanced tool execution if (message.type === 'assistant' && message.message?.content) { const toolUses = message.message.content.filter((c: any) => c.type === 'tool_use'); toolUses.forEach((toolUse: any) => { // Increment tools executed counter sessionMetrics.current.toolsExecuted += 1; sessionMetrics.current.lastActivityTime = Date.now(); // Track file operations const toolName = toolUse.name?.toLowerCase() || ''; if (toolName.includes('create') || toolName.includes('write')) { sessionMetrics.current.filesCreated += 1; } else if (toolName.includes('edit') || toolName.includes('multiedit') || toolName.includes('search_replace')) { sessionMetrics.current.filesModified += 1; } else if (toolName.includes('delete')) { sessionMetrics.current.filesDeleted += 1; } // Track tool start - we'll track completion when we get the result workflowTracking.trackStep(toolUse.name); }); } // Track tool results if (message.type === 'user' && message.message?.content) { const toolResults = message.message.content.filter((c: any) => c.type === 'tool_result'); toolResults.forEach((result: any) => { const isError = result.is_error || false; // Note: We don't have execution time here, but we can track success/failure if (isError) { sessionMetrics.current.toolsFailed += 1; sessionMetrics.current.errorsEncountered += 1; trackEvent.enhancedError({ error_type: 'tool_execution', error_code: 'tool_failed', error_message: result.content, context: `Tool execution failed`, user_action_before_error: 'executing_tool', recovery_attempted: false, recovery_successful: false, error_frequency: 1, stack_trace_hash: undefined }); } }); } // Track code blocks generated if (message.type === 'assistant' && message.message?.content) { const codeBlocks = message.message.content.filter((c: any) => c.type === 'text' && c.text?.includes('```') ); if (codeBlocks.length > 0) { // Count code blocks in text content codeBlocks.forEach((block: any) => { const matches = (block.text.match(/```/g) || []).length; sessionMetrics.current.codeBlocksGenerated += Math.floor(matches / 2); }); } } // Track errors in system messages if (message.type === 'system' && (message.subtype === 'error' || message.error)) { sessionMetrics.current.errorsEncountered += 1; } setMessages((prev) => [...prev, message]); } catch (err) { console.error('Failed to parse message:', err, payload); } } // Helper to handle completion events (both generic and scoped) const processComplete = async (success: boolean) => { setIsLoading(false); hasActiveSessionRef.current = false; isListeningRef.current = false; // Reset listening state // Track enhanced session stopped metrics when session completes if (effectiveSession && claudeSessionId) { const sessionStartTimeValue = messages.length > 0 ? messages[0].timestamp || Date.now() : Date.now(); const duration = Date.now() - sessionStartTimeValue; const metrics = sessionMetrics.current; const timeToFirstMessage = metrics.firstMessageTime ? metrics.firstMessageTime - sessionStartTime.current : undefined; const idleTime = Date.now() - metrics.lastActivityTime; const avgResponseTime = metrics.toolExecutionTimes.length > 0 ? metrics.toolExecutionTimes.reduce((a, b) => a + b, 0) / metrics.toolExecutionTimes.length : undefined; trackEvent.enhancedSessionStopped({ // Basic metrics duration_ms: duration, messages_count: messages.length, reason: success ? 'completed' : 'error', // Timing metrics time_to_first_message_ms: timeToFirstMessage, average_response_time_ms: avgResponseTime, idle_time_ms: idleTime, // Interaction metrics prompts_sent: metrics.promptsSent, tools_executed: metrics.toolsExecuted, tools_failed: metrics.toolsFailed, files_created: metrics.filesCreated, files_modified: metrics.filesModified, files_deleted: metrics.filesDeleted, // Content metrics total_tokens_used: totalTokens, code_blocks_generated: metrics.codeBlocksGenerated, errors_encountered: metrics.errorsEncountered, // Session context model: metrics.modelChanges.length > 0 ? metrics.modelChanges[metrics.modelChanges.length - 1].to : 'sonnet', has_checkpoints: metrics.checkpointCount > 0, checkpoint_count: metrics.checkpointCount, was_resumed: metrics.wasResumed, // Agent context (if applicable) agent_type: undefined, // TODO: Pass from agent execution agent_name: undefined, // TODO: Pass from agent execution agent_success: success, // Stop context stop_source: 'completed', final_state: success ? 'success' : 'failed', has_pending_prompts: queuedPrompts.length > 0, pending_prompts_count: queuedPrompts.length, }); } if (effectiveSession && success) { try { const settings = await api.getCheckpointSettings( effectiveSession.id, effectiveSession.project_id, projectPath ); if (settings.auto_checkpoint_enabled) { await api.checkAutoCheckpoint( effectiveSession.id, effectiveSession.project_id, projectPath, prompt ); // Reload timeline to show new checkpoint setTimelineVersion((v) => v + 1); } } catch (err) { console.error('Failed to check auto checkpoint:', err); } } // Process queued prompts after completion if (queuedPromptsRef.current.length > 0) { const [nextPrompt, ...remainingPrompts] = queuedPromptsRef.current; setQueuedPrompts(remainingPrompts); // Small delay to ensure UI updates setTimeout(() => { handleSendPrompt(nextPrompt.prompt, nextPrompt.model); }, 100); } }; const genericErrorUnlisten = await listen('claude-error', (evt: any) => { console.error('Claude error:', evt.payload); setError(evt.payload); }); const genericCompleteUnlisten = await listen('claude-complete', (evt: any) => { console.log('[ClaudeCodeSession] Received claude-complete (generic):', evt.payload); processComplete(evt.payload); }); // Store the generic unlisteners for now; they may be replaced later. unlistenRefs.current = [genericOutputUnlisten, genericErrorUnlisten, genericCompleteUnlisten]; // -------------------------------------------------------------------- // 2️⃣ Auto-checkpoint logic moved after listener setup (unchanged) // -------------------------------------------------------------------- // Add the user message immediately to the UI (after setting up listeners) const userMessage: ClaudeStreamMessage = { type: "user", message: { content: [ { type: "text", text: prompt } ] } }; setMessages(prev => [...prev, userMessage]); // Update session metrics sessionMetrics.current.promptsSent += 1; sessionMetrics.current.lastActivityTime = Date.now(); if (!sessionMetrics.current.firstMessageTime) { sessionMetrics.current.firstMessageTime = Date.now(); } // Track model changes const lastModel = sessionMetrics.current.modelChanges.length > 0 ? sessionMetrics.current.modelChanges[sessionMetrics.current.modelChanges.length - 1].to : (sessionMetrics.current.wasResumed ? 'sonnet' : model); // Default to sonnet if resumed if (lastModel !== model) { sessionMetrics.current.modelChanges.push({ from: lastModel, to: model, timestamp: Date.now() }); } // Track enhanced prompt submission const codeBlockMatches = prompt.match(/```[\s\S]*?```/g) || []; const hasCode = codeBlockMatches.length > 0; const conversationDepth = messages.filter(m => m.user_message).length; const sessionAge = sessionStartTime.current ? Date.now() - sessionStartTime.current : 0; const wordCount = prompt.split(/\s+/).filter(word => word.length > 0).length; trackEvent.enhancedPromptSubmitted({ prompt_length: prompt.length, model: model, has_attachments: false, // TODO: Add attachment support when implemented source: 'keyboard', // TODO: Track actual source (keyboard vs button) word_count: wordCount, conversation_depth: conversationDepth, prompt_complexity: wordCount < 20 ? 'simple' : wordCount < 100 ? 'moderate' : 'complex', contains_code: hasCode, language_detected: hasCode ? codeBlockMatches?.[0]?.match(/```(\w+)/)?.[1] : undefined, session_age_ms: sessionAge }); // Execute the appropriate command if (effectiveSession && !isFirstPrompt) { console.log('[ClaudeCodeSession] Resuming session:', effectiveSession.id); trackEvent.sessionResumed(effectiveSession.id); trackEvent.modelSelected(model); await api.resumeClaudeCode(projectPath, effectiveSession.id, prompt, model); } else { console.log('[ClaudeCodeSession] Starting new session'); setIsFirstPrompt(false); trackEvent.sessionCreated(model, 'prompt_input'); trackEvent.modelSelected(model); await api.executeClaudeCode(projectPath, prompt, model); } } } catch (err) { console.error("Failed to send prompt:", err); setError("Failed to send prompt"); setIsLoading(false); hasActiveSessionRef.current = false; } }; const handleCopyAsJsonl = async () => { const jsonl = rawJsonlOutput.join('\n'); await navigator.clipboard.writeText(jsonl); setCopyPopoverOpen(false); }; const handleCopyAsMarkdown = async () => { let markdown = `# Claude Code Session\n\n`; markdown += `**Project:** ${projectPath}\n`; markdown += `**Date:** ${new Date().toISOString()}\n\n`; markdown += `---\n\n`; for (const msg of messages) { if (msg.type === "system" && msg.subtype === "init") { markdown += `## System Initialization\n\n`; markdown += `- Session ID: \`${msg.session_id || 'N/A'}\`\n`; markdown += `- Model: \`${msg.model || 'default'}\`\n`; if (msg.cwd) markdown += `- Working Directory: \`${msg.cwd}\`\n`; if (msg.tools?.length) markdown += `- Tools: ${msg.tools.join(', ')}\n`; markdown += `\n`; } else if (msg.type === "assistant" && msg.message) { markdown += `## Assistant\n\n`; for (const content of msg.message.content || []) { if (content.type === "text") { const textContent = typeof content.text === 'string' ? content.text : (content.text?.text || JSON.stringify(content.text || content)); markdown += `${textContent}\n\n`; } else if (content.type === "tool_use") { markdown += `### Tool: ${content.name}\n\n`; markdown += `\`\`\`json\n${JSON.stringify(content.input, null, 2)}\n\`\`\`\n\n`; } } if (msg.message.usage) { markdown += `*Tokens: ${msg.message.usage.input_tokens} in, ${msg.message.usage.output_tokens} out*\n\n`; } } else if (msg.type === "user" && msg.message) { markdown += `## User\n\n`; for (const content of msg.message.content || []) { if (content.type === "text") { const textContent = typeof content.text === 'string' ? content.text : (content.text?.text || JSON.stringify(content.text)); markdown += `${textContent}\n\n`; } else if (content.type === "tool_result") { markdown += `### Tool Result\n\n`; let contentText = ''; if (typeof content.content === 'string') { contentText = content.content; } else if (content.content && typeof content.content === 'object') { if (content.content.text) { contentText = content.content.text; } else if (Array.isArray(content.content)) { contentText = content.content .map((c: any) => (typeof c === 'string' ? c : c.text || JSON.stringify(c))) .join('\n'); } else { contentText = JSON.stringify(content.content, null, 2); } } markdown += `\`\`\`\n${contentText}\n\`\`\`\n\n`; } } } else if (msg.type === "result") { markdown += `## Execution Result\n\n`; if (msg.result) { markdown += `${msg.result}\n\n`; } if (msg.error) { markdown += `**Error:** ${msg.error}\n\n`; } } } await navigator.clipboard.writeText(markdown); setCopyPopoverOpen(false); }; const handleCheckpointSelect = async () => { // Reload messages from the checkpoint await loadSessionHistory(); // Ensure timeline reloads to highlight current checkpoint setTimelineVersion((v) => v + 1); }; const handleCheckpointCreated = () => { // Update checkpoint count in session metrics sessionMetrics.current.checkpointCount += 1; }; const handleCancelExecution = async () => { if (!claudeSessionId || !isLoading) return; try { const sessionStartTime = messages.length > 0 ? messages[0].timestamp || Date.now() : Date.now(); const duration = Date.now() - sessionStartTime; await api.cancelClaudeExecution(claudeSessionId); // Calculate metrics for enhanced analytics const metrics = sessionMetrics.current; const timeToFirstMessage = metrics.firstMessageTime ? metrics.firstMessageTime - sessionStartTime.current : undefined; const idleTime = Date.now() - metrics.lastActivityTime; const avgResponseTime = metrics.toolExecutionTimes.length > 0 ? metrics.toolExecutionTimes.reduce((a, b) => a + b, 0) / metrics.toolExecutionTimes.length : undefined; // Track enhanced session stopped trackEvent.enhancedSessionStopped({ // Basic metrics duration_ms: duration, messages_count: messages.length, reason: 'user_stopped', // Timing metrics time_to_first_message_ms: timeToFirstMessage, average_response_time_ms: avgResponseTime, idle_time_ms: idleTime, // Interaction metrics prompts_sent: metrics.promptsSent, tools_executed: metrics.toolsExecuted, tools_failed: metrics.toolsFailed, files_created: metrics.filesCreated, files_modified: metrics.filesModified, files_deleted: metrics.filesDeleted, // Content metrics total_tokens_used: totalTokens, code_blocks_generated: metrics.codeBlocksGenerated, errors_encountered: metrics.errorsEncountered, // Session context model: metrics.modelChanges.length > 0 ? metrics.modelChanges[metrics.modelChanges.length - 1].to : 'sonnet', // Default to sonnet has_checkpoints: metrics.checkpointCount > 0, checkpoint_count: metrics.checkpointCount, was_resumed: metrics.wasResumed, // Agent context (if applicable) agent_type: undefined, // TODO: Pass from agent execution agent_name: undefined, // TODO: Pass from agent execution agent_success: undefined, // TODO: Pass from agent execution // Stop context stop_source: 'user_button', final_state: 'cancelled', has_pending_prompts: queuedPrompts.length > 0, pending_prompts_count: queuedPrompts.length, }); // Clean up listeners unlistenRefs.current.forEach(unlisten => unlisten()); unlistenRefs.current = []; // Reset states setIsLoading(false); hasActiveSessionRef.current = false; isListeningRef.current = false; setError(null); // Clear queued prompts setQueuedPrompts([]); // Add a message indicating the session was cancelled const cancelMessage: ClaudeStreamMessage = { type: "system", subtype: "info", result: "Session cancelled by user", timestamp: new Date().toISOString() }; setMessages(prev => [...prev, cancelMessage]); } catch (err) { console.error("Failed to cancel execution:", err); // Even if backend fails, we should update UI to reflect stopped state // Add error message but still stop the UI loading state const errorMessage: ClaudeStreamMessage = { type: "system", subtype: "error", result: `Failed to cancel execution: ${err instanceof Error ? err.message : 'Unknown error'}. The process may still be running in the background.`, timestamp: new Date().toISOString() }; setMessages(prev => [...prev, errorMessage]); // Clean up listeners anyway unlistenRefs.current.forEach(unlisten => unlisten()); unlistenRefs.current = []; // Reset states to allow user to continue setIsLoading(false); hasActiveSessionRef.current = false; isListeningRef.current = false; setError(null); } }; const handleFork = (checkpointId: string) => { setForkCheckpointId(checkpointId); setForkSessionName(`Fork-${new Date().toISOString().slice(0, 10)}`); setShowForkDialog(true); }; const handleCompositionStart = () => { isIMEComposingRef.current = true; }; const handleCompositionEnd = () => { setTimeout(() => { isIMEComposingRef.current = false; }, 0); }; const handleConfirmFork = async () => { if (!forkCheckpointId || !forkSessionName.trim() || !effectiveSession) return; try { setIsLoading(true); setError(null); const newSessionId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; await api.forkFromCheckpoint( forkCheckpointId, effectiveSession.id, effectiveSession.project_id, projectPath, newSessionId, forkSessionName ); // Open the new forked session // You would need to implement navigation to the new session console.log("Forked to new session:", newSessionId); setShowForkDialog(false); setForkCheckpointId(null); setForkSessionName(""); } catch (err) { console.error("Failed to fork checkpoint:", err); setError("Failed to fork checkpoint"); } finally { setIsLoading(false); } }; // Handle URL detection from terminal output const handleLinkDetected = (url: string) => { if (!showPreview && !showPreviewPrompt) { setPreviewUrl(url); setShowPreviewPrompt(true); } }; const handleClosePreview = () => { setShowPreview(false); setIsPreviewMaximized(false); // Keep the previewUrl so it can be restored when reopening }; const handlePreviewUrlChange = (url: string) => { console.log('[ClaudeCodeSession] Preview URL changed to:', url); setPreviewUrl(url); }; const handleTogglePreviewMaximize = () => { setIsPreviewMaximized(!isPreviewMaximized); // Reset split position when toggling maximize if (isPreviewMaximized) { setSplitPosition(50); } }; // Cleanup event listeners and track mount state useEffect(() => { isMountedRef.current = true; return () => { console.log('[ClaudeCodeSession] Component unmounting, cleaning up listeners'); isMountedRef.current = false; isListeningRef.current = false; // Track session completion with engagement metrics if (effectiveSession) { trackEvent.sessionCompleted(); // Track session engagement const sessionDuration = sessionStartTime.current ? Date.now() - sessionStartTime.current : 0; const messageCount = messages.filter(m => m.user_message).length; const toolsUsed = new Set(); messages.forEach(msg => { if (msg.type === 'assistant' && msg.message?.content) { const tools = msg.message.content.filter((c: any) => c.type === 'tool_use'); tools.forEach((tool: any) => toolsUsed.add(tool.name)); } }); // Calculate engagement score (0-100) const engagementScore = Math.min(100, (messageCount * 10) + (toolsUsed.size * 5) + (sessionDuration > 300000 ? 20 : sessionDuration / 15000) // 5+ min session gets 20 points ); trackEvent.sessionEngagement({ session_duration_ms: sessionDuration, messages_sent: messageCount, tools_used: Array.from(toolsUsed), files_modified: 0, // TODO: Track file modifications engagement_score: Math.round(engagementScore) }); } // Clean up listeners unlistenRefs.current.forEach(unlisten => unlisten()); unlistenRefs.current = []; // Clear checkpoint manager when session ends if (effectiveSession) { api.clearCheckpointManager(effectiveSession.id).catch(err => { console.error("Failed to clear checkpoint manager:", err); }); } }; }, [effectiveSession, projectPath]); const messagesList = (
{rowVirtualizer.getVirtualItems().map((virtualItem) => { const message = displayableMessages[virtualItem.index]; return ( el && rowVirtualizer.measureElement(el)} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }} transition={{ duration: 0.3 }} className="absolute inset-x-4 pb-4" style={{ top: virtualItem.start, }} > ); })}
{/* Loading indicator under the latest message */} {isLoading && (
)} {/* Error indicator */} {error && ( {error} )}
); const projectPathInput = null; // Removed project path display // If preview is maximized, render only the WebviewPreview in full screen if (showPreview && isPreviewMaximized) { return ( ); } return (
{/* Main Content Area */}
{showPreview ? ( // Split pane layout when preview is active {projectPathInput} {messagesList}
} right={ } initialSplit={splitPosition} onSplitChange={setSplitPosition} minLeftWidth={400} minRightWidth={400} className="h-full" /> ) : ( // Original layout when no preview
{projectPathInput} {messagesList} {isLoading && messages.length === 0 && (
{session ? "Loading session history..." : "Initializing Claude Code..."}
)}
)}
{/* Floating Prompt Input - Always visible */} {/* Queued Prompts Display */} {queuedPrompts.length > 0 && (
Queued Prompts ({queuedPrompts.length})
{!queuedPromptsCollapsed && queuedPrompts.map((queuedPrompt, index) => (
#{index + 1} {queuedPrompt.model === "opus" ? "Opus" : "Sonnet"}

{queuedPrompt.prompt}

))}
)}
{/* Navigation Arrows - positioned above prompt bar with spacing */} {displayableMessages.length > 5 && (
)}
{effectiveSession && ( )} {messages.length > 0 && ( } content={
} open={copyPopoverOpen} onOpenChange={setCopyPopoverOpen} side="top" align="end" /> )} } />
{/* Token Counter - positioned under the Send button */} {totalTokens > 0 && (
{totalTokens.toLocaleString()} tokens
)} {/* Timeline */} {showTimeline && effectiveSession && (
{/* Timeline Header */}

Session Timeline

{/* Timeline Content */}
)}
{/* Fork Dialog */} Fork Session Create a new session branch from the selected checkpoint.
setForkSessionName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !isLoading) { if (e.nativeEvent.isComposing || isIMEComposingRef.current) { return; } handleConfirmFork(); } }} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} />
{/* Settings Dialog */} {showSettings && effectiveSession && ( setShowSettings(false)} /> )} {/* Slash Commands Settings Dialog */} {showSlashCommandsSettings && ( Slash Commands Manage project-specific slash commands for {projectPath}
)}
); }; ================================================ FILE: src/components/ClaudeFileEditor.tsx ================================================ import React, { useState, useEffect } from "react"; import MDEditor from "@uiw/react-md-editor"; import { motion } from "framer-motion"; import { ArrowLeft, Save, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Toast, ToastContainer } from "@/components/ui/toast"; import { api, type ClaudeMdFile } from "@/lib/api"; import { cn } from "@/lib/utils"; interface ClaudeFileEditorProps { /** * The CLAUDE.md file to edit */ file: ClaudeMdFile; /** * Callback to go back to the previous view */ onBack: () => void; /** * Optional className for styling */ className?: string; } /** * ClaudeFileEditor component for editing project-specific CLAUDE.md files * * @example * setEditingFile(null)} * /> */ export const ClaudeFileEditor: React.FC = ({ file, onBack, className, }) => { const [content, setContent] = useState(""); const [originalContent, setOriginalContent] = useState(""); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); const hasChanges = content !== originalContent; // Load the file content on mount useEffect(() => { loadFileContent(); }, [file.absolute_path]); const loadFileContent = async () => { try { setLoading(true); setError(null); const fileContent = await api.readClaudeMdFile(file.absolute_path); setContent(fileContent); setOriginalContent(fileContent); } catch (err) { console.error("Failed to load file:", err); setError("Failed to load CLAUDE.md file"); } finally { setLoading(false); } }; const handleSave = async () => { try { setSaving(true); setError(null); setToast(null); await api.saveClaudeMdFile(file.absolute_path, content); setOriginalContent(content); setToast({ message: "File saved successfully", type: "success" }); } catch (err) { console.error("Failed to save file:", err); setError("Failed to save CLAUDE.md file"); setToast({ message: "Failed to save file", type: "error" }); } finally { setSaving(false); } }; const handleBack = () => { if (hasChanges) { const confirmLeave = window.confirm( "You have unsaved changes. Are you sure you want to leave?" ); if (!confirmLeave) return; } onBack(); }; return (
{/* Header */}

{file.relative_path}

Edit project-specific Claude Code system prompt

{/* Error display */} {error && ( {error} )} {/* Editor */}
{loading ? (
) : (
setContent(val || "")} preview="edit" height="100%" visibleDragbar={false} />
)}
{/* Toast Notification */} {toast && ( setToast(null)} /> )}
); }; ================================================ FILE: src/components/ClaudeMemoriesDropdown.tsx ================================================ import React, { useState, useEffect } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { ChevronDown, Edit2, FileText, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { cn } from "@/lib/utils"; import { api, type ClaudeMdFile } from "@/lib/api"; import { formatUnixTimestamp } from "@/lib/date-utils"; interface ClaudeMemoriesDropdownProps { /** * The project path to search for CLAUDE.md files */ projectPath: string; /** * Callback when an edit button is clicked */ onEditFile: (file: ClaudeMdFile) => void; /** * Optional className for styling */ className?: string; } /** * ClaudeMemoriesDropdown component - Shows all CLAUDE.md files in a project * * @example * console.log('Edit file:', file)} * /> */ export const ClaudeMemoriesDropdown: React.FC = ({ projectPath, onEditFile, className, }) => { const [isOpen, setIsOpen] = useState(false); const [files, setFiles] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // Load CLAUDE.md files when dropdown opens useEffect(() => { if (isOpen && files.length === 0) { loadClaudeMdFiles(); } }, [isOpen]); const loadClaudeMdFiles = async () => { try { setLoading(true); setError(null); const foundFiles = await api.findClaudeMdFiles(projectPath); setFiles(foundFiles); } catch (err) { console.error("Failed to load CLAUDE.md files:", err); setError("Failed to load CLAUDE.md files"); } finally { setLoading(false); } }; const formatFileSize = (bytes: number): string => { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; return (
{/* Dropdown Header */} {/* Dropdown Content */} {isOpen && (
{loading ? (
) : error ? (
{error}
) : files.length === 0 ? (
No CLAUDE.md files found in this project
) : (
{files.map((file, index) => (

{file.relative_path}

{formatFileSize(file.size)} Modified {formatUnixTimestamp(file.modified)}
))}
)}
)}
); }; ================================================ FILE: src/components/ClaudeVersionSelector.tsx ================================================ import React, { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; import { api, type ClaudeInstallation } from "@/lib/api"; import { cn } from "@/lib/utils"; import { CheckCircle, HardDrive, Settings, Terminal, Info } from "lucide-react"; interface ClaudeVersionSelectorProps { /** * Currently selected installation path */ selectedPath?: string | null; /** * Callback when an installation is selected */ onSelect: (installation: ClaudeInstallation) => void; /** * Optional className for styling */ className?: string; /** * Whether to show the save button */ showSaveButton?: boolean; /** * Callback when save is clicked */ onSave?: () => void; /** * Whether save is in progress */ isSaving?: boolean; /** * Simplified mode for cleaner UI */ simplified?: boolean; } /** * ClaudeVersionSelector component for selecting Claude Code installations * Supports system installations and user preferences * * @example * setSelectedInstallation(installation)} * /> */ export const ClaudeVersionSelector: React.FC = ({ selectedPath, onSelect, className, showSaveButton = false, onSave, isSaving = false, simplified = false, }) => { const [installations, setInstallations] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedInstallation, setSelectedInstallation] = useState(null); useEffect(() => { loadInstallations(); }, []); useEffect(() => { // Update selected installation when selectedPath changes if (selectedPath && installations.length > 0) { const found = installations.find(i => i.path === selectedPath); if (found) { setSelectedInstallation(found); } } }, [selectedPath, installations]); const loadInstallations = async () => { try { setLoading(true); setError(null); const foundInstallations = await api.listClaudeInstallations(); setInstallations(foundInstallations); // If we have a selected path, find and select it if (selectedPath) { const found = foundInstallations.find(i => i.path === selectedPath); if (found) { setSelectedInstallation(found); } } else if (foundInstallations.length > 0) { // Auto-select the first (best) installation setSelectedInstallation(foundInstallations[0]); onSelect(foundInstallations[0]); } } catch (err) { console.error("Failed to load Claude installations:", err); setError(err instanceof Error ? err.message : "Failed to load Claude installations"); } finally { setLoading(false); } }; const handleInstallationChange = (installationPath: string) => { const installation = installations.find(i => i.path === installationPath); if (installation) { setSelectedInstallation(installation); onSelect(installation); } }; const getInstallationIcon = (installation: ClaudeInstallation) => { switch (installation.installation_type) { case "System": return ; case "Custom": return ; default: return ; } }; const getInstallationTypeColor = (installation: ClaudeInstallation) => { switch (installation.installation_type) { case "System": return "default"; case "Custom": return "secondary"; default: return "outline"; } }; if (loading) { if (simplified) { return (
); } return ( Claude Code Installation Loading available installations...
); } if (error) { if (simplified) { return (

{error}

); } return ( Claude Code Installation Error loading installations
{error}
); } const systemInstallations = installations.filter(i => i.installation_type === "System"); const customInstallations = installations.filter(i => i.installation_type === "Custom"); // Simplified mode - more streamlined UI if (simplified) { return (

Select which version of Claude to use

{selectedInstallation && ( {selectedInstallation.installation_type} )}
{selectedInstallation && (
Path: {selectedInstallation.path}
)}
); } // Original card-based UI return ( Claude Code Installation Choose your preferred Claude Code installation. {/* Available Installations */}
{/* Installation Details */} {selectedInstallation && (
Selected Installation {selectedInstallation.installation_type}
Path: {selectedInstallation.path}
Source: {selectedInstallation.source}
{selectedInstallation.version && (
Version: {selectedInstallation.version}
)}
)} {/* Save Button */} {showSaveButton && ( )}
); }; ================================================ FILE: src/components/CreateAgent.tsx ================================================ import React, { useState } from "react"; import { motion } from "framer-motion"; import { ArrowLeft, Save, Loader2, ChevronDown, Zap, AlertCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Card } from "@/components/ui/card"; import { Toast, ToastContainer } from "@/components/ui/toast"; import { api, type Agent } from "@/lib/api"; import { cn } from "@/lib/utils"; import MDEditor from "@uiw/react-md-editor"; import { type AgentIconName } from "./CCAgents"; import { IconPicker, ICON_MAP } from "./IconPicker"; interface CreateAgentProps { /** * Optional agent to edit (if provided, component is in edit mode) */ agent?: Agent; /** * Callback to go back to the agents list */ onBack: () => void; /** * Callback when agent is created/updated */ onAgentCreated: () => void; /** * Optional className for styling */ className?: string; } /** * CreateAgent component for creating or editing a CC agent * * @example * setView('list')} onAgentCreated={handleCreated} /> */ export const CreateAgent: React.FC = ({ agent, onBack, onAgentCreated, className, }) => { const [name, setName] = useState(agent?.name || ""); const [selectedIcon, setSelectedIcon] = useState((agent?.icon as AgentIconName) || "bot"); const [systemPrompt, setSystemPrompt] = useState(agent?.system_prompt || ""); const [defaultTask, setDefaultTask] = useState(agent?.default_task || ""); const [model, setModel] = useState(agent?.model || "sonnet"); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); const [showIconPicker, setShowIconPicker] = useState(false); const isEditMode = !!agent; const handleSave = async () => { if (!name.trim()) { setError("Agent name is required"); return; } if (!systemPrompt.trim()) { setError("System prompt is required"); return; } try { setSaving(true); setError(null); if (isEditMode && agent.id) { await api.updateAgent( agent.id, name, selectedIcon, systemPrompt, defaultTask || undefined, model ); } else { await api.createAgent( name, selectedIcon, systemPrompt, defaultTask || undefined, model ); } onAgentCreated(); } catch (err) { console.error("Failed to save agent:", err); setError(isEditMode ? "Failed to update agent" : "Failed to create agent"); setToast({ message: isEditMode ? "Failed to update agent" : "Failed to create agent", type: "error" }); } finally { setSaving(false); } }; const handleBack = () => { if ((name !== (agent?.name || "") || selectedIcon !== (agent?.icon || "bot") || systemPrompt !== (agent?.system_prompt || "") || defaultTask !== (agent?.default_task || "") || model !== (agent?.model || "sonnet")) && !confirm("You have unsaved changes. Are you sure you want to leave?")) { return; } onBack(); }; return (
{/* Header */}

{isEditMode ? "Edit Agent" : "Create New Agent"}

{isEditMode ? "Update your Claude Code agent configuration" : "Configure a new Claude Code agent"}

{/* Error display */} {error && ( {error} )} {/* Content */}
{/* Basic Information */}

Basic Information

setName(e.target.value)} placeholder="e.g., Code Assistant" required className="h-9" />
setShowIconPicker(true)} className="h-9 px-3 py-2 bg-background border border-input rounded-md cursor-pointer hover:bg-accent hover:text-accent-foreground transition-colors flex items-center justify-between" >
{(() => { const Icon = ICON_MAP[selectedIcon] || ICON_MAP.bot; return ( <> {selectedIcon} ); })()}
{/* Model Selection */}
setModel("sonnet")} whileTap={{ scale: 0.97 }} transition={{ duration: 0.15 }} className={cn( "flex-1 px-4 py-3 rounded-md border transition-all", model === "sonnet" ? "border-primary bg-primary/10 text-primary" : "border-border hover:border-primary/50 hover:bg-accent" )} >
Claude 4 Sonnet
Faster, efficient for most tasks
setModel("opus")} whileTap={{ scale: 0.97 }} transition={{ duration: 0.15 }} className={cn( "flex-1 px-4 py-3 rounded-md border transition-all", model === "opus" ? "border-primary bg-primary/10 text-primary" : "border-border hover:border-primary/50 hover:bg-accent" )} >
Claude 4 Opus
More capable, better for complex tasks
{/* Configuration */}

Configuration

setDefaultTask(e.target.value)} className="h-9" />

This will be used as the default task placeholder when executing the agent

{/* System Prompt */}

System Prompt

Define the behavior and capabilities of your Claude Code agent

setSystemPrompt(val || "")} preview="edit" height={350} visibleDragbar={false} />
{/* Toast Notification */} {toast && ( setToast(null)} /> )} {/* Icon Picker Dialog */} { setSelectedIcon(iconName as AgentIconName); setShowIconPicker(false); }} isOpen={showIconPicker} onClose={() => setShowIconPicker(false)} />
); }; ================================================ FILE: src/components/CustomTitlebar.tsx ================================================ import React, { useState, useRef, useEffect } from 'react'; import { motion } from 'framer-motion'; import { Settings, Minus, Square, X, Bot, BarChart3, FileText, Network, Info, MoreVertical } from 'lucide-react'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { TooltipProvider, TooltipSimple } from '@/components/ui/tooltip-modern'; interface CustomTitlebarProps { onSettingsClick?: () => void; onAgentsClick?: () => void; onUsageClick?: () => void; onClaudeClick?: () => void; onMCPClick?: () => void; onInfoClick?: () => void; } export const CustomTitlebar: React.FC = ({ onSettingsClick, onAgentsClick, onUsageClick, onClaudeClick, onMCPClick, onInfoClick }) => { const [isHovered, setIsHovered] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const dropdownRef = useRef(null); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { setIsDropdownOpen(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); const handleMinimize = async () => { try { const window = getCurrentWindow(); await window.minimize(); console.log('Window minimized successfully'); } catch (error) { console.error('Failed to minimize window:', error); } }; const handleMaximize = async () => { try { const window = getCurrentWindow(); const isMaximized = await window.isMaximized(); if (isMaximized) { await window.unmaximize(); console.log('Window unmaximized successfully'); } else { await window.maximize(); console.log('Window maximized successfully'); } } catch (error) { console.error('Failed to maximize/unmaximize window:', error); } }; const handleClose = async () => { try { const window = getCurrentWindow(); await window.close(); console.log('Window closed successfully'); } catch (error) { console.error('Failed to close window:', error); } }; return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > {/* Left side - macOS Traffic Light buttons */}
{/* Close button */} {/* Minimize button */} {/* Maximize button */}
{/* Center - Title (hidden) */} {/*
{title}
*/} {/* Right side - Navigation icons with improved spacing */}
{/* Primary actions group */}
{onAgentsClick && ( )} {onUsageClick && ( )}
{/* Visual separator */}
{/* Secondary actions group */}
{onSettingsClick && ( )} {/* Dropdown menu for additional options */}
setIsDropdownOpen(!isDropdownOpen)} whileTap={{ scale: 0.97 }} transition={{ duration: 0.15 }} className="p-2 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors flex items-center gap-1" > {isDropdownOpen && (
{onClaudeClick && ( )} {onMCPClick && ( )} {onInfoClick && ( )}
)}
); }; ================================================ FILE: src/components/ErrorBoundary.tsx ================================================ import React, { Component, ReactNode } from "react"; import { AlertCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; interface ErrorBoundaryProps { children: ReactNode; fallback?: (error: Error, reset: () => void) => ReactNode; } interface ErrorBoundaryState { hasError: boolean; error: Error | null; } /** * Error Boundary component to catch and display React rendering errors */ export class ErrorBoundary extends Component { constructor(props: ErrorBoundaryProps) { super(props); this.state = { hasError: false, error: null }; } static getDerivedStateFromError(error: Error): ErrorBoundaryState { // Update state so the next render will show the fallback UI return { hasError: true, error }; } componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { // Log the error to console console.error("Error caught by boundary:", error, errorInfo); } reset = () => { this.setState({ hasError: false, error: null }); }; render() { if (this.state.hasError && this.state.error) { // Use custom fallback if provided if (this.props.fallback) { return this.props.fallback(this.state.error, this.reset); } // Default error UI return (

Something went wrong

An error occurred while rendering this component.

{this.state.error.message && (
Error details
                        {this.state.error.message}
                      
)}
); } return this.props.children; } } ================================================ FILE: src/components/ExecutionControlBar.tsx ================================================ import React from "react"; import { motion, AnimatePresence } from "framer-motion"; import { StopCircle, Clock, Hash } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; interface ExecutionControlBarProps { isExecuting: boolean; onStop: () => void; totalTokens?: number; elapsedTime?: number; // in seconds className?: string; } /** * Floating control bar shown during agent execution * Provides stop functionality and real-time statistics */ export const ExecutionControlBar: React.FC = ({ isExecuting, onStop, totalTokens = 0, elapsedTime = 0, className }) => { // Format elapsed time const formatTime = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = seconds % 60; if (mins > 0) { return `${mins}m ${secs.toFixed(0)}s`; } return `${secs.toFixed(1)}s`; }; // Format token count const formatTokens = (tokens: number) => { if (tokens >= 1000) { return `${(tokens / 1000).toFixed(1)}k`; } return tokens.toString(); }; return ( {isExecuting && ( {/* Rotating symbol indicator */}
{/* Status text */} Executing... {/* Divider */}
{/* Stats */}
{/* Time */}
{formatTime(elapsedTime)}
{/* Tokens */}
{formatTokens(totalTokens)} tokens
{/* Divider */}
{/* Stop button */} )} ); }; ================================================ FILE: src/components/FilePicker.optimized.tsx ================================================ import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { motion } from "framer-motion"; import { useVirtualizer } from "@tanstack/react-virtual"; import { Button } from "@/components/ui/button"; import { api } from "@/lib/api"; import { X, Folder, File, ArrowLeft, FileCode, FileText, FileImage, Search, ChevronRight } from "lucide-react"; import type { FileEntry } from "@/lib/api"; import { cn } from "@/lib/utils"; // Global caches that persist across component instances const globalDirectoryCache = new Map(); const globalSearchCache = new Map(); interface FilePickerProps { basePath: string; onSelect: (entry: FileEntry) => void; onClose: () => void; initialQuery?: string; className?: string; allowDirectorySelection?: boolean; } // Memoized file icon selector const getFileIcon = (entry: FileEntry) => { if (entry.is_directory) return Folder; const ext = entry.name.split('.').pop()?.toLowerCase(); switch (ext) { case 'js': case 'jsx': case 'ts': case 'tsx': case 'py': case 'java': case 'cpp': case 'c': case 'go': case 'rs': return FileCode; case 'md': case 'txt': case 'json': case 'xml': case 'yaml': case 'yml': return FileText; case 'png': case 'jpg': case 'jpeg': case 'gif': case 'svg': case 'webp': return FileImage; default: return File; } }; const formatFileSize = (bytes: number): string => { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; }; export const FilePicker: React.FC = React.memo(({ basePath, onSelect, onClose, initialQuery = "", className, allowDirectorySelection = false }) => { const [currentPath, setCurrentPath] = useState(basePath); const [entries, setEntries] = useState([]); const [searchQuery, setSearchQuery] = useState(initialQuery); const [selectedIndex, setSelectedIndex] = useState(0); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const searchInputRef = useRef(null); const scrollContainerRef = useRef(null); const searchDebounceRef = useRef(); // Filter and sort entries const displayEntries = useMemo(() => { const filtered = searchQuery.trim() ? entries.filter(entry => entry.name.toLowerCase().includes(searchQuery.toLowerCase()) ) : entries; return filtered.sort((a, b) => { if (a.is_directory !== b.is_directory) { return a.is_directory ? -1 : 1; } return a.name.localeCompare(b.name); }); }, [entries, searchQuery]); // Virtual scrolling setup const virtualizer = useVirtualizer({ count: displayEntries.length, getScrollElement: () => scrollContainerRef.current, estimateSize: () => 32, // Height of each item overscan: 10, // Number of items to render outside viewport }); const virtualItems = virtualizer.getVirtualItems(); // Load directory contents const loadDirectory = useCallback(async (path: string) => { const cacheKey = path; // Check cache first if (globalDirectoryCache.has(cacheKey)) { setEntries(globalDirectoryCache.get(cacheKey)!); return; } setIsLoading(true); setError(null); try { const result = await api.listDirectoryContents(path); globalDirectoryCache.set(cacheKey, result); setEntries(result); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load directory'); } finally { setIsLoading(false); } }, []); // Search functionality const performSearch = useCallback(async (query: string) => { if (!query.trim()) { loadDirectory(currentPath); return; } const cacheKey = `${currentPath}:${query}`; if (globalSearchCache.has(cacheKey)) { setEntries(globalSearchCache.get(cacheKey)!); return; } setIsLoading(true); setError(null); try { const result = await api.searchFiles(currentPath, query); globalSearchCache.set(cacheKey, result); setEntries(result); } catch (err) { setError(err instanceof Error ? err.message : 'Search failed'); } finally { setIsLoading(false); } }, [currentPath, loadDirectory]); // Handle entry click const handleEntryClick = useCallback((entry: FileEntry) => { if (!entry.is_directory || allowDirectorySelection) { onSelect(entry); } }, [onSelect, allowDirectorySelection]); // Handle entry double click const handleEntryDoubleClick = useCallback((entry: FileEntry) => { if (entry.is_directory) { setCurrentPath(entry.path); setSearchQuery(""); setSelectedIndex(0); } else { onSelect(entry); } }, [onSelect]); // Keyboard navigation const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (displayEntries.length === 0) return; switch (e.key) { case 'ArrowUp': e.preventDefault(); setSelectedIndex(prev => Math.max(0, prev - 1)); break; case 'ArrowDown': e.preventDefault(); setSelectedIndex(prev => Math.min(displayEntries.length - 1, prev + 1)); break; case 'Enter': e.preventDefault(); const selectedEntry = displayEntries[selectedIndex]; if (selectedEntry) { if (e.shiftKey || !selectedEntry.is_directory) { handleEntryClick(selectedEntry); } else { handleEntryDoubleClick(selectedEntry); } } break; case 'Escape': e.preventDefault(); onClose(); break; } }, [displayEntries, selectedIndex, handleEntryClick, handleEntryDoubleClick, onClose]); // Debounced search useEffect(() => { if (searchDebounceRef.current) { clearTimeout(searchDebounceRef.current); } searchDebounceRef.current = setTimeout(() => { performSearch(searchQuery); }, 300); return () => { if (searchDebounceRef.current) { clearTimeout(searchDebounceRef.current); } }; }, [searchQuery, performSearch]); // Load initial directory useEffect(() => { loadDirectory(currentPath); }, [currentPath, loadDirectory]); // Focus search input on mount useEffect(() => { searchInputRef.current?.focus(); }, []); // Scroll selected item into view useEffect(() => { const item = virtualizer.getVirtualItems().find( vItem => vItem.index === selectedIndex ); if (item) { virtualizer.scrollToIndex(selectedIndex, { align: 'center' }); } }, [selectedIndex, virtualizer]); return ( {/* Header */}
setSearchQuery(e.target.value)} placeholder="Search files..." className="flex-1 bg-transparent outline-none text-sm" />
{/* Current path */}
{currentPath}
{/* File list with virtual scrolling */}
{isLoading && (
Loading...
)} {error && (
{error}
)} {!isLoading && !error && displayEntries.length === 0 && (
{searchQuery.trim() ? 'No files found' : 'Empty directory'}
)} {displayEntries.length > 0 && (
{virtualItems.map((virtualRow) => { const entry = displayEntries[virtualRow.index]; const Icon = getFileIcon(entry); const isSelected = virtualRow.index === selectedIndex; return (
); })}
)}
{/* Footer */}
{displayEntries.length} {displayEntries.length === 1 ? 'item' : 'items'}
{allowDirectorySelection && (
Shift+Enter to select directory
)}
); }); ================================================ FILE: src/components/FilePicker.tsx ================================================ import React, { useState, useEffect, useRef } from "react"; import { motion } from "framer-motion"; import { Button } from "@/components/ui/button"; import { api } from "@/lib/api"; import { X, Folder, File, ArrowLeft, FileCode, FileText, FileImage, Search, ChevronRight } from "lucide-react"; import type { FileEntry } from "@/lib/api"; import { cn } from "@/lib/utils"; // Global caches that persist across component instances const globalDirectoryCache = new Map(); const globalSearchCache = new Map(); // Note: These caches persist for the lifetime of the application. // In a production app, you might want to: // 1. Add TTL (time-to-live) to expire old entries // 2. Implement LRU (least recently used) eviction // 3. Clear caches when the working directory changes // 4. Add a maximum cache size limit interface FilePickerProps { /** * The base directory path to browse */ basePath: string; /** * Callback when a file/directory is selected */ onSelect: (entry: FileEntry) => void; /** * Callback to close the picker */ onClose: () => void; /** * Initial search query */ initialQuery?: string; /** * Optional className for styling */ className?: string; } // File icon mapping based on extension const getFileIcon = (entry: FileEntry) => { if (entry.is_directory) return Folder; const ext = entry.extension?.toLowerCase(); if (!ext) return File; // Code files if (['ts', 'tsx', 'js', 'jsx', 'py', 'rs', 'go', 'java', 'cpp', 'c', 'h'].includes(ext)) { return FileCode; } // Text/Markdown files if (['md', 'txt', 'json', 'yaml', 'yml', 'toml', 'xml', 'html', 'css'].includes(ext)) { return FileText; } // Image files if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico'].includes(ext)) { return FileImage; } return File; }; // Format file size to human readable const formatFileSize = (bytes: number): string => { if (bytes === 0) return ''; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; }; /** * FilePicker component - File browser with fuzzy search * * @example * console.log('Selected:', entry)} * onClose={() => setShowPicker(false)} * /> */ export const FilePicker: React.FC = ({ basePath, onSelect, onClose, initialQuery = "", className, }) => { const searchQuery = initialQuery; const [currentPath, setCurrentPath] = useState(basePath); const [entries, setEntries] = useState(() => searchQuery.trim() ? [] : globalDirectoryCache.get(basePath) || [] ); const [searchResults, setSearchResults] = useState(() => { if (searchQuery.trim()) { const cacheKey = `${basePath}:${searchQuery}`; return globalSearchCache.get(cacheKey) || []; } return []; }); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [pathHistory, setPathHistory] = useState([basePath]); const [selectedIndex, setSelectedIndex] = useState(0); const [isShowingCached, setIsShowingCached] = useState(() => { // Check if we're showing cached data on mount if (searchQuery.trim()) { const cacheKey = `${basePath}:${searchQuery}`; return globalSearchCache.has(cacheKey); } return globalDirectoryCache.has(basePath); }); const searchDebounceRef = useRef(null); const fileListRef = useRef(null); // Computed values const displayEntries = searchQuery.trim() ? searchResults : entries; const canGoBack = pathHistory.length > 1; // Get relative path for display const relativePath = currentPath.startsWith(basePath) ? currentPath.slice(basePath.length) || '/' : currentPath; // Load directory contents useEffect(() => { loadDirectory(currentPath); }, [currentPath]); // Debounced search useEffect(() => { if (searchDebounceRef.current) { clearTimeout(searchDebounceRef.current); } if (searchQuery.trim()) { const cacheKey = `${basePath}:${searchQuery}`; // Immediately show cached results if available if (globalSearchCache.has(cacheKey)) { console.log('[FilePicker] Immediately showing cached search results for:', searchQuery); setSearchResults(globalSearchCache.get(cacheKey) || []); setIsShowingCached(true); setError(null); } // Schedule fresh search after debounce searchDebounceRef.current = setTimeout(() => { performSearch(searchQuery); }, 300); } else { setSearchResults([]); setIsShowingCached(false); } return () => { if (searchDebounceRef.current) { clearTimeout(searchDebounceRef.current); } }; }, [searchQuery, basePath]); // Reset selected index when entries change useEffect(() => { setSelectedIndex(0); }, [entries, searchResults]); // Keyboard navigation useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const displayEntries = searchQuery.trim() ? searchResults : entries; switch (e.key) { case 'Escape': e.preventDefault(); onClose(); break; case 'Enter': e.preventDefault(); // Enter always selects the current item (file or directory) if (displayEntries.length > 0 && selectedIndex < displayEntries.length) { onSelect(displayEntries[selectedIndex]); } break; case 'ArrowUp': e.preventDefault(); setSelectedIndex(prev => Math.max(0, prev - 1)); break; case 'ArrowDown': e.preventDefault(); setSelectedIndex(prev => Math.min(displayEntries.length - 1, prev + 1)); break; case 'ArrowRight': e.preventDefault(); // Right arrow enters directories if (displayEntries.length > 0 && selectedIndex < displayEntries.length) { const entry = displayEntries[selectedIndex]; if (entry.is_directory) { navigateToDirectory(entry.path); } } break; case 'ArrowLeft': e.preventDefault(); // Left arrow goes back to parent directory if (canGoBack) { navigateBack(); } break; } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [entries, searchResults, selectedIndex, searchQuery, canGoBack]); // Scroll selected item into view useEffect(() => { if (fileListRef.current) { const selectedElement = fileListRef.current.querySelector(`[data-index="${selectedIndex}"]`); if (selectedElement) { selectedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } } }, [selectedIndex]); const loadDirectory = async (path: string) => { try { console.log('[FilePicker] Loading directory:', path); // Check cache first and show immediately if (globalDirectoryCache.has(path)) { console.log('[FilePicker] Showing cached contents for:', path); setEntries(globalDirectoryCache.get(path) || []); setIsShowingCached(true); setError(null); } else { // Only show loading if we don't have cached data setIsLoading(true); } // Always fetch fresh data in background const contents = await api.listDirectoryContents(path); console.log('[FilePicker] Loaded fresh contents:', contents.length, 'items'); // Cache the results globalDirectoryCache.set(path, contents); // Update with fresh data setEntries(contents); setIsShowingCached(false); setError(null); } catch (err) { console.error('[FilePicker] Failed to load directory:', path, err); console.error('[FilePicker] Error details:', err); // Only set error if we don't have cached data to show if (!globalDirectoryCache.has(path)) { setError(err instanceof Error ? err.message : 'Failed to load directory'); } } finally { setIsLoading(false); } }; const performSearch = async (query: string) => { try { console.log('[FilePicker] Searching for:', query, 'in:', basePath); // Create cache key that includes both query and basePath const cacheKey = `${basePath}:${query}`; // Check cache first and show immediately if (globalSearchCache.has(cacheKey)) { console.log('[FilePicker] Showing cached search results for:', query); setSearchResults(globalSearchCache.get(cacheKey) || []); setIsShowingCached(true); setError(null); } else { // Only show loading if we don't have cached data setIsLoading(true); } // Always fetch fresh results in background const results = await api.searchFiles(basePath, query); console.log('[FilePicker] Fresh search results:', results.length, 'items'); // Cache the results globalSearchCache.set(cacheKey, results); // Update with fresh results setSearchResults(results); setIsShowingCached(false); setError(null); } catch (err) { console.error('[FilePicker] Search failed:', query, err); // Only set error if we don't have cached data to show const cacheKey = `${basePath}:${query}`; if (!globalSearchCache.has(cacheKey)) { setError(err instanceof Error ? err.message : 'Search failed'); } } finally { setIsLoading(false); } }; const navigateToDirectory = (path: string) => { setCurrentPath(path); setPathHistory(prev => [...prev, path]); }; const navigateBack = () => { if (pathHistory.length > 1) { const newHistory = [...pathHistory]; newHistory.pop(); // Remove current const previousPath = newHistory[newHistory.length - 1]; // Don't go beyond the base path if (previousPath.startsWith(basePath) || previousPath === basePath) { setCurrentPath(previousPath); setPathHistory(newHistory); } } }; const handleEntryClick = (entry: FileEntry) => { // Single click always selects (file or directory) onSelect(entry); }; const handleEntryDoubleClick = (entry: FileEntry) => { // Double click navigates into directories if (entry.is_directory) { navigateToDirectory(entry.path); } }; return ( {/* Header */}
{relativePath}
{/* File List */}
{/* Show loading only if no cached data */} {isLoading && displayEntries.length === 0 && (
Loading...
)} {/* Show subtle indicator when displaying cached data while fetching fresh */} {isShowingCached && isLoading && displayEntries.length > 0 && (
updating...
)} {error && displayEntries.length === 0 && (
{error}
)} {!isLoading && !error && displayEntries.length === 0 && (
{searchQuery.trim() ? 'No files found' : 'Empty directory'}
)} {displayEntries.length > 0 && (
{displayEntries.map((entry, index) => { const Icon = getFileIcon(entry); const isSearching = searchQuery.trim() !== ''; const isSelected = index === selectedIndex; return ( ); })}
)}
{/* Footer */}

↑↓ Navigate • Enter Select • → Enter Directory • ← Go Back • Esc Close

); }; ================================================ FILE: src/components/FloatingPromptInput.tsx ================================================ import React, { useState, useRef, useEffect } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { Send, Maximize2, Minimize2, ChevronUp, Sparkles, Zap, Square, Brain, Lightbulb, Cpu, Rocket, } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Popover } from "@/components/ui/popover"; import { Textarea } from "@/components/ui/textarea"; import { TooltipProvider, TooltipSimple, Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip-modern"; import { FilePicker } from "./FilePicker"; import { SlashCommandPicker } from "./SlashCommandPicker"; import { ImagePreview } from "./ImagePreview"; import { type FileEntry, type SlashCommand } from "@/lib/api"; // Conditional import for Tauri webview window let tauriGetCurrentWebviewWindow: any; try { if (typeof window !== 'undefined' && window.__TAURI__) { tauriGetCurrentWebviewWindow = require("@tauri-apps/api/webviewWindow").getCurrentWebviewWindow; } } catch (e) { console.log('[FloatingPromptInput] Tauri webview API not available, using web mode'); } // Web-compatible replacement const getCurrentWebviewWindow = tauriGetCurrentWebviewWindow || (() => ({ listen: () => Promise.resolve(() => {}) })); interface FloatingPromptInputProps { /** * Callback when prompt is sent */ onSend: (prompt: string, model: "sonnet" | "opus") => void; /** * Whether the input is loading */ isLoading?: boolean; /** * Whether the input is disabled */ disabled?: boolean; /** * Default model to select */ defaultModel?: "sonnet" | "opus"; /** * Project path for file picker */ projectPath?: string; /** * Optional className for styling */ className?: string; /** * Callback when cancel is clicked (only during loading) */ onCancel?: () => void; /** * Extra menu items to display in the prompt bar */ extraMenuItems?: React.ReactNode; } export interface FloatingPromptInputRef { addImage: (imagePath: string) => void; } /** * Thinking mode type definition */ type ThinkingMode = "auto" | "think" | "think_hard" | "think_harder" | "ultrathink"; /** * Thinking mode configuration */ type ThinkingModeConfig = { id: ThinkingMode; name: string; description: string; level: number; // 0-4 for visual indicator phrase?: string; // The phrase to append icon: React.ReactNode; color: string; shortName: string; }; const THINKING_MODES: ThinkingModeConfig[] = [ { id: "auto", name: "Auto", description: "Let Claude decide", level: 0, icon: , color: "text-muted-foreground", shortName: "A" }, { id: "think", name: "Think", description: "Basic reasoning", level: 1, phrase: "think", icon: , color: "text-primary", shortName: "T" }, { id: "think_hard", name: "Think Hard", description: "Deeper analysis", level: 2, phrase: "think hard", icon: , color: "text-primary", shortName: "T+" }, { id: "think_harder", name: "Think Harder", description: "Extensive reasoning", level: 3, phrase: "think harder", icon: , color: "text-primary", shortName: "T++" }, { id: "ultrathink", name: "Ultrathink", description: "Maximum computation", level: 4, phrase: "ultrathink", icon: , color: "text-primary", shortName: "Ultra" } ]; /** * ThinkingModeIndicator component - Shows visual indicator bars for thinking level */ const ThinkingModeIndicator: React.FC<{ level: number; color?: string }> = ({ level, color: _color }) => { const getBarColor = (barIndex: number) => { if (barIndex > level) return "bg-muted"; return "bg-primary"; }; return (
{[1, 2, 3, 4].map((i) => (
))}
); }; type Model = { id: "sonnet" | "opus"; name: string; description: string; icon: React.ReactNode; shortName: string; color: string; }; const MODELS: Model[] = [ { id: "sonnet", name: "Claude 4 Sonnet", description: "Faster, efficient for most tasks", icon: , shortName: "S", color: "text-primary" }, { id: "opus", name: "Claude 4 Opus", description: "More capable, better for complex tasks", icon: , shortName: "O", color: "text-primary" } ]; /** * FloatingPromptInput component - Fixed position prompt input with model picker * * @example * const promptRef = useRef(null); * console.log('Send:', prompt, model)} * isLoading={false} * /> */ const FloatingPromptInputInner = ( { onSend, isLoading = false, disabled = false, defaultModel = "sonnet", projectPath, className, onCancel, extraMenuItems, }: FloatingPromptInputProps, ref: React.Ref, ) => { const [prompt, setPrompt] = useState(""); const [selectedModel, setSelectedModel] = useState<"sonnet" | "opus">(defaultModel); const [selectedThinkingMode, setSelectedThinkingMode] = useState("auto"); const [isExpanded, setIsExpanded] = useState(false); const [modelPickerOpen, setModelPickerOpen] = useState(false); const [thinkingModePickerOpen, setThinkingModePickerOpen] = useState(false); const [showFilePicker, setShowFilePicker] = useState(false); const [filePickerQuery, setFilePickerQuery] = useState(""); const [showSlashCommandPicker, setShowSlashCommandPicker] = useState(false); const [slashCommandQuery, setSlashCommandQuery] = useState(""); const [cursorPosition, setCursorPosition] = useState(0); const [embeddedImages, setEmbeddedImages] = useState([]); const [dragActive, setDragActive] = useState(false); const textareaRef = useRef(null); const expandedTextareaRef = useRef(null); const unlistenDragDropRef = useRef<(() => void) | null>(null); const [textareaHeight, setTextareaHeight] = useState(48); const isIMEComposingRef = useRef(false); // Expose a method to add images programmatically React.useImperativeHandle( ref, () => ({ addImage: (imagePath: string) => { setPrompt(currentPrompt => { const existingPaths = extractImagePaths(currentPrompt); if (existingPaths.includes(imagePath)) { return currentPrompt; // Image already added } // Wrap path in quotes if it contains spaces const mention = imagePath.includes(' ') ? `@"${imagePath}"` : `@${imagePath}`; const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mention + ' '; // Focus the textarea setTimeout(() => { const target = isExpanded ? expandedTextareaRef.current : textareaRef.current; target?.focus(); target?.setSelectionRange(newPrompt.length, newPrompt.length); }, 0); return newPrompt; }); } }), [isExpanded] ); // Helper function to check if a file is an image const isImageFile = (path: string): boolean => { // Check if it's a data URL if (path.startsWith('data:image/')) { return true; } // Otherwise check file extension const ext = path.split('.').pop()?.toLowerCase(); return ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp'].includes(ext || ''); }; // Extract image paths from prompt text const extractImagePaths = (text: string): string[] => { console.log('[extractImagePaths] Input text length:', text.length); // Updated regex to handle both quoted and unquoted paths // Pattern 1: @"path with spaces or data URLs" - quoted paths // Pattern 2: @path - unquoted paths (continues until @ or end) const quotedRegex = /@"([^"]+)"/g; const unquotedRegex = /@([^@\n\s]+)/g; const pathsSet = new Set(); // Use Set to ensure uniqueness // First, extract quoted paths (including data URLs) let matches = Array.from(text.matchAll(quotedRegex)); console.log('[extractImagePaths] Quoted matches:', matches.length); for (const match of matches) { const path = match[1]; // No need to trim, quotes preserve exact path console.log('[extractImagePaths] Processing quoted path:', path.startsWith('data:') ? 'data URL' : path); // For data URLs, use as-is; for file paths, convert to absolute const fullPath = path.startsWith('data:') ? path : (path.startsWith('/') ? path : (projectPath ? `${projectPath}/${path}` : path)); if (isImageFile(fullPath)) { pathsSet.add(fullPath); } } // Remove quoted mentions from text to avoid double-matching let textWithoutQuoted = text.replace(quotedRegex, ''); // Then extract unquoted paths (typically file paths) matches = Array.from(textWithoutQuoted.matchAll(unquotedRegex)); console.log('[extractImagePaths] Unquoted matches:', matches.length); for (const match of matches) { const path = match[1].trim(); // Skip if it looks like a data URL fragment (shouldn't happen with proper quoting) if (path.includes('data:')) continue; console.log('[extractImagePaths] Processing unquoted path:', path); // Convert relative path to absolute if needed const fullPath = path.startsWith('/') ? path : (projectPath ? `${projectPath}/${path}` : path); if (isImageFile(fullPath)) { pathsSet.add(fullPath); } } const uniquePaths = Array.from(pathsSet); console.log('[extractImagePaths] Final extracted paths (unique):', uniquePaths.length); return uniquePaths; }; // Update embedded images when prompt changes useEffect(() => { console.log('[useEffect] Prompt changed:', prompt); const imagePaths = extractImagePaths(prompt); console.log('[useEffect] Setting embeddedImages to:', imagePaths); setEmbeddedImages(imagePaths); // Auto-resize on prompt change (handles paste, programmatic changes, etc.) if (textareaRef.current && !isExpanded) { textareaRef.current.style.height = 'auto'; const scrollHeight = textareaRef.current.scrollHeight; const newHeight = Math.min(Math.max(scrollHeight, 48), 240); setTextareaHeight(newHeight); textareaRef.current.style.height = `${newHeight}px`; } }, [prompt, projectPath, isExpanded]); // Set up Tauri drag-drop event listener useEffect(() => { // This effect runs only once on component mount to set up the listener. let lastDropTime = 0; const setupListener = async () => { try { // If a listener from a previous mount/render is still around, clean it up. if (unlistenDragDropRef.current) { unlistenDragDropRef.current(); } const webview = getCurrentWebviewWindow(); unlistenDragDropRef.current = await webview.onDragDropEvent((event: any) => { if (event.payload.type === 'enter' || event.payload.type === 'over') { setDragActive(true); } else if (event.payload.type === 'leave') { setDragActive(false); } else if (event.payload.type === 'drop' && event.payload.paths) { setDragActive(false); const currentTime = Date.now(); if (currentTime - lastDropTime < 200) { // This debounce is crucial to handle the storm of drop events // that Tauri/OS can fire for a single user action. return; } lastDropTime = currentTime; const droppedPaths = event.payload.paths as string[]; const imagePaths = droppedPaths.filter(isImageFile); if (imagePaths.length > 0) { setPrompt(currentPrompt => { const existingPaths = extractImagePaths(currentPrompt); const newPaths = imagePaths.filter(p => !existingPaths.includes(p)); if (newPaths.length === 0) { return currentPrompt; // All dropped images are already in the prompt } // Wrap paths with spaces in quotes for clarity const mentionsToAdd = newPaths.map(p => { // If path contains spaces, wrap in quotes if (p.includes(' ')) { return `@"${p}"`; } return `@${p}`; }).join(' '); const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mentionsToAdd + ' '; setTimeout(() => { const target = isExpanded ? expandedTextareaRef.current : textareaRef.current; target?.focus(); target?.setSelectionRange(newPrompt.length, newPrompt.length); }, 0); return newPrompt; }); } } }); } catch (error) { console.error('Failed to set up Tauri drag-drop listener:', error); } }; setupListener(); return () => { // On unmount, ensure we clean up the listener. if (unlistenDragDropRef.current) { unlistenDragDropRef.current(); unlistenDragDropRef.current = null; } }; }, []); // Empty dependency array ensures this runs only on mount/unmount. useEffect(() => { // Focus the appropriate textarea when expanded state changes if (isExpanded && expandedTextareaRef.current) { expandedTextareaRef.current.focus(); } else if (!isExpanded && textareaRef.current) { textareaRef.current.focus(); } }, [isExpanded]); const handleTextChange = (e: React.ChangeEvent) => { const newValue = e.target.value; const newCursorPosition = e.target.selectionStart || 0; // Auto-resize textarea based on content if (textareaRef.current && !isExpanded) { // Reset height to auto to get the actual scrollHeight textareaRef.current.style.height = 'auto'; const scrollHeight = textareaRef.current.scrollHeight; // Set min height to 48px and max to 240px (about 10 lines) const newHeight = Math.min(Math.max(scrollHeight, 48), 240); setTextareaHeight(newHeight); textareaRef.current.style.height = `${newHeight}px`; } // Check if / was just typed at the beginning of input or after whitespace if (newValue.length > prompt.length && newValue[newCursorPosition - 1] === '/') { // Check if it's at the start or after whitespace const isStartOfCommand = newCursorPosition === 1 || (newCursorPosition > 1 && /\s/.test(newValue[newCursorPosition - 2])); if (isStartOfCommand) { console.log('[FloatingPromptInput] / detected for slash command'); setShowSlashCommandPicker(true); setSlashCommandQuery(""); setCursorPosition(newCursorPosition); } } // Check if @ was just typed if (projectPath?.trim() && newValue.length > prompt.length && newValue[newCursorPosition - 1] === '@') { console.log('[FloatingPromptInput] @ detected, projectPath:', projectPath); setShowFilePicker(true); setFilePickerQuery(""); setCursorPosition(newCursorPosition); } // Check if we're typing after / (for slash command search) if (showSlashCommandPicker && newCursorPosition >= cursorPosition) { // Find the / position before cursor let slashPosition = -1; for (let i = newCursorPosition - 1; i >= 0; i--) { if (newValue[i] === '/') { slashPosition = i; break; } // Stop if we hit whitespace (new word) if (newValue[i] === ' ' || newValue[i] === '\n') { break; } } if (slashPosition !== -1) { const query = newValue.substring(slashPosition + 1, newCursorPosition); setSlashCommandQuery(query); } else { // / was removed or cursor moved away setShowSlashCommandPicker(false); setSlashCommandQuery(""); } } // Check if we're typing after @ (for search query) if (showFilePicker && newCursorPosition >= cursorPosition) { // Find the @ position before cursor let atPosition = -1; for (let i = newCursorPosition - 1; i >= 0; i--) { if (newValue[i] === '@') { atPosition = i; break; } // Stop if we hit whitespace (new word) if (newValue[i] === ' ' || newValue[i] === '\n') { break; } } if (atPosition !== -1) { const query = newValue.substring(atPosition + 1, newCursorPosition); setFilePickerQuery(query); } else { // @ was removed or cursor moved away setShowFilePicker(false); setFilePickerQuery(""); } } setPrompt(newValue); setCursorPosition(newCursorPosition); }; const handleFileSelect = (entry: FileEntry) => { if (textareaRef.current) { // Find the @ position before cursor let atPosition = -1; for (let i = cursorPosition - 1; i >= 0; i--) { if (prompt[i] === '@') { atPosition = i; break; } // Stop if we hit whitespace (new word) if (prompt[i] === ' ' || prompt[i] === '\n') { break; } } if (atPosition === -1) { // @ not found, this shouldn't happen but handle gracefully console.error('[FloatingPromptInput] @ position not found'); return; } // Replace the @ and partial query with the selected path (file or directory) const textarea = textareaRef.current; const beforeAt = prompt.substring(0, atPosition); const afterCursor = prompt.substring(cursorPosition); const relativePath = entry.path.startsWith(projectPath || '') ? entry.path.slice((projectPath || '').length + 1) : entry.path; const newPrompt = `${beforeAt}@${relativePath} ${afterCursor}`; setPrompt(newPrompt); setShowFilePicker(false); setFilePickerQuery(""); // Focus back on textarea and set cursor position after the inserted path setTimeout(() => { textarea.focus(); const newCursorPos = beforeAt.length + relativePath.length + 2; // +2 for @ and space textarea.setSelectionRange(newCursorPos, newCursorPos); }, 0); } }; const handleFilePickerClose = () => { setShowFilePicker(false); setFilePickerQuery(""); // Return focus to textarea setTimeout(() => { textareaRef.current?.focus(); }, 0); }; const handleSlashCommandSelect = (command: SlashCommand) => { const textarea = isExpanded ? expandedTextareaRef.current : textareaRef.current; if (!textarea) return; // Find the / position before cursor let slashPosition = -1; for (let i = cursorPosition - 1; i >= 0; i--) { if (prompt[i] === '/') { slashPosition = i; break; } // Stop if we hit whitespace (new word) if (prompt[i] === ' ' || prompt[i] === '\n') { break; } } if (slashPosition === -1) { console.error('[FloatingPromptInput] / position not found'); return; } // Simply insert the command syntax const beforeSlash = prompt.substring(0, slashPosition); const afterCursor = prompt.substring(cursorPosition); if (command.accepts_arguments) { // Insert command with placeholder for arguments const newPrompt = `${beforeSlash}${command.full_command} `; setPrompt(newPrompt); setShowSlashCommandPicker(false); setSlashCommandQuery(""); // Focus and position cursor after the command setTimeout(() => { textarea.focus(); const newCursorPos = beforeSlash.length + command.full_command.length + 1; textarea.setSelectionRange(newCursorPos, newCursorPos); }, 0); } else { // Insert command and close picker const newPrompt = `${beforeSlash}${command.full_command} ${afterCursor}`; setPrompt(newPrompt); setShowSlashCommandPicker(false); setSlashCommandQuery(""); // Focus and position cursor after the command setTimeout(() => { textarea.focus(); const newCursorPos = beforeSlash.length + command.full_command.length + 1; textarea.setSelectionRange(newCursorPos, newCursorPos); }, 0); } }; const handleSlashCommandPickerClose = () => { setShowSlashCommandPicker(false); setSlashCommandQuery(""); // Return focus to textarea setTimeout(() => { const textarea = isExpanded ? expandedTextareaRef.current : textareaRef.current; textarea?.focus(); }, 0); }; const handleCompositionStart = () => { isIMEComposingRef.current = true; }; const handleCompositionEnd = () => { setTimeout(() => { isIMEComposingRef.current = false; }, 0); }; const isIMEInteraction = (event?: React.KeyboardEvent) => { if (isIMEComposingRef.current) { return true; } if (!event) { return false; } const nativeEvent = event.nativeEvent; if (nativeEvent.isComposing) { return true; } const key = nativeEvent.key; if (key === 'Process' || key === 'Unidentified') { return true; } const keyboardEvent = nativeEvent as unknown as KeyboardEvent; const keyCode = keyboardEvent.keyCode ?? (keyboardEvent as unknown as { which?: number }).which; if (keyCode === 229) { return true; } return false; }; const handleSend = () => { if (isIMEInteraction()) { return; } if (prompt.trim() && !disabled) { let finalPrompt = prompt.trim(); // Append thinking phrase if not auto mode const thinkingMode = THINKING_MODES.find(m => m.id === selectedThinkingMode); if (thinkingMode && thinkingMode.phrase) { finalPrompt = `${finalPrompt}.\n\n${thinkingMode.phrase}.`; } onSend(finalPrompt, selectedModel); setPrompt(""); setEmbeddedImages([]); setTextareaHeight(48); // Reset height after sending } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (showFilePicker && e.key === 'Escape') { e.preventDefault(); setShowFilePicker(false); setFilePickerQuery(""); return; } if (showSlashCommandPicker && e.key === 'Escape') { e.preventDefault(); setShowSlashCommandPicker(false); setSlashCommandQuery(""); return; } // Add keyboard shortcut for expanding if (e.key === 'e' && (e.ctrlKey || e.metaKey) && e.shiftKey) { e.preventDefault(); setIsExpanded(true); return; } if ( e.key === "Enter" && !e.shiftKey && !isExpanded && !showFilePicker && !showSlashCommandPicker ) { if (isIMEInteraction(e)) { return; } e.preventDefault(); handleSend(); } }; const handlePaste = async (e: React.ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; for (const item of items) { if (item.type.startsWith('image/')) { e.preventDefault(); // Get the image blob const blob = item.getAsFile(); if (!blob) continue; try { // Convert blob to base64 const reader = new FileReader(); reader.onload = () => { const base64Data = reader.result as string; // Add the base64 data URL directly to the prompt setPrompt(currentPrompt => { // Use the data URL directly as the image reference const mention = `@"${base64Data}"`; const newPrompt = currentPrompt + (currentPrompt.endsWith(' ') || currentPrompt === '' ? '' : ' ') + mention + ' '; // Focus the textarea and move cursor to end setTimeout(() => { const target = isExpanded ? expandedTextareaRef.current : textareaRef.current; target?.focus(); target?.setSelectionRange(newPrompt.length, newPrompt.length); }, 0); return newPrompt; }); }; reader.readAsDataURL(blob); } catch (error) { console.error('Failed to paste image:', error); } } } }; // Browser drag and drop handlers - just prevent default behavior // Actual file handling is done via Tauri's window-level drag-drop events const handleDrag = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); // Visual feedback is handled by Tauri events }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); // File processing is handled by Tauri's onDragDropEvent }; const handleRemoveImage = (index: number) => { // Remove the corresponding @mention from the prompt const imagePath = embeddedImages[index]; // For data URLs, we need to handle them specially since they're always quoted if (imagePath.startsWith('data:')) { // Simply remove the exact quoted data URL const quotedPath = `@"${imagePath}"`; const newPrompt = prompt.replace(quotedPath, '').trim(); setPrompt(newPrompt); return; } // For file paths, use the original logic const escapedPath = imagePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedRelativePath = imagePath.replace(projectPath + '/', '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // Create patterns for both quoted and unquoted mentions const patterns = [ // Quoted full path new RegExp(`@"${escapedPath}"\\s?`, 'g'), // Unquoted full path new RegExp(`@${escapedPath}\\s?`, 'g'), // Quoted relative path new RegExp(`@"${escapedRelativePath}"\\s?`, 'g'), // Unquoted relative path new RegExp(`@${escapedRelativePath}\\s?`, 'g') ]; let newPrompt = prompt; for (const pattern of patterns) { newPrompt = newPrompt.replace(pattern, ''); } setPrompt(newPrompt.trim()); }; const selectedModelData = MODELS.find(m => m.id === selectedModel) || MODELS[0]; return ( <> {/* Expanded Modal */} {isExpanded && ( setIsExpanded(false)} > e.stopPropagation()} >

Compose your prompt

{/* Image previews in expanded mode */} {embeddedImages.length > 0 && ( )}