Initial public release
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
name: opencode
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
opencode:
|
||||||
|
if: |
|
||||||
|
contains(github.event.comment.body, ' /oc') ||
|
||||||
|
startsWith(github.event.comment.body, '/oc') ||
|
||||||
|
contains(github.event.comment.body, ' /opencode') ||
|
||||||
|
startsWith(github.event.comment.body, '/opencode')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Run opencode
|
||||||
|
uses: sst/opencode/github@latest
|
||||||
|
env:
|
||||||
|
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||||
|
with:
|
||||||
|
model: zai-coding-plan/glm-4.6
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Version to release (e.g., 0.1.0)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
dry_run:
|
||||||
|
description: 'Dry run (skip publishing)'
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_INCREMENTAL: 0
|
||||||
|
RUST_BACKTRACE: short
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
create-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
release_id: ${{ steps.create_release.outputs.id }}
|
||||||
|
release_upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||||
|
version: ${{ steps.get_version.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Get version
|
||||||
|
id: get_version
|
||||||
|
run: |
|
||||||
|
if [[ -n "${{ github.event.inputs.version }}" ]]; then
|
||||||
|
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
|
||||||
|
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||||
|
echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "version=0.0.0-dev" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Extract changelog for release
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.get_version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
node - <<'NODE'
|
||||||
|
const fs = require('fs');
|
||||||
|
const version = process.env.VERSION;
|
||||||
|
const changelogPath = 'CHANGELOG.md';
|
||||||
|
if (!fs.existsSync(changelogPath)) {
|
||||||
|
throw new Error('CHANGELOG.md not found; add it before releasing.');
|
||||||
|
}
|
||||||
|
const changelog = fs.readFileSync(changelogPath, 'utf8');
|
||||||
|
const sections = changelog.split(/^## /m);
|
||||||
|
const section = sections.find(s => s.startsWith('[' + version + ']'));
|
||||||
|
if (!section) {
|
||||||
|
throw new Error('Changelog section [' + version + '] not found. Add a section like "## [' + version + '] - YYYY-MM-DD".');
|
||||||
|
}
|
||||||
|
const content = ('## ' + section).trim();
|
||||||
|
fs.mkdirSync('artifacts', { recursive: true });
|
||||||
|
fs.writeFileSync('artifacts/release-notes.md', content + '\n');
|
||||||
|
NODE
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
id: create_release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: v${{ steps.get_version.outputs.version }}
|
||||||
|
draft: true
|
||||||
|
generate_release_notes: false
|
||||||
|
body_path: artifacts/release-notes.md
|
||||||
|
name: OpenChamber v${{ steps.get_version.outputs.version }}
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
build-desktop-macos:
|
||||||
|
needs: create-release
|
||||||
|
runs-on: macos-14
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
uses: swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: packages/desktop/src-tauri
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Install Apple Certificate
|
||||||
|
env:
|
||||||
|
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
# Create temporary keychain
|
||||||
|
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||||
|
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
|
||||||
|
|
||||||
|
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
|
||||||
|
echo "$APPLE_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12
|
||||||
|
security import $RUNNER_TEMP/certificate.p12 \
|
||||||
|
-P "$APPLE_CERTIFICATE_PASSWORD" \
|
||||||
|
-A -t cert -f pkcs12 \
|
||||||
|
-k "$KEYCHAIN_PATH"
|
||||||
|
|
||||||
|
security list-keychain -d user -s "$KEYCHAIN_PATH"
|
||||||
|
security set-key-partition-list -S apple-tool:,apple:,codesign: \
|
||||||
|
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||||
|
|
||||||
|
- name: Set up notarization credentials
|
||||||
|
env:
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
# Validate secrets are set
|
||||||
|
if [ -z "$APPLE_ID" ] || [ -z "$APPLE_TEAM_ID" ] || [ -z "$APPLE_PASSWORD" ]; then
|
||||||
|
echo "Error: Missing Apple notarization credentials"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
xcrun notarytool store-credentials "openchamber-notarize" \
|
||||||
|
--apple-id "$APPLE_ID" \
|
||||||
|
--team-id "$APPLE_TEAM_ID" \
|
||||||
|
--password "$APPLE_PASSWORD"
|
||||||
|
|
||||||
|
- name: Build UI package
|
||||||
|
run: pnpm -C packages/ui run build
|
||||||
|
|
||||||
|
- name: Build Desktop app
|
||||||
|
run: pnpm desktop:build
|
||||||
|
env:
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
|
||||||
|
- name: Prepare release artifacts
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
VERSION="${{ needs.create-release.outputs.version }}"
|
||||||
|
|
||||||
|
# Copy DMG
|
||||||
|
cp packages/desktop/src-tauri/target/release/bundle/dmg/*.dmg artifacts/ 2>/dev/null || true
|
||||||
|
|
||||||
|
# Copy tar.gz and signature for updater
|
||||||
|
cp packages/desktop/src-tauri/target/release/bundle/macos/*.tar.gz artifacts/ 2>/dev/null || true
|
||||||
|
cp packages/desktop/src-tauri/target/release/bundle/macos/*.tar.gz.sig artifacts/ 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Generate update manifest
|
||||||
|
run: |
|
||||||
|
VERSION="${{ needs.create-release.outputs.version }}"
|
||||||
|
|
||||||
|
# Find the signature file
|
||||||
|
SIG_FILE=$(find artifacts -name "*.tar.gz.sig" | head -1)
|
||||||
|
if [ -f "$SIG_FILE" ]; then
|
||||||
|
SIGNATURE=$(cat "$SIG_FILE")
|
||||||
|
else
|
||||||
|
SIGNATURE=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find the tar.gz file name
|
||||||
|
TAR_FILE=$(find artifacts -name "*.tar.gz" ! -name "*.sig" | head -1)
|
||||||
|
TAR_NAME=$(basename "$TAR_FILE" 2>/dev/null || echo "OpenChamber.app.tar.gz")
|
||||||
|
|
||||||
|
cat > artifacts/latest.json << EOF
|
||||||
|
{
|
||||||
|
"version": "${VERSION}",
|
||||||
|
"notes": "See release notes at https://github.com/${{ github.repository }}/releases/tag/v${VERSION}",
|
||||||
|
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||||
|
"platforms": {
|
||||||
|
"darwin-aarch64": {
|
||||||
|
"signature": "${SIGNATURE}",
|
||||||
|
"url": "https://github.com/${{ github.repository }}/releases/download/v${VERSION}/${TAR_NAME}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Generated latest.json:"
|
||||||
|
cat artifacts/latest.json
|
||||||
|
|
||||||
|
- name: Upload release assets
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: v${{ needs.create-release.outputs.version }}
|
||||||
|
files: |
|
||||||
|
artifacts/*.dmg
|
||||||
|
artifacts/*.tar.gz
|
||||||
|
artifacts/*.tar.gz.sig
|
||||||
|
artifacts/latest.json
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
publish-npm:
|
||||||
|
needs: create-release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'pnpm'
|
||||||
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build packages
|
||||||
|
run: pnpm run build
|
||||||
|
|
||||||
|
- name: Create npm tarball
|
||||||
|
working-directory: packages/web
|
||||||
|
run: npm pack
|
||||||
|
|
||||||
|
- name: Upload npm tarball to release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: v${{ needs.create-release.outputs.version }}
|
||||||
|
files: packages/web/*.tgz
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Publish to npm
|
||||||
|
if: ${{ github.event.inputs.dry_run != 'true' }}
|
||||||
|
working-directory: packages/web
|
||||||
|
run: npm publish --access public
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
|
finalize-release:
|
||||||
|
needs: [create-release, build-desktop-macos, publish-npm]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Publish release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: v${{ needs.create-release.outputs.version }}
|
||||||
|
draft: false
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
release
|
||||||
|
*.local
|
||||||
|
*.tgz
|
||||||
|
/npm
|
||||||
|
/tsc
|
||||||
|
/openchamber@*
|
||||||
|
local-dev*
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# OpenChamber - AI Agent & Contributor Reference
|
||||||
|
|
||||||
|
Technical reference for AI coding agents and human contributors working on this project.
|
||||||
|
|
||||||
|
## Core Purpose
|
||||||
|
|
||||||
|
Web and desktop interface for OpenCode AI coding agent. Provides cross-device continuity, remote accessibility, and a unified chat interface using the OpenCode API backend.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **React 19.1.1**: Modern React with concurrent features
|
||||||
|
- **TypeScript 5.8.3**: Full type safety
|
||||||
|
- **Vite 7.1.2**: Build tool with HMR and proxy
|
||||||
|
- **Tailwind CSS v4.0.0**: Latest `@import` syntax
|
||||||
|
- **Zustand 5.0.8**: State management with persistence
|
||||||
|
- **@opencode-ai/sdk**: Official OpenCode SDK with typed endpoints and SSE
|
||||||
|
- **@remixicon/react**: Icon system
|
||||||
|
- **@radix-ui primitives**: Accessible component foundations
|
||||||
|
|
||||||
|
## Architecture Overview (Monorepo)
|
||||||
|
|
||||||
|
Workspaces:
|
||||||
|
- `packages/ui` - Shared UI components and stores
|
||||||
|
- `packages/web` - Web runtime, Express server, CLI
|
||||||
|
- `packages/desktop` - Tauri desktop app with native APIs
|
||||||
|
|
||||||
|
### Core Components (UI)
|
||||||
|
In `packages/ui/src/components/`: ChatContainer, MessageList, ChatMessage, StreamingAnimatedText, ChatInput, FileAttachment, ModelControls, PermissionCard, SessionList, SessionSwitcherDialog, DirectoryTree, DirectoryExplorerDialog, MainLayout, Header, Sidebar, SettingsDialog, AgentsPage, CommandsPage, GitIdentitiesPage, ProvidersPage, SessionsPage, SettingsPage, CommandPalette, HelpDialog, ConfigUpdateOverlay, ContextUsageDisplay, ErrorBoundary, MemoryDebugPanel, MobileOverlayPanel, ThemeDemo, ThemeSwitcher.
|
||||||
|
|
||||||
|
In `packages/ui/src/components/views/`: ChatView, GitView, DiffView, TerminalView.
|
||||||
|
|
||||||
|
In `packages/ui/src/components/terminal/`: TerminalViewport
|
||||||
|
|
||||||
|
### State Management (UI)
|
||||||
|
In `packages/ui/src/stores/`: ConfigStore, SessionStore, DirectoryStore, UIStore, FileStore, MessageStore, ContextStore, PermissionStore, AgentsStore, CommandsStore, GitIdentitiesStore, TerminalStore
|
||||||
|
|
||||||
|
### OpenCode SDK Integration (UI)
|
||||||
|
In `packages/ui/src/lib/opencode/`: client.ts wrapper around `@opencode-ai/sdk` with directory-aware API calls, SDK methods (session.*, message.*, agent.*, provider.*, config.*, project.*, path.*), AsyncGenerator SSE streaming (2 retry attempts, 500ms->8s backoff), automatic directory parameter injection.
|
||||||
|
|
||||||
|
In `packages/ui/src/hooks/`: useEventStream.ts for real-time SSE connection management.
|
||||||
|
|
||||||
|
### Web Runtime (server/CLI)
|
||||||
|
Express server and CLI in `packages/web`: API adapters in `packages/web/src/api`, server in `packages/web/server/index.js` (git/terminal/config), UI bundle imported from `@openchamber/ui`.
|
||||||
|
|
||||||
|
### Desktop Runtime (Tauri)
|
||||||
|
Native desktop app in `packages/desktop`: Tauri backend in `src-tauri/` (Rust), frontend API adapters in `src/api/` (settings, permissions, diagnostics, files, git, terminal, notifications, tools), bridge layer in `src/lib/` for Tauri IPC communication.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Code Validation
|
||||||
|
Always validate changes before committing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm -r type-check # TypeScript validation
|
||||||
|
pnpm -r lint # ESLint checks
|
||||||
|
pnpm -r build # Production build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building
|
||||||
|
```bash
|
||||||
|
pnpm run build # Build all packages
|
||||||
|
pnpm run desktop:build # Build desktop app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
### Section-Based Navigation
|
||||||
|
Modular section architecture with dedicated pages and sidebars. Sections: Agents, Commands, Git Identities, Providers, Sessions, Settings. Independent state management and routing.
|
||||||
|
|
||||||
|
### File Attachments
|
||||||
|
Drag-and-drop upload with 10MB limit (`FileAttachment.tsx`), Data URL encoding, type validation with fallbacks, integrated via `useFileStore.addAttachedFile()`.
|
||||||
|
|
||||||
|
### Theme System
|
||||||
|
In `packages/ui/src/lib/theme/`: TypeScript-based themes (Flexoki Light and Dark), CSS variable generation, component-specific theming, Tailwind CSS v4 integration.
|
||||||
|
|
||||||
|
### Typography System
|
||||||
|
In `packages/ui/src/lib/`: Semantic typography with 6 CSS variables, theme-independent scales. **CRITICAL**: Always use semantic typography classes, never hardcoded font sizes.
|
||||||
|
|
||||||
|
### Streaming Architecture
|
||||||
|
SDK-managed SSE with AsyncGenerator, temp->real ID swap, pending-user guards, empty-response detection via `window.__opencodeDebug`.
|
||||||
|
|
||||||
|
## Development Guidelines
|
||||||
|
|
||||||
|
### Lint & Type Safety
|
||||||
|
|
||||||
|
- Never land code that introduces new ESLint or TypeScript errors
|
||||||
|
- Run `pnpm run lint` and `pnpm run type-check` before finalizing changes
|
||||||
|
- Adding `eslint-disable` requires justification in a comment explaining why typing is impossible
|
||||||
|
- Do **not** use `any` or `unknown` casts as escape hatches; build narrow adapter interfaces instead
|
||||||
|
- Refactors or new features must keep existing lint/type baselines green
|
||||||
|
|
||||||
|
### Theme Integration
|
||||||
|
|
||||||
|
- Check theme definitions before adding colors or font sizes to new components
|
||||||
|
- Always use theme-defined typography classes, never hardcoded font sizes
|
||||||
|
- Reference existing theme colors instead of adding new ones
|
||||||
|
- Ensure new components support both light and dark themes
|
||||||
|
- Use theme-generated CSS variables for dynamic styling
|
||||||
|
|
||||||
|
### Code Standards
|
||||||
|
|
||||||
|
- **Functional components**: Exclusive use of function components with hooks
|
||||||
|
- **Custom hooks**: Extract logic for reusability
|
||||||
|
- **Type-first development**: Comprehensive TypeScript usage
|
||||||
|
- **Component composition**: Prefer composition over inheritance
|
||||||
|
|
||||||
|
## Feature Implementation Map
|
||||||
|
|
||||||
|
### Directory & File System
|
||||||
|
`packages/ui/src/components/session/`: DirectoryTree, DirectoryExplorerDialog
|
||||||
|
`packages/ui/src/stores/`: DirectoryStore
|
||||||
|
Backend: `packages/web/server/index.js` with `listLocalDirectory()`, `getFilesystemHome()`
|
||||||
|
|
||||||
|
### Session Switcher
|
||||||
|
`SessionSwitcherDialog.tsx`: Collapsible date groups, mobile parity with MobileOverlayPanel, Git worktree and shared session chips, streaming indicators.
|
||||||
|
|
||||||
|
### Settings & Configuration
|
||||||
|
`packages/ui/src/components/sections/`: AgentsPage, CommandsPage, GitIdentitiesPage, ProvidersPage, SessionsPage, SettingsPage
|
||||||
|
Related stores: useAgentsStore, useCommandsStore, useConfigStore, useGitIdentitiesStore
|
||||||
|
|
||||||
|
### Git Operations
|
||||||
|
`packages/ui/src/components/views/`: GitView, DiffView
|
||||||
|
`packages/ui/src/stores/`: useGitIdentitiesStore
|
||||||
|
Backend: `packages/ui/src/lib/gitApi.ts` + `packages/web/server/index.js` (simple-git wrapper)
|
||||||
|
|
||||||
|
### Terminal
|
||||||
|
`packages/ui/src/components/views/`: TerminalView
|
||||||
|
`packages/ui/src/components/terminal/`: TerminalViewport (Xterm.js with FitAddon)
|
||||||
|
`packages/ui/src/stores/`: useTerminalStore
|
||||||
|
Backend: `packages/web/server/index.js` (node-pty wrapper with SSE)
|
||||||
|
|
||||||
|
### Theme System
|
||||||
|
`packages/ui/src/lib/theme/`: themes (2 definitions), cssGenerator, syntaxThemeGenerator
|
||||||
|
`packages/ui/src/components/providers/`: ThemeProvider
|
||||||
|
|
||||||
|
### Mobile & UX
|
||||||
|
`packages/ui/src/components/ui/`: MobileOverlayPanel
|
||||||
|
`packages/ui/src/hooks/`: useEdgeSwipe, useChatScrollManager
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Pending
|
||||||
|
|
||||||
|
## [1.0.0] - 2025-12-07
|
||||||
|
|
||||||
|
- Initial public release of OpenChamber web and desktop packages in a unified monorepo.
|
||||||
|
- Added GitHub Actions release pipeline with macOS signing/notarization, npm publish, and release asset uploads.
|
||||||
|
- Introduced OpenCode agent chat experience with section-based navigation, theming, and session persistence.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Contributing to OpenChamber
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/btriapitsyn/openchamber.git
|
||||||
|
cd openchamber
|
||||||
|
pnpm install
|
||||||
|
pnpm run dev:web:full # Web development
|
||||||
|
pnpm run desktop:dev # Desktop development (Tauri)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Before Submitting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm -r type-check # Must pass
|
||||||
|
pnpm -r lint # Must pass
|
||||||
|
pnpm -r build # Must succeed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- Functional React components only
|
||||||
|
- TypeScript strict mode - no `any` without justification
|
||||||
|
- Use existing theme colors/typography - don't add new ones
|
||||||
|
- Components must support light and dark themes
|
||||||
|
|
||||||
|
## Pull Requests
|
||||||
|
|
||||||
|
1. Fork and create a branch
|
||||||
|
2. Make changes
|
||||||
|
3. Run validation commands above
|
||||||
|
4. Submit PR with clear description of what and why
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
See [AGENTS.md](./AGENTS.md) for detailed architecture reference.
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
Open an issue.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Bohdan Triapitsyn
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# OpenChamber
|
||||||
|
|
||||||
|
Web and desktop interface for the [OpenCode](https://opencode.ai) AI coding agent. Works alongside the OpenCode TUI.
|
||||||
|
|
||||||
|
The OpenCode team is actively working on their own desktop app. I still decided to release this project as a fan-made alternative.
|
||||||
|
|
||||||
|
It was entirely built with OpenCode tool - first with the TUI version, then with the first usable version of OpenChamber, which I then used to build the rest.
|
||||||
|
|
||||||
|
The whole project was built entirely with AI coding agents under my supervision. It started as a hobby project and proof of concept that AI agents can create genuinely usable software.
|
||||||
|
|
||||||
|
## Why use OpenChamber?
|
||||||
|
|
||||||
|
- **Cross-device continuity**: Start in TUI, continue on tablet/phone, return to terminal - same session
|
||||||
|
- **Remote access**: Use OpenCode from anywhere via browser
|
||||||
|
- **Familiarity**: A visual alternative for developers who prefer GUI workflows
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Integrated terminal
|
||||||
|
- Git operations with identity management and AI commit message generation
|
||||||
|
- Beautiful themes (Flexoki Light/Dark) with dynamic CSS variable system
|
||||||
|
- Mobile-optimized with edge-swipe gestures, terminal control and optimizations all around
|
||||||
|
- Git worktrees operations with isolating sessions within them
|
||||||
|
- Memory optimizations with LRU eviction
|
||||||
|
- Rich permission cards with syntax-highlighted operation previews
|
||||||
|
- Smart tool visualization (inline diffs, file trees, results highlighting)
|
||||||
|
- Per-agent permission mode control (ask, allow, full) adjustable per-session
|
||||||
|
- Familiar diff viewer like you're used to in VSCode
|
||||||
|
- Built-in OpenCode agent/command management
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### CLI (Web Server)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add -g openchamber
|
||||||
|
|
||||||
|
openchamber # Start on port 3000
|
||||||
|
openchamber --port 8080 # Custom port
|
||||||
|
openchamber --daemon # Background mode
|
||||||
|
openchamber --ui-password secret # Password-protect UI
|
||||||
|
openchamber stop # Stop server
|
||||||
|
```
|
||||||
|
|
||||||
|
### Desktop App (macOS)
|
||||||
|
|
||||||
|
Download from [Releases](https://github.com/btriapitsyn/openchamber/releases).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [OpenCode CLI](https://opencode.ai) installed and running (`opencode serve`)
|
||||||
|
- Node.js 20+
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/btriapitsyn/openchamber.git
|
||||||
|
cd openchamber
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
pnpm run dev:web:full # Web development
|
||||||
|
pnpm run desktop:dev # Desktop development (Tauri)
|
||||||
|
pnpm run build # Production build
|
||||||
|
```
|
||||||
|
|
||||||
|
See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
React 19, TypeScript, Vite 7, Tailwind CSS v4, Zustand, Radix UI, @opencode-ai/sdk, Express, Tauri (desktop)
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
Independent project, not affiliated with OpenCode team.
|
||||||
|
|
||||||
|
**Special thanks to:**
|
||||||
|
|
||||||
|
- [OpenCode](https://opencode.ai) - For the excellent API and extensible architecture
|
||||||
|
- [Flexoki](https://github.com/kepano/flexoki) - Beautiful color scheme by [Steph Ango](https://stephango.com/flexoki)
|
||||||
|
- [Tauri](https://github.com/tauri-apps/tauri) - Desktop application framework
|
||||||
|
- [David Hill](https://x.com/iamdavidhill) - who inspired me to release this without [overthinking](https://x.com/iamdavidhill/status/1993648326450020746?s=20)
|
||||||
|
- My wife, who created a beautiful firework animation for the app while testing it for the first time
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default tseslint.config([
|
||||||
|
globalIgnores(['dist', '.openchamber']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs['recommended-latest'],
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix for http-proxy package util._extend deprecation warning
|
||||||
|
* This script patches the http-proxy package to use Object.assign instead of util._extend
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
function fixHttpProxyDeprecation() {
|
||||||
|
try {
|
||||||
|
// Find the http-proxy package in node_modules
|
||||||
|
const httpProxyDir = path.join(__dirname, 'node_modules', 'http-proxy', 'lib', 'http-proxy');
|
||||||
|
const indexPath = path.join(httpProxyDir, 'index.js');
|
||||||
|
const commonPath = path.join(httpProxyDir, 'common.js');
|
||||||
|
|
||||||
|
if (!fs.existsSync(indexPath) || !fs.existsSync(commonPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch index.js
|
||||||
|
let needsPatch = false;
|
||||||
|
|
||||||
|
if (fs.existsSync(indexPath)) {
|
||||||
|
let content = fs.readFileSync(indexPath, 'utf8');
|
||||||
|
|
||||||
|
let indexPatched = false;
|
||||||
|
|
||||||
|
if (content.includes("require('util')._extend")) {
|
||||||
|
content = content.replace(
|
||||||
|
/extend\s*=\s*require\('util'\)\._extend,/,
|
||||||
|
"extend = Object.assign,"
|
||||||
|
);
|
||||||
|
indexPatched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.includes("require('util').inherits")) {
|
||||||
|
content = content.replace(
|
||||||
|
/require\('util'\)\.inherits\((\w+),\s*(\w+)\);/,
|
||||||
|
"Object.setPrototypeOf($1.prototype, $2.prototype);"
|
||||||
|
);
|
||||||
|
indexPatched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (indexPatched) {
|
||||||
|
fs.writeFileSync(indexPath, content, 'utf8');
|
||||||
|
needsPatch = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch common.js
|
||||||
|
if (fs.existsSync(commonPath)) {
|
||||||
|
let content = fs.readFileSync(commonPath, 'utf8');
|
||||||
|
|
||||||
|
let commonPatched = false;
|
||||||
|
|
||||||
|
if (content.includes("require('util')._extend")) {
|
||||||
|
content = content.replace(
|
||||||
|
/extend\s*=\s*require\('util'\)\._extend,/,
|
||||||
|
"extend = Object.assign,"
|
||||||
|
);
|
||||||
|
commonPatched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commonPatched) {
|
||||||
|
fs.writeFileSync(commonPath, content, 'utf8');
|
||||||
|
needsPatch = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Silently handle errors - functionality is not affected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the fix
|
||||||
|
fixHttpProxyDeprecation();
|
||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
{
|
||||||
|
"name": "openchamber-monorepo",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@10.22.0",
|
||||||
|
"workspaces": [
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"opencode",
|
||||||
|
"ai",
|
||||||
|
"coding",
|
||||||
|
"openchamber",
|
||||||
|
"cli"
|
||||||
|
],
|
||||||
|
"author": "Bohdan Triapitsyn",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"build": "pnpm --if-present run --recursive --filter \"./packages/*\" build",
|
||||||
|
"build:web": "pnpm -C packages/web run build",
|
||||||
|
"build:ui": "pnpm -C packages/ui run build",
|
||||||
|
"build:desktop": "pnpm -C packages/desktop run build",
|
||||||
|
"type-check": "pnpm --if-present run --recursive --filter \"./packages/*\" type-check",
|
||||||
|
"type-check:web": "pnpm -C packages/web run type-check",
|
||||||
|
"type-check:ui": "pnpm -C packages/ui run type-check",
|
||||||
|
"type-check:desktop": "pnpm -C packages/desktop run type-check",
|
||||||
|
"lint": "pnpm --if-present run --recursive --filter \"./packages/*\" lint",
|
||||||
|
"lint:web": "pnpm -C packages/web run lint",
|
||||||
|
"lint:ui": "pnpm -C packages/ui run lint",
|
||||||
|
"lint:desktop": "pnpm -C packages/desktop run lint",
|
||||||
|
"clean": "pnpm --if-present run --recursive --filter \"./packages/*\" clean",
|
||||||
|
"dev:web": "pnpm -C packages/web run build:watch",
|
||||||
|
"dev:web:server": "pnpm -C packages/web run dev:server:watch",
|
||||||
|
"dev:web:full": "concurrently -n \"api,build\" -c \"cyan,magenta\" \"pnpm -C packages/web run dev:server:watch\" \"pnpm -C packages/web run build:watch\"",
|
||||||
|
"start:web": "pnpm -C packages/web run start",
|
||||||
|
"pack:web": "pnpm -C packages/web pack --pack-destination .",
|
||||||
|
"desktop:start-cli": "node ./packages/desktop/scripts/opencode-cli.mjs start",
|
||||||
|
"desktop:stop-cli": "node ./packages/desktop/scripts/opencode-cli.mjs stop",
|
||||||
|
"desktop:dev": "node ./packages/desktop/scripts/desktop-dev.mjs",
|
||||||
|
"desktop:build": "pnpm -C packages/desktop build && pnpm -C packages/desktop tauri build",
|
||||||
|
"desktop:lint": "pnpm -C packages/desktop run lint && cargo fmt --manifest-path packages/desktop/src-tauri/Cargo.toml -- --check && cargo clippy --manifest-path packages/desktop/src-tauri/Cargo.toml -- -D warnings",
|
||||||
|
"desktop:type-check": "pnpm -C packages/desktop run type-check && cargo fmt --manifest-path packages/desktop/src-tauri/Cargo.toml -- --check && cargo clippy --manifest-path packages/desktop/src-tauri/Cargo.toml -- -D warnings",
|
||||||
|
"version:bump": "node scripts/bump-version.mjs",
|
||||||
|
"release:prepare": "pnpm run build && pnpm run type-check && pnpm run lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||||
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
|
"@heroui/scroll-shadow": "^2.3.18",
|
||||||
|
"@heroui/system": "^2.4.23",
|
||||||
|
"@heroui/theme": "^2.4.23",
|
||||||
|
"@ibm/plex": "^6.4.1",
|
||||||
|
"@opencode-ai/sdk": "^1.0.133",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@remixicon/react": "^4.7.0",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"@xterm/xterm": "^5.3.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"electron-context-menu": "^4.1.1",
|
||||||
|
"electron-store": "^11.0.2",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"http-proxy-middleware": "^3.0.5",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"node-pty": "^1.0.0",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-syntax-highlighter": "^15.6.6",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"simple-git": "^3.28.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"strip-json-comments": "^5.0.3",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"yaml": "^2.8.1",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.33.0",
|
||||||
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
|
"@types/node": "^24.3.1",
|
||||||
|
"@types/react": "^19.1.10",
|
||||||
|
"@types/react-dom": "^19.1.7",
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"electron": "^38.2.0",
|
||||||
|
"electron-builder": "^24.13.3",
|
||||||
|
"eslint": "^9.33.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
|
"globals": "^16.3.0",
|
||||||
|
"nodemon": "^3.1.7",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
|
"tw-animate-css": "^1.3.8",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"typescript-eslint": "^8.39.1",
|
||||||
|
"vite": "^7.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Vite build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Tauri build artifacts
|
||||||
|
src-tauri/target/
|
||||||
|
|
||||||
|
# Tauri generated code
|
||||||
|
src-tauri/gen/
|
||||||
|
|
||||||
|
# OpenCode CLI state tracking
|
||||||
|
.opencode-cli-state.json
|
||||||
|
|
||||||
|
# OS-specific
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# @openchamber/desktop
|
||||||
|
|
||||||
|
Desktop application for the [OpenCode](https://opencode.ai) AI coding agent. Built with Tauri.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Download from [Releases](https://github.com/btriapitsyn/openchamber/releases).
|
||||||
|
|
||||||
|
Currently available for macOS (Apple Silicon).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [OpenCode CLI](https://opencode.ai) installed
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Native macOS app with auto-updates
|
||||||
|
- Integrated terminal
|
||||||
|
- Git operations with identity management and AI commit message generation
|
||||||
|
- Beautiful themes (Flexoki Light/Dark)
|
||||||
|
- Rich permission cards with syntax-highlighted operation previews
|
||||||
|
- Smart tool visualization (inline diffs, file trees, results highlighting)
|
||||||
|
- Per-agent permission mode control
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/btriapitsyn/openchamber.git
|
||||||
|
cd openchamber
|
||||||
|
pnpm install
|
||||||
|
pnpm run desktop:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="h-full">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||||
|
/>
|
||||||
|
<meta name="theme-color" content="#151313" />
|
||||||
|
<link rel="preload" href="/ibm-plex-mono-latin-600-normal.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
|
<title>OpenChamber Desktop</title>
|
||||||
|
<style>
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Mono';
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: normal;
|
||||||
|
src: url('/ibm-plex-mono-latin-600-normal.woff2') format('woff2');
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
background-color: transparent;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.loading span {
|
||||||
|
display: inline-block;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
animation: slideUp 0.5s ease-out forwards;
|
||||||
|
}
|
||||||
|
@keyframes slideUp {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="h-full">
|
||||||
|
<div id="root" class="h-full">
|
||||||
|
<div class="loading"><span style="animation-delay:0.1s">O</span><span style="animation-delay:0.15s">p</span><span style="animation-delay:0.2s">e</span><span style="animation-delay:0.25s">n</span><span style="animation-delay:0.3s">C</span><span style="animation-delay:0.35s">h</span><span style="animation-delay:0.4s">a</span><span style="animation-delay:0.45s">m</span><span style="animation-delay:0.5s">b</span><span style="animation-delay:0.55s">e</span><span style="animation-delay:0.6s">r</span></div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "@openchamber/desktop",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"desktopPrerequisites": [
|
||||||
|
"Rust stable toolchain (via rustup)",
|
||||||
|
"Xcode Command Line Tools installed",
|
||||||
|
"Tauri CLI installed (cargo install tauri-cli@^2)"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"tauri": "tauri",
|
||||||
|
"dev": "vite dev --host 127.0.0.1 --port 1421",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview --host 127.0.0.1 --port 5051",
|
||||||
|
"type-check": "tsc --noEmit",
|
||||||
|
"lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
|
"@tauri-apps/plugin-process": "^2",
|
||||||
|
"@tauri-apps/plugin-updater": "^2",
|
||||||
|
"@openchamber/ui": "workspace:*",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2",
|
||||||
|
"@tauri-apps/api": "^2.9.1",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.4.2",
|
||||||
|
"@types/node": "^24.3.1",
|
||||||
|
"@types/react": "^19.1.10",
|
||||||
|
"@types/react-dom": "^19.1.7",
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"vite": "^7.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { startCli, stopCli } from './opencode-cli.mjs';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const repoRoot = path.resolve(__dirname, '../../..');
|
||||||
|
const desktopDir = path.join(repoRoot, 'packages/desktop');
|
||||||
|
|
||||||
|
function spawnProcess(command, args, opts = {}) {
|
||||||
|
return spawn(command, args, {
|
||||||
|
cwd: repoRoot,
|
||||||
|
env: { ...process.env },
|
||||||
|
stdio: 'inherit',
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await startCli();
|
||||||
|
|
||||||
|
const tauriProcess = spawnProcess('pnpm', ['-C', desktopDir, 'tauri', 'dev']);
|
||||||
|
|
||||||
|
let cleaning = false;
|
||||||
|
|
||||||
|
const teardown = async (code) => {
|
||||||
|
if (cleaning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cleaning = true;
|
||||||
|
|
||||||
|
const stopChild = (child, label) => {
|
||||||
|
if (!child || child.killed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
child.kill('SIGINT');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[desktop:dev] Failed to stop ${label}:`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
stopChild(tauriProcess, 'Tauri dev process');
|
||||||
|
|
||||||
|
await stopCli({ silent: true }).catch((error) => {
|
||||||
|
console.warn('[desktop:dev] Failed to stop OpenCode CLI:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.exit(typeof code === 'number' ? code : 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChildExit = (childName) => (code, signal) => {
|
||||||
|
if (code !== 0 || signal) {
|
||||||
|
console.warn(`[desktop:dev] ${childName} exited with code ${code ?? 'null'} signal ${signal ?? 'none'}.`);
|
||||||
|
}
|
||||||
|
teardown(code).catch((error) => {
|
||||||
|
console.error('[desktop:dev] Cleanup error:', error);
|
||||||
|
process.exit(code ?? 1);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
tauriProcess.on('exit', handleChildExit('Tauri dev process'));
|
||||||
|
const errorHandler = (label) => (error) => {
|
||||||
|
console.error(`[desktop:dev] Failed to start ${label}:`, error);
|
||||||
|
teardown(1).catch(() => process.exit(1));
|
||||||
|
};
|
||||||
|
|
||||||
|
tauriProcess.on('error', errorHandler('Tauri dev process'));
|
||||||
|
|
||||||
|
const signalExitCodes = {
|
||||||
|
SIGINT: 130,
|
||||||
|
SIGTERM: 143,
|
||||||
|
SIGQUIT: 131,
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.entries(signalExitCodes).forEach(([signal, exitCode]) => {
|
||||||
|
process.on(signal, () => {
|
||||||
|
teardown(exitCode).catch(() => process.exit(exitCode));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error('[desktop:dev] Unexpected error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { access, readFile, unlink, writeFile } from 'node:fs/promises';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const desktopDir = path.resolve(__dirname, '..');
|
||||||
|
const stateFile = path.join(desktopDir, '.opencode-cli-state.json');
|
||||||
|
const DEFAULT_BIN_CANDIDATES = [
|
||||||
|
process.env.OPENCHAMBER_OPENCODE_PATH,
|
||||||
|
process.env.OPENCHAMBER_OPENCODE_BIN,
|
||||||
|
process.env.OPENCODE_PATH,
|
||||||
|
process.env.OPENCODE_BINARY,
|
||||||
|
'/opt/homebrew/bin/opencode',
|
||||||
|
'/usr/local/bin/opencode',
|
||||||
|
'/usr/bin/opencode',
|
||||||
|
path.join(os.homedir(), '.local/bin/opencode'),
|
||||||
|
].filter(Boolean);
|
||||||
|
const CLI_ARGS_ENV = process.env.OPENCHAMBER_OPENCODE_ARGS;
|
||||||
|
const DEFAULT_ARGS = CLI_ARGS_ENV
|
||||||
|
? parseArgs(CLI_ARGS_ENV)
|
||||||
|
: ['api'];
|
||||||
|
|
||||||
|
function parseArgs(raw) {
|
||||||
|
if (!raw || typeof raw !== 'string') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith('[')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (Array.isArray(parsed) && parsed.every((item) => typeof item === 'string')) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to whitespace split
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trimmed.split(/\s+/g);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileExists(targetPath) {
|
||||||
|
try {
|
||||||
|
await access(targetPath, fs.constants.X_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveCliPath() {
|
||||||
|
for (const candidate of DEFAULT_BIN_CANDIDATES) {
|
||||||
|
if (candidate && await fileExists(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const envPath = process.env.PATH || '';
|
||||||
|
for (const segment of envPath.split(path.delimiter)) {
|
||||||
|
const candidate = path.join(segment, 'opencode');
|
||||||
|
if (await fileExists(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unable to locate the OpenCode CLI. Set OPENCHAMBER_OPENCODE_PATH to the executable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readState() {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(stateFile, 'utf8');
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
if (typeof data?.pid === 'number') {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProcessAlive(pid) {
|
||||||
|
if (!pid || typeof pid !== 'number') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeState(pid) {
|
||||||
|
await writeFile(stateFile, JSON.stringify({ pid }), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeStateFile() {
|
||||||
|
try {
|
||||||
|
await unlink(stateFile);
|
||||||
|
} catch {
|
||||||
|
// already removed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnCli(cliPath, args) {
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
OPENCHAMBER_OPENCODE_PORT: process.env.OPENCHAMBER_OPENCODE_PORT || process.env.OPENCODE_PORT || process.env.OPENCHAMBER_INTERNAL_PORT || '0',
|
||||||
|
};
|
||||||
|
const cwd = process.env.OPENCHAMBER_OPENCODE_CWD || process.cwd();
|
||||||
|
|
||||||
|
const child = spawn(cliPath, args.length > 0 ? args : DEFAULT_ARGS, {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
|
||||||
|
child.unref();
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startCli({ silent = false } = {}) {
|
||||||
|
const existing = await readState();
|
||||||
|
if (existing?.pid && isProcessAlive(existing.pid)) {
|
||||||
|
if (!silent) {
|
||||||
|
console.log(`[desktop:start-cli] OpenCode CLI already running (pid ${existing.pid}).`);
|
||||||
|
}
|
||||||
|
return existing.pid;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cliPath = await resolveCliPath();
|
||||||
|
const child = spawnCli(cliPath, DEFAULT_ARGS);
|
||||||
|
await writeState(child.pid);
|
||||||
|
if (!silent) {
|
||||||
|
console.log(`[desktop:start-cli] OpenCode CLI started (${cliPath}) pid ${child.pid}.`);
|
||||||
|
}
|
||||||
|
return child.pid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopCli({ silent = false } = {}) {
|
||||||
|
const state = await readState();
|
||||||
|
if (!state?.pid) {
|
||||||
|
if (!silent) {
|
||||||
|
console.log('[desktop:stop-cli] No OpenCode CLI PID recorded.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pid } = state;
|
||||||
|
if (!isProcessAlive(pid)) {
|
||||||
|
await removeStateFile();
|
||||||
|
if (!silent) {
|
||||||
|
console.log('[desktop:stop-cli] CLI already stopped.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.kill(pid, 'SIGTERM');
|
||||||
|
} catch (error) {
|
||||||
|
if (!silent) {
|
||||||
|
console.error(`[desktop:stop-cli] Failed to send SIGTERM to pid ${pid}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutMs = 5000;
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
if (!isProcessAlive(pid)) {
|
||||||
|
await removeStateFile();
|
||||||
|
if (!silent) {
|
||||||
|
console.log('[desktop:stop-cli] OpenCode CLI stopped.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.kill(pid, 'SIGKILL');
|
||||||
|
if (!silent) {
|
||||||
|
console.warn(`[desktop:stop-cli] Forced termination sent to pid ${pid}.`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!silent) {
|
||||||
|
console.error(`[desktop:stop-cli] Unable to terminate pid ${pid}:`, error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await removeStateFile();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const [, , command] = process.argv;
|
||||||
|
if (!command || command === '--help' || command === '-h') {
|
||||||
|
console.log('Usage: node opencode-cli.mjs <start|stop|status>');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === 'start') {
|
||||||
|
await startCli();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === 'stop') {
|
||||||
|
await stopCli();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === 'status') {
|
||||||
|
const state = await readState();
|
||||||
|
if (state?.pid && isProcessAlive(state.pid)) {
|
||||||
|
console.log(`OpenCode CLI running (pid ${state.pid}).`);
|
||||||
|
} else {
|
||||||
|
console.log('OpenCode CLI not running.');
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`Unknown command: ${command}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error('[desktop:opencode-cli] Unexpected error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
Generated
+6606
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
|||||||
|
[package]
|
||||||
|
name = "openchamber-desktop"
|
||||||
|
version = "1.0.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "openchamber_desktop"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "openchamber-desktop"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0.86"
|
||||||
|
axum = { version = "0.8.4", features = ["macros"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
dirs = "5.0"
|
||||||
|
fastrand = "2.0"
|
||||||
|
futures-util = "0.3"
|
||||||
|
log = "0.4.28"
|
||||||
|
nix = { version = "0.28", features = ["signal"] }
|
||||||
|
objc = "0.2.7"
|
||||||
|
objc2 = "0.6.3"
|
||||||
|
objc2-foundation = { version = "0.3.2", features = ["NSProcessInfo", "NSString", "NSObjCRuntime"] }
|
||||||
|
once_cell = "1.19"
|
||||||
|
parking_lot = "0.12.3"
|
||||||
|
portable-pty = "0.9.0"
|
||||||
|
portpicker = "0.1.1"
|
||||||
|
regex = "1.10.4"
|
||||||
|
reqwest = { version = "0.12.4", default-features = false, features = ["json", "stream", "rustls-tls", "gzip", "brotli", "deflate"] }
|
||||||
|
serde = { version = "1.0.210", features = ["derive"] }
|
||||||
|
serde_json = "1.0.143"
|
||||||
|
serde_yaml = "0.9"
|
||||||
|
tauri = { version = "2.9.4", features = ["macos-private-api", "devtools" ] }
|
||||||
|
tauri-plugin-dialog = "2.4.2"
|
||||||
|
tauri-plugin-fs = "2.4.4"
|
||||||
|
tauri-plugin-log = "2.7.1"
|
||||||
|
tauri-plugin-shell = "2.3.3"
|
||||||
|
tokio = { version = "1.38", features = ["macros", "rt-multi-thread", "process", "signal", "sync", "time", "fs"] }
|
||||||
|
tower-http = { version = "0.5.2", features = ["cors"] }
|
||||||
|
uuid = { version = "1.18.1", features = ["v4"] }
|
||||||
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
|
tauri-plugin-notification = "2.3.3"
|
||||||
|
tauri-plugin-updater = "2"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2.5.3", features = [] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
window-vibrancy = "0.7.1"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSSupportsAutomaticTermination</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSSupportsSuddenTermination</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build();
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default capabilities for OpenChamber desktop runtime",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"core:window:default",
|
||||||
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-set-title",
|
||||||
|
"core:window:allow-set-size",
|
||||||
|
"core:window:allow-set-position",
|
||||||
|
"core:window:allow-start-dragging",
|
||||||
|
"core:webview:default",
|
||||||
|
"core:webview:allow-webview-close",
|
||||||
|
"shell:allow-open",
|
||||||
|
"shell:allow-execute",
|
||||||
|
"dialog:allow-open",
|
||||||
|
"dialog:allow-save",
|
||||||
|
"dialog:allow-message",
|
||||||
|
"dialog:allow-ask",
|
||||||
|
"dialog:allow-confirm",
|
||||||
|
"fs:allow-read-text-file",
|
||||||
|
"fs:allow-read-file",
|
||||||
|
"fs:allow-write-text-file",
|
||||||
|
"fs:allow-write-file",
|
||||||
|
"fs:allow-read-dir",
|
||||||
|
"fs:allow-exists",
|
||||||
|
"fs:allow-create",
|
||||||
|
"fs:allow-mkdir",
|
||||||
|
"fs:allow-remove",
|
||||||
|
"fs:scope-app-index",
|
||||||
|
"fs:scope-home",
|
||||||
|
"notification:default",
|
||||||
|
"notification:allow-is-permission-granted",
|
||||||
|
"notification:allow-request-permission",
|
||||||
|
"notification:allow-notify",
|
||||||
|
"updater:default",
|
||||||
|
"updater:allow-check",
|
||||||
|
"updater:allow-download-and-install"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<filter id="iconShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feDropShadow dx="0" dy="12" stdDeviation="14" flood-opacity="0.5" flood-color="#000000"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="glyphShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feDropShadow dx="2" dy="4" stdDeviation="8" flood-opacity="0.3" flood-color="#000000"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="bgGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#131010"/>
|
||||||
|
<stop offset="100%" stop-color="#1E1919"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g transform="translate(100, 100)">
|
||||||
|
<rect x="0" y="0" width="824" height="824" rx="185" ry="185" filter="url(#iconShadow)" fill="url(#bgGradient)"/>
|
||||||
|
|
||||||
|
<g transform="translate(412, 412) scale(7.65) translate(-35, -35.5)" filter="url(#glyphShadow)">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 13H35V58H0V13ZM26.25 22.1957H8.75V48.701H26.25V22.1957Z" fill="#F8F7F3"/>
|
||||||
|
<rect x="8.75" y="30" width="17.5" height="18.5" fill="#4B4646"/>
|
||||||
|
<path d="M43.75 13H70V22.1957H52.5V48.701H70V57.8967H43.75V13Z" fill="#F8F7F3"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<ns0:svg xmlns:ns0="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||||
|
<ns0:defs>
|
||||||
|
<ns0:filter id="iconShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<ns0:feDropShadow dx="0" dy="12" stdDeviation="14" flood-opacity="0.5" flood-color="#000000" />
|
||||||
|
</ns0:filter>
|
||||||
|
<ns0:filter id="glyphShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<ns0:feDropShadow dx="2" dy="4" stdDeviation="6" flood-opacity="0.28" flood-color="#000000" />
|
||||||
|
</ns0:filter>
|
||||||
|
<ns0:linearGradient id="bgGradient" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<ns0:stop offset="0%" stop-color="#151313" />
|
||||||
|
<ns0:stop offset="100%" stop-color="#151313" />
|
||||||
|
</ns0:linearGradient>
|
||||||
|
<ns0:linearGradient id="glyphGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<ns0:stop offset="0%" stop-color="#F8F8F8" />
|
||||||
|
<ns0:stop offset="55%" stop-color="#DAD6D0" />
|
||||||
|
<ns0:stop offset="100%" stop-color="#BAB4AF" />
|
||||||
|
</ns0:linearGradient>
|
||||||
|
<ns0:linearGradient id="glyphStrokeGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<ns0:stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.08" />
|
||||||
|
<ns0:stop offset="45%" stop-color="#000000" stop-opacity="0.15" />
|
||||||
|
<ns0:stop offset="100%" stop-color="#000000" stop-opacity="0.2" />
|
||||||
|
</ns0:linearGradient>
|
||||||
|
</ns0:defs>
|
||||||
|
|
||||||
|
<ns0:g transform="translate(100, 100)">
|
||||||
|
<ns0:rect x="0" y="0" width="824" height="824" rx="185" ry="185" filter="url(#iconShadow)" fill="url(#bgGradient)" />
|
||||||
|
|
||||||
|
<ns0:g transform="translate(412, 412) scale(8.15) translate(-35, -35.5)" filter="url(#glyphShadow)">
|
||||||
|
<ns0:rect x="8.75" y="31.0" width="17.5" height="20.5" fill="#4B4646" />
|
||||||
|
<ns0:path fill-rule="evenodd" clip-rule="evenodd" d="M0 13H35V58H0V13ZM26.25 22.1957H8.75V48.701H26.25V22.1957Z" fill="url(#glyphGradient)" stroke="url(#glyphStrokeGradient)" stroke-width="1.1" stroke-linejoin="round" />
|
||||||
|
<ns0:path d="M43.75 13H70V22.1957H52.5V48.701H70V57.8967H43.75V13Z" fill="url(#glyphGradient)" stroke="url(#glyphStrokeGradient)" stroke-width="1.1" stroke-linejoin="round" />
|
||||||
|
</ns0:g>
|
||||||
|
</ns0:g>
|
||||||
|
</ns0:svg>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
@@ -0,0 +1,280 @@
|
|||||||
|
use std::{collections::HashSet, time::Duration};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use futures_util::TryStreamExt;
|
||||||
|
use log::{debug, info, warn};
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
use tauri_plugin_notification::NotificationExt;
|
||||||
|
use tokio::{io::AsyncBufReadExt, sync::Mutex};
|
||||||
|
use tokio_util::io::StreamReader;
|
||||||
|
|
||||||
|
use crate::DesktopRuntime;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct EventEnvelope {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
event_type: String,
|
||||||
|
#[serde(default)]
|
||||||
|
properties: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_assistant_notifications(
|
||||||
|
app: AppHandle,
|
||||||
|
runtime: DesktopRuntime,
|
||||||
|
) -> tauri::async_runtime::JoinHandle<()> {
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let client = Client::builder()
|
||||||
|
// Give SSE a very long overall timeout so idle periods don't abort the stream.
|
||||||
|
.timeout(Duration::from_secs(24 * 60 * 60))
|
||||||
|
.tcp_keepalive(Some(Duration::from_secs(30)))
|
||||||
|
.build()
|
||||||
|
.expect("failed to build reqwest client");
|
||||||
|
|
||||||
|
let mut shutdown_rx = runtime.subscribe_shutdown();
|
||||||
|
let notified_messages = Mutex::new(HashSet::<String>::new());
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = shutdown_rx.recv() => {
|
||||||
|
info!("[desktop:notify] Shutdown received, stopping SSE listener");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ = async {
|
||||||
|
if let Err(err) = run_once(&app, &runtime, &client, ¬ified_messages).await {
|
||||||
|
warn!("[desktop:notify] SSE loop error: {err:?}");
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
} => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_once(
|
||||||
|
app: &AppHandle,
|
||||||
|
runtime: &DesktopRuntime,
|
||||||
|
client: &Client,
|
||||||
|
notified_messages: &Mutex<HashSet<String>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let opencode = runtime.opencode_manager();
|
||||||
|
|
||||||
|
let port = match opencode.current_port() {
|
||||||
|
Some(port) => port,
|
||||||
|
None => {
|
||||||
|
warn!("[desktop:notify] OpenCode port unavailable; will retry");
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let prefix = opencode.api_prefix();
|
||||||
|
let mut url = format!("http://127.0.0.1:{port}{}/event", prefix);
|
||||||
|
|
||||||
|
if let Some(dir) = opencode.get_working_directory().to_str().map(|s| s.to_string()) {
|
||||||
|
let mut parsed = reqwest::Url::parse(&url)?;
|
||||||
|
parsed
|
||||||
|
.query_pairs_mut()
|
||||||
|
.append_pair("directory", &dir);
|
||||||
|
url = parsed.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("[desktop:notify] Connecting SSE for notifications: {url}");
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(&url)
|
||||||
|
.header("accept", "text/event-stream")
|
||||||
|
.header("accept-encoding", "identity")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"[desktop:notify] SSE response status={} headers={:?}",
|
||||||
|
response.status(),
|
||||||
|
response.headers()
|
||||||
|
);
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
warn!(
|
||||||
|
"[desktop:notify] SSE connect failed with status {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let stream = response
|
||||||
|
.bytes_stream()
|
||||||
|
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err));
|
||||||
|
let mut reader = StreamReader::new(stream);
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut data_lines: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
buf.clear();
|
||||||
|
let bytes_read = match reader.read_until(b'\n', &mut buf).await {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(err) => {
|
||||||
|
warn!("[desktop:notify] Read error in SSE stream: {err:?}");
|
||||||
|
return Err(err.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if bytes_read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let line = match std::str::from_utf8(&buf) {
|
||||||
|
Ok(s) => s.trim_end_matches(&['\r', '\n'][..]).to_string(),
|
||||||
|
Err(err) => {
|
||||||
|
warn!("[desktop:notify] Non-UTF8 SSE chunk: {err}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if line.is_empty() {
|
||||||
|
if data_lines.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let raw = data_lines.join("\n");
|
||||||
|
data_lines.clear();
|
||||||
|
|
||||||
|
match serde_json::from_str::<EventEnvelope>(&raw) {
|
||||||
|
Ok(event) => handle_event(app, event, notified_messages).await,
|
||||||
|
Err(err) => {
|
||||||
|
warn!("[desktop:notify] Failed to parse SSE data: {err}; raw={raw}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(rest) = line.strip_prefix("data:") {
|
||||||
|
data_lines.push(rest.trim_start().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_event(
|
||||||
|
app: &AppHandle,
|
||||||
|
event: EventEnvelope,
|
||||||
|
notified_messages: &Mutex<HashSet<String>>,
|
||||||
|
) {
|
||||||
|
if event.event_type.as_str() != "message.updated" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(info) = event.properties.get("info") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let role = info.get("role").and_then(Value::as_str).unwrap_or_default();
|
||||||
|
if role != "assistant" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let finish = info.get("finish").and_then(Value::as_str);
|
||||||
|
if finish != Some("stop") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let message_id = match info.get("id").and_then(Value::as_str) {
|
||||||
|
Some(id) => id.to_string(),
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut notified = notified_messages.lock().await;
|
||||||
|
if notified.contains(&message_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notified.insert(message_id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw_mode = info
|
||||||
|
.get("mode")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or("agent");
|
||||||
|
let raw_model = info
|
||||||
|
.get("modelID")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or("assistant");
|
||||||
|
|
||||||
|
let title = format!("{} agent is ready", format_mode(raw_mode));
|
||||||
|
let body = format!("{} completed the task", format_model_id(raw_model));
|
||||||
|
|
||||||
|
let should_notify = app
|
||||||
|
.get_webview_window("main")
|
||||||
|
.map(|window| {
|
||||||
|
let focused = window.is_focused().unwrap_or(false);
|
||||||
|
let minimized = window.is_minimized().unwrap_or(false);
|
||||||
|
// Only notify when the app is not in the foreground or is minimized
|
||||||
|
!focused || minimized
|
||||||
|
})
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
if should_notify {
|
||||||
|
let _ = app
|
||||||
|
.notification()
|
||||||
|
.builder()
|
||||||
|
.title(title)
|
||||||
|
.body(body)
|
||||||
|
.sound("Glass")
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_mode(raw: &str) -> String {
|
||||||
|
if raw.is_empty() {
|
||||||
|
return "Agent".to_string();
|
||||||
|
}
|
||||||
|
raw.split(&['-', '_', ' '][..])
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(capitalize)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_model_id(raw: &str) -> String {
|
||||||
|
if raw.is_empty() {
|
||||||
|
return "Assistant".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let tokens: Vec<&str> = raw.split(&['-', '_'][..]).collect();
|
||||||
|
let mut result: Vec<String> = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
while i < tokens.len() {
|
||||||
|
let current = tokens[i];
|
||||||
|
|
||||||
|
if current.chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
if i + 1 < tokens.len() && tokens[i + 1].chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
let combined = format!("{}.{}", current, tokens[i + 1]);
|
||||||
|
result.push(combined);
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(current.to_string());
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
.into_iter()
|
||||||
|
.map(|part| capitalize(&part))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capitalize(s: &str) -> String {
|
||||||
|
let mut chars = s.chars();
|
||||||
|
match chars.next() {
|
||||||
|
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
use crate::{DesktopRuntime, SettingsStore};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::{
|
||||||
|
collections::{HashSet, VecDeque},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
time::UNIX_EPOCH,
|
||||||
|
};
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
|
const DEFAULT_FILE_SEARCH_LIMIT: usize = 60;
|
||||||
|
const MAX_FILE_SEARCH_LIMIT: usize = 400;
|
||||||
|
const FILE_SEARCH_MAX_CONCURRENCY: usize = 5;
|
||||||
|
const FILE_SEARCH_EXCLUDED_DIRS: &[&str] = &[
|
||||||
|
"node_modules",
|
||||||
|
".git",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
".next",
|
||||||
|
".turbo",
|
||||||
|
".cache",
|
||||||
|
"coverage",
|
||||||
|
"tmp",
|
||||||
|
"logs",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FileListEntry {
|
||||||
|
name: String,
|
||||||
|
path: String,
|
||||||
|
is_directory: bool,
|
||||||
|
is_file: bool,
|
||||||
|
is_symbolic_link: bool,
|
||||||
|
size: Option<u64>,
|
||||||
|
modified_time: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DirectoryListResult {
|
||||||
|
directory: String,
|
||||||
|
path: String,
|
||||||
|
entries: Vec<FileListEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CreateDirectoryResponse {
|
||||||
|
success: bool,
|
||||||
|
path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FileSearchHit {
|
||||||
|
name: String,
|
||||||
|
path: String,
|
||||||
|
relative_path: String,
|
||||||
|
extension: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SearchFilesResponse {
|
||||||
|
root: String,
|
||||||
|
count: usize,
|
||||||
|
files: Vec<FileSearchHit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum FsCommandError {
|
||||||
|
NotFound,
|
||||||
|
AccessDenied,
|
||||||
|
NotDirectory,
|
||||||
|
OutsideWorkspace,
|
||||||
|
Other(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FsCommandError {
|
||||||
|
fn to_list_message(&self) -> String {
|
||||||
|
match self {
|
||||||
|
FsCommandError::NotFound => "Directory not found".to_string(),
|
||||||
|
FsCommandError::AccessDenied | FsCommandError::OutsideWorkspace => {
|
||||||
|
"Access to directory denied".to_string()
|
||||||
|
}
|
||||||
|
FsCommandError::NotDirectory => "Specified path is not a directory".to_string(),
|
||||||
|
FsCommandError::Other(message) => {
|
||||||
|
let _ = message;
|
||||||
|
"Failed to list directory".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_search_message(&self) -> String {
|
||||||
|
match self {
|
||||||
|
FsCommandError::NotFound => "Directory not found".to_string(),
|
||||||
|
FsCommandError::AccessDenied | FsCommandError::OutsideWorkspace => {
|
||||||
|
"Access to directory denied".to_string()
|
||||||
|
}
|
||||||
|
FsCommandError::NotDirectory => "Specified path is not a directory".to_string(),
|
||||||
|
FsCommandError::Other(message) => {
|
||||||
|
let _ = message;
|
||||||
|
"Failed to search files".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_create_message(&self) -> String {
|
||||||
|
match self {
|
||||||
|
FsCommandError::AccessDenied | FsCommandError::OutsideWorkspace => {
|
||||||
|
"Access to directory denied".to_string()
|
||||||
|
}
|
||||||
|
FsCommandError::NotDirectory => "Parent path must be a directory".to_string(),
|
||||||
|
FsCommandError::Other(message) => {
|
||||||
|
let _ = message;
|
||||||
|
"Failed to create directory".to_string()
|
||||||
|
}
|
||||||
|
FsCommandError::NotFound => "Parent directory not found".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for FsCommandError {
|
||||||
|
fn from(error: std::io::Error) -> Self {
|
||||||
|
match error.kind() {
|
||||||
|
std::io::ErrorKind::NotFound => FsCommandError::NotFound,
|
||||||
|
std::io::ErrorKind::PermissionDenied => FsCommandError::AccessDenied,
|
||||||
|
_ => FsCommandError::Other(error.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_directory(
|
||||||
|
path: Option<String>,
|
||||||
|
state: tauri::State<'_, DesktopRuntime>,
|
||||||
|
) -> Result<DirectoryListResult, String> {
|
||||||
|
let workspace_root = resolve_workspace_root(state.settings()).await;
|
||||||
|
let resolved_path = resolve_sandboxed_path(path, workspace_root.as_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_list_message())?;
|
||||||
|
|
||||||
|
let metadata = fs::metadata(&resolved_path)
|
||||||
|
.await
|
||||||
|
.map_err(|err| FsCommandError::from(err).to_list_message())?;
|
||||||
|
|
||||||
|
if !metadata.is_dir() {
|
||||||
|
return Err(FsCommandError::NotDirectory.to_list_message());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-check boundary after canonicalization to guard against traversal
|
||||||
|
if let Some(root) = &workspace_root {
|
||||||
|
if !resolved_path.starts_with(root) {
|
||||||
|
return Err(FsCommandError::OutsideWorkspace.to_list_message());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
let mut dir_entries = fs::read_dir(&resolved_path)
|
||||||
|
.await
|
||||||
|
.map_err(|err| FsCommandError::from(err).to_list_message())?;
|
||||||
|
|
||||||
|
while let Some(entry) = dir_entries
|
||||||
|
.next_entry()
|
||||||
|
.await
|
||||||
|
.map_err(|err| FsCommandError::from(err).to_list_message())?
|
||||||
|
{
|
||||||
|
let file_type = entry
|
||||||
|
.file_type()
|
||||||
|
.await
|
||||||
|
.map_err(|err| FsCommandError::from(err).to_list_message())?;
|
||||||
|
|
||||||
|
let entry_path = entry.path();
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
let mut is_directory = file_type.is_dir();
|
||||||
|
let is_symlink = file_type.is_symlink();
|
||||||
|
|
||||||
|
if !is_directory && is_symlink {
|
||||||
|
if let Ok(link_meta) = fs::metadata(&entry_path).await {
|
||||||
|
is_directory = link_meta.is_dir();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata = fs::metadata(&entry_path).await.ok();
|
||||||
|
let size = metadata
|
||||||
|
.as_ref()
|
||||||
|
.filter(|meta| meta.is_file())
|
||||||
|
.map(|meta| meta.len());
|
||||||
|
let modified_time = metadata
|
||||||
|
.and_then(|meta| meta.modified().ok())
|
||||||
|
.and_then(|mtime| mtime.duration_since(UNIX_EPOCH).ok())
|
||||||
|
.map(|duration| duration.as_millis() as i64);
|
||||||
|
|
||||||
|
entries.push(FileListEntry {
|
||||||
|
name,
|
||||||
|
path: normalize_path(&entry_path),
|
||||||
|
is_directory,
|
||||||
|
is_file: file_type.is_file(),
|
||||||
|
is_symbolic_link: is_symlink,
|
||||||
|
size,
|
||||||
|
modified_time,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(DirectoryListResult {
|
||||||
|
directory: normalize_path(&resolved_path),
|
||||||
|
path: normalize_path(&resolved_path),
|
||||||
|
entries,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn search_files(
|
||||||
|
directory: Option<String>,
|
||||||
|
query: Option<String>,
|
||||||
|
max_results: Option<usize>,
|
||||||
|
state: tauri::State<'_, DesktopRuntime>,
|
||||||
|
) -> Result<SearchFilesResponse, String> {
|
||||||
|
let workspace_root = resolve_workspace_root(state.settings()).await;
|
||||||
|
let resolved_root = resolve_sandboxed_path(directory, workspace_root.as_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_search_message())?;
|
||||||
|
|
||||||
|
let limit = clamp_search_limit(max_results);
|
||||||
|
let normalized_query = query.unwrap_or_default().trim().to_lowercase();
|
||||||
|
let match_all = normalized_query.is_empty();
|
||||||
|
|
||||||
|
let mut files = Vec::new();
|
||||||
|
let mut queue = VecDeque::new();
|
||||||
|
let mut visited = HashSet::new();
|
||||||
|
|
||||||
|
queue.push_back(resolved_root.clone());
|
||||||
|
visited.insert(resolved_root.clone());
|
||||||
|
|
||||||
|
while !queue.is_empty() && files.len() < limit {
|
||||||
|
for _ in 0..FILE_SEARCH_MAX_CONCURRENCY {
|
||||||
|
let Some(dir) = queue.pop_front() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut entries = match fs::read_dir(&dir).await {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
|
let Ok(file_type) = entry.file_type().await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name_str = name.to_string_lossy();
|
||||||
|
if name_str.is_empty() || name_str.starts_with('.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry_path = entry.path();
|
||||||
|
if file_type.is_dir() {
|
||||||
|
if should_skip_directory(&name_str) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if visited.insert(entry_path.clone()) && files.len() < limit {
|
||||||
|
queue.push_back(entry_path);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !file_type.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let relative_path = relative_path(&resolved_root, &entry_path);
|
||||||
|
if !match_all {
|
||||||
|
let lowercase_name = name_str.to_lowercase();
|
||||||
|
let lowercase_path = relative_path.to_lowercase();
|
||||||
|
if !lowercase_name.contains(&normalized_query)
|
||||||
|
&& !lowercase_path.contains(&normalized_query)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let extension = entry_path
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.map(|ext| ext.to_lowercase());
|
||||||
|
|
||||||
|
files.push(FileSearchHit {
|
||||||
|
name: name_str.to_string(),
|
||||||
|
path: normalize_path(&entry_path),
|
||||||
|
relative_path: relative_path.replace('\\', "/"),
|
||||||
|
extension,
|
||||||
|
});
|
||||||
|
|
||||||
|
if files.len() >= limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if files.len() >= limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(SearchFilesResponse {
|
||||||
|
root: normalize_path(&resolved_root),
|
||||||
|
count: files.len(),
|
||||||
|
files,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_directory(
|
||||||
|
path: String,
|
||||||
|
state: tauri::State<'_, DesktopRuntime>,
|
||||||
|
) -> Result<CreateDirectoryResponse, String> {
|
||||||
|
let trimmed = path.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err("Path is required".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let workspace_root = resolve_workspace_root(state.settings()).await;
|
||||||
|
let resolved_path = resolve_creatable_path(trimmed, workspace_root.as_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_create_message())?;
|
||||||
|
|
||||||
|
fs::create_dir_all(&resolved_path)
|
||||||
|
.await
|
||||||
|
.map_err(|err| FsCommandError::from(err).to_create_message())?;
|
||||||
|
|
||||||
|
Ok(CreateDirectoryResponse {
|
||||||
|
success: true,
|
||||||
|
path: normalize_path(&resolved_path),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_sandboxed_path(
|
||||||
|
path: Option<String>,
|
||||||
|
workspace_root: Option<&PathBuf>,
|
||||||
|
) -> Result<PathBuf, FsCommandError> {
|
||||||
|
let candidate_input = path
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| value.trim())
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
|
||||||
|
let candidate_path = match (candidate_input, workspace_root) {
|
||||||
|
(Some(value), _) => PathBuf::from(value),
|
||||||
|
(None, Some(root)) => root.clone(),
|
||||||
|
(None, None) => default_home_directory(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolved = if candidate_path.is_absolute() {
|
||||||
|
candidate_path
|
||||||
|
} else if let Some(root) = workspace_root {
|
||||||
|
root.join(candidate_path)
|
||||||
|
} else {
|
||||||
|
default_home_directory().join(candidate_path)
|
||||||
|
};
|
||||||
|
|
||||||
|
let canonicalized = fs::canonicalize(&resolved)
|
||||||
|
.await
|
||||||
|
.map_err(FsCommandError::from)?;
|
||||||
|
|
||||||
|
if let Some(root) = workspace_root {
|
||||||
|
if !canonicalized.starts_with(root) {
|
||||||
|
return Err(FsCommandError::OutsideWorkspace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(canonicalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_creatable_path(
|
||||||
|
path: &str,
|
||||||
|
workspace_root: Option<&PathBuf>,
|
||||||
|
) -> Result<PathBuf, FsCommandError> {
|
||||||
|
let candidate = PathBuf::from(path);
|
||||||
|
if candidate.as_os_str().is_empty() {
|
||||||
|
return Err(FsCommandError::Other("Path is required".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let absolute = if candidate.is_absolute() {
|
||||||
|
candidate
|
||||||
|
} else if let Some(root) = workspace_root {
|
||||||
|
root.join(candidate)
|
||||||
|
} else {
|
||||||
|
default_home_directory().join(candidate)
|
||||||
|
};
|
||||||
|
|
||||||
|
let parent = absolute.parent().ok_or(FsCommandError::NotDirectory)?;
|
||||||
|
|
||||||
|
let canonical_parent = fs::canonicalize(parent)
|
||||||
|
.await
|
||||||
|
.map_err(FsCommandError::from)?;
|
||||||
|
|
||||||
|
if let Some(root) = workspace_root {
|
||||||
|
if !canonical_parent.starts_with(root) {
|
||||||
|
return Err(FsCommandError::OutsideWorkspace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(absolute)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_workspace_root(settings: &SettingsStore) -> Option<PathBuf> {
|
||||||
|
if let Ok(Some(last_dir)) = settings.last_directory().await {
|
||||||
|
if let Ok(canonicalized) = fs::canonicalize(&last_dir).await {
|
||||||
|
return Some(canonicalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_home_directory() -> PathBuf {
|
||||||
|
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clamp_search_limit(value: Option<usize>) -> usize {
|
||||||
|
let limit = value.unwrap_or(DEFAULT_FILE_SEARCH_LIMIT);
|
||||||
|
limit.clamp(1, MAX_FILE_SEARCH_LIMIT)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_skip_directory(name: &str) -> bool {
|
||||||
|
if name.starts_with('.') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
FILE_SEARCH_EXCLUDED_DIRS
|
||||||
|
.iter()
|
||||||
|
.any(|dir| dir.eq_ignore_ascii_case(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_path(path: &Path) -> String {
|
||||||
|
path.to_string_lossy().replace('\\', "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relative_path(root: &Path, target: &Path) -> String {
|
||||||
|
target
|
||||||
|
.strip_prefix(root)
|
||||||
|
.map(|relative| normalize_path(relative))
|
||||||
|
.unwrap_or_else(|_| normalize_path(target))
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
|||||||
|
use crate::logging::log_file_path;
|
||||||
|
use serde::Serialize;
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DesktopLogFile {
|
||||||
|
pub file_name: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn fetch_desktop_logs() -> Result<DesktopLogFile, String> {
|
||||||
|
let path = log_file_path().ok_or_else(|| "Log location unavailable".to_string())?;
|
||||||
|
let content = fs::read_to_string(&path)
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Failed to read log file: {err}"))?;
|
||||||
|
let file_name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or("desktop.log")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(DesktopLogFile { file_name, content })
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
pub mod files;
|
||||||
|
pub mod git;
|
||||||
|
pub mod logs;
|
||||||
|
pub mod permissions;
|
||||||
|
pub mod settings;
|
||||||
|
pub mod terminal;
|
||||||
|
pub mod notifications;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use tauri::{AppHandle, Runtime};
|
||||||
|
use tauri_plugin_notification::NotificationExt;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct NotificationPayload {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub body: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn desktop_notify<R: Runtime>(
|
||||||
|
app: AppHandle<R>,
|
||||||
|
payload: Option<NotificationPayload>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let title = payload
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.title.as_deref())
|
||||||
|
.unwrap_or("OpenChamber");
|
||||||
|
let body = payload
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.body.as_deref())
|
||||||
|
.unwrap_or("Task completed");
|
||||||
|
|
||||||
|
match app
|
||||||
|
.notification()
|
||||||
|
.builder()
|
||||||
|
.title(title)
|
||||||
|
.body(body)
|
||||||
|
.sound("Glass")
|
||||||
|
.show()
|
||||||
|
{
|
||||||
|
Ok(_) => Ok(true),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
use log::{info, warn};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::DesktopRuntime;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DirectoryPermissionRequest {
|
||||||
|
path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DirectoryPermissionResult {
|
||||||
|
success: bool,
|
||||||
|
path: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct StartAccessingResult {
|
||||||
|
success: bool,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process directory selection from frontend
|
||||||
|
/// Updates settings with lastDirectory
|
||||||
|
/// OpenCode restart is triggered separately via /api/opencode/directory endpoint
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn process_directory_selection(
|
||||||
|
path: String,
|
||||||
|
state: State<'_, DesktopRuntime>,
|
||||||
|
) -> Result<DirectoryPermissionResult, String> {
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
// Validate directory exists
|
||||||
|
let path_buf = PathBuf::from(&path);
|
||||||
|
if !path_buf.exists() {
|
||||||
|
return Ok(DirectoryPermissionResult {
|
||||||
|
success: false,
|
||||||
|
path: None,
|
||||||
|
error: Some("Directory does not exist".to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if !path_buf.is_dir() {
|
||||||
|
return Ok(DirectoryPermissionResult {
|
||||||
|
success: false,
|
||||||
|
path: None,
|
||||||
|
error: Some("Path is not a directory".to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update settings with lastDirectory
|
||||||
|
let mut settings = state
|
||||||
|
.settings()
|
||||||
|
.load()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to load settings: {}", e))?;
|
||||||
|
|
||||||
|
if let Some(obj) = settings.as_object_mut() {
|
||||||
|
obj.insert(
|
||||||
|
"lastDirectory".to_string(),
|
||||||
|
serde_json::Value::String(path.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
state
|
||||||
|
.settings()
|
||||||
|
.save(settings)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to save updated settings: {}", e))?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"[permissions] Updated settings with lastDirectory: {}",
|
||||||
|
path
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(DirectoryPermissionResult {
|
||||||
|
success: true,
|
||||||
|
path: Some(path),
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy directory picker command (frontend handles actual dialog)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn pick_directory(
|
||||||
|
_app_handle: AppHandle,
|
||||||
|
_state: State<'_, DesktopRuntime>,
|
||||||
|
) -> Result<DirectoryPermissionResult, String> {
|
||||||
|
Ok(DirectoryPermissionResult {
|
||||||
|
success: false,
|
||||||
|
path: None,
|
||||||
|
error: Some(
|
||||||
|
"Use requestDirectoryAccess instead - it handles native dialog properly".to_string(),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request directory access (desktop implementation)
|
||||||
|
/// For unsandboxed apps, just validates the path is accessible
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn request_directory_access(
|
||||||
|
request: DirectoryPermissionRequest,
|
||||||
|
_state: State<'_, DesktopRuntime>,
|
||||||
|
) -> Result<DirectoryPermissionResult, String> {
|
||||||
|
let path = request.path;
|
||||||
|
|
||||||
|
let path_buf = std::path::PathBuf::from(&path);
|
||||||
|
if !path_buf.exists() {
|
||||||
|
return Ok(DirectoryPermissionResult {
|
||||||
|
success: false,
|
||||||
|
path: None,
|
||||||
|
error: Some("Directory does not exist".to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if !path_buf.is_dir() {
|
||||||
|
return Ok(DirectoryPermissionResult {
|
||||||
|
success: false,
|
||||||
|
path: None,
|
||||||
|
error: Some("Path is not a directory".to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// For unsandboxed apps, no bookmark needed - just verify access
|
||||||
|
match std::fs::read_dir(&path_buf) {
|
||||||
|
Ok(_) => Ok(DirectoryPermissionResult {
|
||||||
|
success: true,
|
||||||
|
path: Some(path),
|
||||||
|
error: None,
|
||||||
|
}),
|
||||||
|
Err(e) => Ok(DirectoryPermissionResult {
|
||||||
|
success: false,
|
||||||
|
path: None,
|
||||||
|
error: Some(format!("Cannot access directory: {}", e)),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start accessing directory (desktop implementation)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn start_accessing_directory(
|
||||||
|
path: String,
|
||||||
|
_state: State<'_, DesktopRuntime>,
|
||||||
|
) -> Result<StartAccessingResult, String> {
|
||||||
|
// Check if directory exists and is accessible
|
||||||
|
let path_buf = std::path::PathBuf::from(&path);
|
||||||
|
|
||||||
|
if !path_buf.exists() {
|
||||||
|
return Ok(StartAccessingResult {
|
||||||
|
success: false,
|
||||||
|
error: Some("Directory does not exist".to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if !path_buf.is_dir() {
|
||||||
|
return Ok(StartAccessingResult {
|
||||||
|
success: false,
|
||||||
|
error: Some("Path is not a directory".to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to read the directory to verify access
|
||||||
|
match std::fs::read_dir(&path_buf) {
|
||||||
|
Ok(_) => {
|
||||||
|
info!("Successfully started accessing directory: {}", path);
|
||||||
|
Ok(StartAccessingResult {
|
||||||
|
success: true,
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to access directory {}: {}", path, e);
|
||||||
|
Ok(StartAccessingResult {
|
||||||
|
success: false,
|
||||||
|
error: Some(format!("Failed to access directory: {}", e)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop accessing directory (desktop implementation)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn stop_accessing_directory(
|
||||||
|
_path: String,
|
||||||
|
_state: State<'_, DesktopRuntime>,
|
||||||
|
) -> Result<StartAccessingResult, String> {
|
||||||
|
// For Stage 1, just confirm the operation
|
||||||
|
// Full implementation would call stopAccessingSecurityScopedResource
|
||||||
|
info!("Stopped accessing directory");
|
||||||
|
Ok(StartAccessingResult {
|
||||||
|
success: true,
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore bookmarks on app startup (no-op for unsandboxed apps)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn restore_bookmarks_on_startup(_state: State<'_, DesktopRuntime>) -> Result<(), String> {
|
||||||
|
// For unsandboxed apps, no bookmarks needed
|
||||||
|
// Directory access is restored from settings.lastDirectory
|
||||||
|
info!("[permissions] Bookmark restore not needed for unsandboxed app");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::DesktopRuntime;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SettingsLoadResult {
|
||||||
|
settings: Value,
|
||||||
|
source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RestartResult {
|
||||||
|
restarted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load settings from disk (matches Express handler behavior)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn load_settings(state: State<'_, DesktopRuntime>) -> Result<SettingsLoadResult, String> {
|
||||||
|
let settings = state
|
||||||
|
.settings()
|
||||||
|
.load()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to load settings: {}", e))?;
|
||||||
|
|
||||||
|
Ok(SettingsLoadResult {
|
||||||
|
settings,
|
||||||
|
source: "desktop".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save settings to disk with merge logic matching Express implementation
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_settings(
|
||||||
|
changes: Value,
|
||||||
|
state: State<'_, DesktopRuntime>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
// Load current settings
|
||||||
|
let current = state
|
||||||
|
.settings()
|
||||||
|
.load()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to load current settings: {}", e))?;
|
||||||
|
|
||||||
|
// Sanitize incoming changes
|
||||||
|
let sanitized_changes = sanitize_settings_update(&changes);
|
||||||
|
|
||||||
|
// Merge changes into current settings
|
||||||
|
let merged = merge_persisted_settings(¤t, &sanitized_changes);
|
||||||
|
|
||||||
|
// Save merged settings
|
||||||
|
state
|
||||||
|
.settings()
|
||||||
|
.save(merged.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to save settings: {}", e))?;
|
||||||
|
|
||||||
|
// Format response
|
||||||
|
Ok(format_settings_response(&merged))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restart OpenCode CLI (matches Express /api/config/reload)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn restart_opencode(state: State<'_, DesktopRuntime>) -> Result<RestartResult, String> {
|
||||||
|
state
|
||||||
|
.opencode
|
||||||
|
.restart()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to restart OpenCode: {}", e))?;
|
||||||
|
|
||||||
|
Ok(RestartResult { restarted: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanitize settings update payload (port of Express sanitizeSettingsUpdate)
|
||||||
|
fn sanitize_settings_update(payload: &Value) -> Value {
|
||||||
|
let mut result = json!({});
|
||||||
|
|
||||||
|
if let Some(obj) = payload.as_object() {
|
||||||
|
let result_obj = result.as_object_mut().unwrap();
|
||||||
|
|
||||||
|
// String fields
|
||||||
|
if let Some(Value::String(s)) = obj.get("themeId") {
|
||||||
|
if !s.is_empty() {
|
||||||
|
result_obj.insert("themeId".to_string(), json!(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(Value::String(s)) = obj.get("themeVariant") {
|
||||||
|
if s == "light" || s == "dark" {
|
||||||
|
result_obj.insert("themeVariant".to_string(), json!(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(Value::String(s)) = obj.get("lightThemeId") {
|
||||||
|
if !s.is_empty() {
|
||||||
|
result_obj.insert("lightThemeId".to_string(), json!(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(Value::String(s)) = obj.get("darkThemeId") {
|
||||||
|
if !s.is_empty() {
|
||||||
|
result_obj.insert("darkThemeId".to_string(), json!(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(Value::String(s)) = obj.get("lastDirectory") {
|
||||||
|
if !s.is_empty() {
|
||||||
|
result_obj.insert("lastDirectory".to_string(), json!(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(Value::String(s)) = obj.get("homeDirectory") {
|
||||||
|
if !s.is_empty() {
|
||||||
|
result_obj.insert("homeDirectory".to_string(), json!(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(Value::String(s)) = obj.get("uiFont") {
|
||||||
|
if !s.is_empty() {
|
||||||
|
result_obj.insert("uiFont".to_string(), json!(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(Value::String(s)) = obj.get("monoFont") {
|
||||||
|
if !s.is_empty() {
|
||||||
|
result_obj.insert("monoFont".to_string(), json!(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(Value::String(s)) = obj.get("markdownDisplayMode") {
|
||||||
|
if !s.is_empty() {
|
||||||
|
result_obj.insert("markdownDisplayMode".to_string(), json!(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boolean fields
|
||||||
|
if let Some(Value::Bool(b)) = obj.get("useSystemTheme") {
|
||||||
|
result_obj.insert("useSystemTheme".to_string(), json!(b));
|
||||||
|
}
|
||||||
|
if let Some(Value::Bool(b)) = obj.get("showReasoningTraces") {
|
||||||
|
result_obj.insert("showReasoningTraces".to_string(), json!(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array fields
|
||||||
|
if let Some(arr) = obj.get("approvedDirectories") {
|
||||||
|
result_obj.insert(
|
||||||
|
"approvedDirectories".to_string(),
|
||||||
|
normalize_string_array(arr),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(arr) = obj.get("securityScopedBookmarks") {
|
||||||
|
result_obj.insert(
|
||||||
|
"securityScopedBookmarks".to_string(),
|
||||||
|
normalize_string_array(arr),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(arr) = obj.get("pinnedDirectories") {
|
||||||
|
result_obj.insert("pinnedDirectories".to_string(), normalize_string_array(arr));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typography sizes object (partial)
|
||||||
|
if let Some(typo) = obj.get("typographySizes") {
|
||||||
|
if let Some(sanitized) = sanitize_typography_sizes_partial(typo) {
|
||||||
|
result_obj.insert("typographySizes".to_string(), sanitized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge persisted settings (port of Express mergePersistedSettings)
|
||||||
|
fn merge_persisted_settings(current: &Value, changes: &Value) -> Value {
|
||||||
|
let mut result = current.clone();
|
||||||
|
|
||||||
|
if let (Some(result_obj), Some(changes_obj)) = (result.as_object_mut(), changes.as_object()) {
|
||||||
|
// First apply all changes
|
||||||
|
for (key, value) in changes_obj {
|
||||||
|
result_obj.insert(key.clone(), value.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build approvedDirectories from base + additional
|
||||||
|
let base_approved = if let Some(arr) = changes_obj.get("approvedDirectories") {
|
||||||
|
extract_string_vec(arr)
|
||||||
|
} else if let Some(arr) = current.get("approvedDirectories") {
|
||||||
|
extract_string_vec(arr)
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut additional_approved = vec![];
|
||||||
|
if let Some(Value::String(s)) = changes_obj.get("lastDirectory") {
|
||||||
|
if !s.is_empty() {
|
||||||
|
additional_approved.push(s.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(Value::String(s)) = changes_obj.get("homeDirectory") {
|
||||||
|
if !s.is_empty() {
|
||||||
|
additional_approved.push(s.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut approved_set: HashSet<String> = base_approved.into_iter().collect();
|
||||||
|
for item in additional_approved {
|
||||||
|
approved_set.insert(item);
|
||||||
|
}
|
||||||
|
let approved_vec: Vec<String> = approved_set.into_iter().collect();
|
||||||
|
result_obj.insert("approvedDirectories".to_string(), json!(approved_vec));
|
||||||
|
|
||||||
|
// Security scoped bookmarks
|
||||||
|
let base_bookmarks = if let Some(arr) = changes_obj.get("securityScopedBookmarks") {
|
||||||
|
extract_string_vec(arr)
|
||||||
|
} else if let Some(arr) = current.get("securityScopedBookmarks") {
|
||||||
|
extract_string_vec(arr)
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
let bookmarks_set: HashSet<String> = base_bookmarks.into_iter().collect();
|
||||||
|
let bookmarks_vec: Vec<String> = bookmarks_set.into_iter().collect();
|
||||||
|
result_obj.insert("securityScopedBookmarks".to_string(), json!(bookmarks_vec));
|
||||||
|
|
||||||
|
// Merge typography sizes if present
|
||||||
|
if changes_obj.contains_key("typographySizes") {
|
||||||
|
let current_typo = current
|
||||||
|
.get("typographySizes")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let changes_typo = changes_obj
|
||||||
|
.get("typographySizes")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut merged_typo = current_typo;
|
||||||
|
for (key, value) in changes_typo {
|
||||||
|
merged_typo.insert(key, value);
|
||||||
|
}
|
||||||
|
result_obj.insert("typographySizes".to_string(), json!(merged_typo));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format settings response (port of Express formatSettingsResponse)
|
||||||
|
fn format_settings_response(settings: &Value) -> Value {
|
||||||
|
let mut result = sanitize_settings_update(settings);
|
||||||
|
|
||||||
|
if let Some(obj) = result.as_object_mut() {
|
||||||
|
// Ensure array fields are normalized
|
||||||
|
obj.insert(
|
||||||
|
"approvedDirectories".to_string(),
|
||||||
|
normalize_string_array(settings.get("approvedDirectories").unwrap_or(&json!([]))),
|
||||||
|
);
|
||||||
|
obj.insert(
|
||||||
|
"securityScopedBookmarks".to_string(),
|
||||||
|
normalize_string_array(
|
||||||
|
settings
|
||||||
|
.get("securityScopedBookmarks")
|
||||||
|
.unwrap_or(&json!([])),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
obj.insert(
|
||||||
|
"pinnedDirectories".to_string(),
|
||||||
|
normalize_string_array(settings.get("pinnedDirectories").unwrap_or(&json!([]))),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Typography sizes
|
||||||
|
if let Some(sanitized_typo) = sanitize_typography_sizes_partial(
|
||||||
|
settings.get("typographySizes").unwrap_or(&json!(null)),
|
||||||
|
) {
|
||||||
|
obj.insert("typographySizes".to_string(), sanitized_typo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// showReasoningTraces with fallback
|
||||||
|
let show_reasoning = settings
|
||||||
|
.get("showReasoningTraces")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.or_else(|| {
|
||||||
|
// Get showReasoningTraces from sanitized result instead of the current mutable borrow
|
||||||
|
if let Some(Value::Bool(b)) = obj.get("showReasoningTraces") {
|
||||||
|
Some(*b)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
obj.insert("showReasoningTraces".to_string(), json!(show_reasoning));
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize string array helper
|
||||||
|
fn normalize_string_array(input: &Value) -> Value {
|
||||||
|
if let Some(arr) = input.as_array() {
|
||||||
|
let strings: Vec<String> = arr
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect();
|
||||||
|
let unique: HashSet<String> = strings.into_iter().collect();
|
||||||
|
json!(unique.into_iter().collect::<Vec<_>>())
|
||||||
|
} else {
|
||||||
|
json!([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanitize typography sizes partial helper
|
||||||
|
fn sanitize_typography_sizes_partial(input: &Value) -> Option<Value> {
|
||||||
|
if let Some(obj) = input.as_object() {
|
||||||
|
let mut result = serde_json::Map::new();
|
||||||
|
let mut populated = false;
|
||||||
|
|
||||||
|
for key in &["markdown", "code", "uiHeader", "uiLabel", "meta", "micro"] {
|
||||||
|
if let Some(Value::String(s)) = obj.get(*key) {
|
||||||
|
if !s.is_empty() {
|
||||||
|
result.insert(key.to_string(), json!(s));
|
||||||
|
populated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if populated {
|
||||||
|
Some(json!(result))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract string vector from JSON value
|
||||||
|
fn extract_string_vec(value: &Value) -> Vec<String> {
|
||||||
|
if let Some(arr) = value.as_array() {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
use log::error;
|
||||||
|
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
env,
|
||||||
|
io::{Read, Write},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
thread,
|
||||||
|
};
|
||||||
|
use tauri::{Emitter, State, Window};
|
||||||
|
|
||||||
|
const DEFAULT_SHELL: &str = "/bin/zsh";
|
||||||
|
const DEFAULT_TERM: &str = "xterm-256color";
|
||||||
|
const DEFAULT_COLORTERM: &str = "truecolor";
|
||||||
|
const DEFAULT_LOCALE: &str = "en_US.UTF-8";
|
||||||
|
const TERM_PROGRAM_NAME: &str = "OpenChamber";
|
||||||
|
const TERM_PROGRAM_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
|
pub struct TerminalSession {
|
||||||
|
pub master: Box<dyn MasterPty + Send>,
|
||||||
|
pub writer: Arc<Mutex<Box<dyn Write + Send>>>,
|
||||||
|
pub child: Arc<Mutex<Box<dyn Child + Send + Sync>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TerminalState {
|
||||||
|
pub sessions: Arc<Mutex<HashMap<String, TerminalSession>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerminalState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateTerminalPayload {
|
||||||
|
pub cols: u16,
|
||||||
|
pub rows: u16,
|
||||||
|
pub cwd: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct CreateTerminalResponse {
|
||||||
|
pub session_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_terminal_session(
|
||||||
|
payload: CreateTerminalPayload,
|
||||||
|
state: State<'_, TerminalState>,
|
||||||
|
window: Window,
|
||||||
|
) -> Result<CreateTerminalResponse, String> {
|
||||||
|
let pty_system = NativePtySystem::default();
|
||||||
|
let size = PtySize {
|
||||||
|
rows: payload.rows,
|
||||||
|
cols: payload.cols,
|
||||||
|
pixel_width: 0,
|
||||||
|
pixel_height: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let working_dir = resolve_working_directory(payload.cwd.as_deref())?;
|
||||||
|
let shell_path = resolve_shell();
|
||||||
|
|
||||||
|
let mut cmd = CommandBuilder::new(&shell_path);
|
||||||
|
if shell_accepts_login_flag(&shell_path) {
|
||||||
|
cmd.arg("-l");
|
||||||
|
}
|
||||||
|
if let Some(cwd) = working_dir.to_str() {
|
||||||
|
cmd.cwd(cwd);
|
||||||
|
}
|
||||||
|
apply_terminal_environment(&mut cmd, &shell_path);
|
||||||
|
|
||||||
|
let pair = pty_system.openpty(size).map_err(|e| e.to_string())?;
|
||||||
|
let child = pair
|
||||||
|
.slave
|
||||||
|
.spawn_command(cmd)
|
||||||
|
.map_err(|e| format!("Failed to spawn shell: {e}"))?;
|
||||||
|
drop(pair.slave);
|
||||||
|
|
||||||
|
let reader = pair
|
||||||
|
.master
|
||||||
|
.try_clone_reader()
|
||||||
|
.map_err(|e| format!("Failed to clone PTY reader: {e}"))?;
|
||||||
|
let writer = Arc::new(Mutex::new(
|
||||||
|
pair.master
|
||||||
|
.take_writer()
|
||||||
|
.map_err(|e| format!("Failed to take PTY writer: {e}"))?,
|
||||||
|
));
|
||||||
|
let master = pair.master;
|
||||||
|
let child = Arc::new(Mutex::new(child));
|
||||||
|
|
||||||
|
let session_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
state.sessions.lock().unwrap().insert(
|
||||||
|
session_id.clone(),
|
||||||
|
TerminalSession {
|
||||||
|
master,
|
||||||
|
writer: writer.clone(),
|
||||||
|
child: child.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
spawn_reader_thread(reader, window.clone(), session_id.clone());
|
||||||
|
spawn_exit_watcher(child, window, state.sessions.clone(), session_id.clone());
|
||||||
|
|
||||||
|
Ok(CreateTerminalResponse { session_id })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn send_terminal_input(
|
||||||
|
session_id: String,
|
||||||
|
data: String,
|
||||||
|
state: State<'_, TerminalState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let sessions = state.sessions.lock().unwrap();
|
||||||
|
let Some(session) = sessions.get(&session_id) else {
|
||||||
|
return Err("Terminal session not found".to_string());
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut writer = session
|
||||||
|
.writer
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "Terminal busy".to_string())?;
|
||||||
|
writer
|
||||||
|
.write_all(data.as_bytes())
|
||||||
|
.map_err(|e| format!("Failed to write to terminal: {e}"))?;
|
||||||
|
writer
|
||||||
|
.flush()
|
||||||
|
.map_err(|e| format!("Failed to flush terminal input: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn resize_terminal(
|
||||||
|
session_id: String,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
state: State<'_, TerminalState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut sessions = state.sessions.lock().unwrap();
|
||||||
|
let Some(session) = sessions.get_mut(&session_id) else {
|
||||||
|
return Err("Terminal session not found".to_string());
|
||||||
|
};
|
||||||
|
|
||||||
|
session
|
||||||
|
.master
|
||||||
|
.resize(PtySize {
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
pixel_width: 0,
|
||||||
|
pixel_height: 0,
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("Failed to resize terminal: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn close_terminal(
|
||||||
|
session_id: String,
|
||||||
|
state: State<'_, TerminalState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let session = {
|
||||||
|
let mut sessions = state.sessions.lock().unwrap();
|
||||||
|
sessions.remove(&session_id)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(session) = session {
|
||||||
|
if let Ok(mut child) = session.child.lock() {
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_reader_thread(mut reader: Box<dyn Read + Send>, window: Window, session_id: String) {
|
||||||
|
thread::spawn(move || {
|
||||||
|
let mut buffer = [0u8; 4096];
|
||||||
|
let event_name = format!("terminal://{}", session_id);
|
||||||
|
loop {
|
||||||
|
match reader.read(&mut buffer) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
let data = String::from_utf8_lossy(&buffer[..n]).to_string();
|
||||||
|
if data.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(error) =
|
||||||
|
window.emit(&event_name, serde_json::json!({ "type": "data", "data": data }))
|
||||||
|
{
|
||||||
|
error!("Failed to emit terminal data: {error}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
error!("Terminal read error: {error}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_exit_watcher(
|
||||||
|
child: Arc<Mutex<Box<dyn Child + Send + Sync>>>,
|
||||||
|
window: Window,
|
||||||
|
sessions: Arc<Mutex<HashMap<String, TerminalSession>>>,
|
||||||
|
session_id: String,
|
||||||
|
) {
|
||||||
|
thread::spawn(move || {
|
||||||
|
let status = {
|
||||||
|
let mut guard = child.lock().expect("terminal child poisoned");
|
||||||
|
guard.wait()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (exit_code, signal) = match status {
|
||||||
|
Ok(status) => (
|
||||||
|
status.exit_code() as i32,
|
||||||
|
status.signal().map(|sig| sig.to_string()),
|
||||||
|
),
|
||||||
|
Err(err) => {
|
||||||
|
error!("Failed to wait for terminal exit: {err}");
|
||||||
|
(1, Some("Terminal crashed".to_string()))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let event_name = format!("terminal://{}", session_id);
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"type": "exit",
|
||||||
|
"exitCode": exit_code,
|
||||||
|
"signal": signal
|
||||||
|
});
|
||||||
|
let _ = window.emit(&event_name, payload);
|
||||||
|
|
||||||
|
let mut sessions = sessions.lock().unwrap();
|
||||||
|
sessions.remove(&session_id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_shell() -> String {
|
||||||
|
env::var("SHELL")
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| DEFAULT_SHELL.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shell_accepts_login_flag(shell_path: &str) -> bool {
|
||||||
|
let shell_name = Path::new(shell_path)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.unwrap_or(shell_path)
|
||||||
|
.to_lowercase();
|
||||||
|
|
||||||
|
matches!(
|
||||||
|
shell_name.as_str(),
|
||||||
|
name if name.contains("zsh")
|
||||||
|
|| name.contains("bash")
|
||||||
|
|| name.contains("sh")
|
||||||
|
|| name.contains("fish")
|
||||||
|
|| name.contains("ksh")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_working_directory(input: Option<&str>) -> Result<PathBuf, String> {
|
||||||
|
let maybe_path = input
|
||||||
|
.map(|value| PathBuf::from(value))
|
||||||
|
.or_else(|| dirs::home_dir());
|
||||||
|
|
||||||
|
let Some(path) = maybe_path else {
|
||||||
|
return Err("Unable to determine working directory".to_string());
|
||||||
|
};
|
||||||
|
|
||||||
|
if !path.exists() || !path.is_dir() {
|
||||||
|
return Err(format!(
|
||||||
|
"Working directory is not accessible: {}",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_terminal_environment(cmd: &mut CommandBuilder, shell_path: &str) {
|
||||||
|
cmd.env(
|
||||||
|
"TERM",
|
||||||
|
env::var("TERM").unwrap_or_else(|_| DEFAULT_TERM.to_string()),
|
||||||
|
);
|
||||||
|
cmd.env(
|
||||||
|
"COLORTERM",
|
||||||
|
env::var("COLORTERM").unwrap_or_else(|_| DEFAULT_COLORTERM.to_string()),
|
||||||
|
);
|
||||||
|
cmd.env(
|
||||||
|
"LC_ALL",
|
||||||
|
env::var("LC_ALL").unwrap_or_else(|_| DEFAULT_LOCALE.to_string()),
|
||||||
|
);
|
||||||
|
cmd.env(
|
||||||
|
"LANG",
|
||||||
|
env::var("LANG").unwrap_or_else(|_| DEFAULT_LOCALE.to_string()),
|
||||||
|
);
|
||||||
|
cmd.env("TERM_PROGRAM", TERM_PROGRAM_NAME);
|
||||||
|
cmd.env("TERM_PROGRAM_VERSION", TERM_PROGRAM_VERSION);
|
||||||
|
cmd.env("OPENCHAMBER_DESKTOP", "1");
|
||||||
|
cmd.env("SHELL", shell_path);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
const PLATFORM_LOG_SEGMENTS: &[&str] = &["Library", "Logs", "OpenChamber"];
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
const PLATFORM_LOG_SEGMENTS: &[&str] = &[".config", "openchamber", "logs"];
|
||||||
|
|
||||||
|
pub fn log_directory() -> Option<PathBuf> {
|
||||||
|
let mut path = dirs::home_dir()?;
|
||||||
|
for segment in PLATFORM_LOG_SEGMENTS {
|
||||||
|
path.push(segment);
|
||||||
|
}
|
||||||
|
Some(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn log_file_path() -> Option<PathBuf> {
|
||||||
|
let mut dir = log_directory()?;
|
||||||
|
dir.push("desktop.log");
|
||||||
|
Some(dir)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,794 @@
|
|||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use log::info;
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use regex::Regex;
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
|
static PROMPT_FILE_PATTERN: Lazy<Regex> =
|
||||||
|
Lazy::new(|| Regex::new(r"(?i)^\{file:(.+)\}$").expect("valid regex"));
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SourceInfo {
|
||||||
|
pub exists: bool,
|
||||||
|
pub path: Option<String>,
|
||||||
|
pub fields: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ConfigSources {
|
||||||
|
pub md: SourceInfo,
|
||||||
|
pub json: SourceInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get OpenCode config directory path
|
||||||
|
fn get_config_dir() -> PathBuf {
|
||||||
|
dirs::home_dir()
|
||||||
|
.expect("Cannot determine home directory")
|
||||||
|
.join(".config")
|
||||||
|
.join("opencode")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get agent directory path
|
||||||
|
fn get_agent_dir() -> PathBuf {
|
||||||
|
get_config_dir().join("agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get command directory path
|
||||||
|
fn get_command_dir() -> PathBuf {
|
||||||
|
get_config_dir().join("command")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get config file path
|
||||||
|
fn get_config_file() -> PathBuf {
|
||||||
|
get_config_dir().join("opencode.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure required directories exist
|
||||||
|
async fn ensure_dirs() -> Result<()> {
|
||||||
|
let config_dir = get_config_dir();
|
||||||
|
let agent_dir = get_agent_dir();
|
||||||
|
let command_dir = get_command_dir();
|
||||||
|
|
||||||
|
fs::create_dir_all(&config_dir).await?;
|
||||||
|
fs::create_dir_all(&agent_dir).await?;
|
||||||
|
fs::create_dir_all(&command_dir).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a value is a prompt file reference like {file:./prompts/agent.txt}
|
||||||
|
fn is_prompt_file_reference(value: &str) -> bool {
|
||||||
|
PROMPT_FILE_PATTERN.is_match(value.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a prompt file reference to an absolute path
|
||||||
|
fn resolve_prompt_file_path(reference: &str) -> Option<PathBuf> {
|
||||||
|
let trimmed = reference.trim();
|
||||||
|
let captures = PROMPT_FILE_PATTERN.captures(trimmed)?;
|
||||||
|
let target = captures.get(1)?.as_str().trim();
|
||||||
|
|
||||||
|
if target.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = if target.starts_with("./") {
|
||||||
|
get_config_dir().join(&target[2..])
|
||||||
|
} else if Path::new(target).is_absolute() {
|
||||||
|
PathBuf::from(target)
|
||||||
|
} else {
|
||||||
|
get_config_dir().join(target)
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write content to a prompt file
|
||||||
|
async fn write_prompt_file(file_path: &Path, content: &str) -> Result<()> {
|
||||||
|
if let Some(parent) = file_path.parent() {
|
||||||
|
fs::create_dir_all(parent).await?;
|
||||||
|
}
|
||||||
|
fs::write(file_path, content).await?;
|
||||||
|
info!("Updated prompt file: {}", file_path.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip JSON comments from content
|
||||||
|
fn strip_json_comments(content: &str) -> String {
|
||||||
|
let mut result = String::new();
|
||||||
|
let mut in_string = false;
|
||||||
|
let mut escape_next = false;
|
||||||
|
let mut chars = content.chars().peekable();
|
||||||
|
|
||||||
|
while let Some(ch) = chars.next() {
|
||||||
|
if escape_next {
|
||||||
|
result.push(ch);
|
||||||
|
escape_next = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == '\\' && in_string {
|
||||||
|
result.push(ch);
|
||||||
|
escape_next = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == '"' {
|
||||||
|
in_string = !in_string;
|
||||||
|
result.push(ch);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !in_string {
|
||||||
|
if ch == '/' {
|
||||||
|
if let Some(&next_ch) = chars.peek() {
|
||||||
|
if next_ch == '/' {
|
||||||
|
// Line comment - skip until end of line
|
||||||
|
chars.next(); // consume the second '/'
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
if c == '\n' {
|
||||||
|
result.push('\n');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
} else if next_ch == '*' {
|
||||||
|
// Block comment - skip until */
|
||||||
|
chars.next(); // consume the '*'
|
||||||
|
let mut prev = ' ';
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
if prev == '*' && c == '/' {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
prev = c;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read opencode.json configuration file
|
||||||
|
pub async fn read_config() -> Result<Value> {
|
||||||
|
let config_file = get_config_file();
|
||||||
|
|
||||||
|
if !config_file.exists() {
|
||||||
|
return Ok(Value::Object(serde_json::Map::new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs::read_to_string(&config_file).await?;
|
||||||
|
let normalized = strip_json_comments(&content).trim().to_string();
|
||||||
|
|
||||||
|
if normalized.is_empty() {
|
||||||
|
return Ok(Value::Object(serde_json::Map::new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::from_str(&normalized).map_err(|e| anyhow!("Failed to parse config: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write opencode.json configuration file with backup
|
||||||
|
pub async fn write_config(config: &Value) -> Result<()> {
|
||||||
|
let config_file = get_config_file();
|
||||||
|
|
||||||
|
// Create/overwrite single backup before writing
|
||||||
|
if config_file.exists() {
|
||||||
|
let file_name = config_file
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.ok_or_else(|| anyhow!("Invalid config file name"))?;
|
||||||
|
|
||||||
|
let backup_path = config_file.with_file_name(format!("{file_name}.openchamber.backup"));
|
||||||
|
fs::copy(&config_file, &backup_path).await?;
|
||||||
|
info!("Created config backup: {}", backup_path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
let json_string = serde_json::to_string_pretty(config)?;
|
||||||
|
fs::write(&config_file, json_string).await?;
|
||||||
|
info!("Successfully wrote config file");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Markdown file data
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct MdData {
|
||||||
|
frontmatter: HashMap<String, Value>,
|
||||||
|
body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse markdown file with YAML frontmatter
|
||||||
|
async fn parse_md_file(file_path: &Path) -> Result<MdData> {
|
||||||
|
let content = fs::read_to_string(file_path).await?;
|
||||||
|
|
||||||
|
// Match YAML frontmatter: ---\n...\n---\n
|
||||||
|
let re = Regex::new(r"(?s)^---\r?\n(.*?)\r?\n---\r?\n(.*)$").expect("valid regex");
|
||||||
|
|
||||||
|
if let Some(captures) = re.captures(&content) {
|
||||||
|
let yaml_str = captures.get(1).map(|m| m.as_str()).unwrap_or("");
|
||||||
|
let body = captures.get(2).map(|m| m.as_str()).unwrap_or("").trim();
|
||||||
|
|
||||||
|
let frontmatter: HashMap<String, Value> =
|
||||||
|
serde_yaml::from_str(yaml_str).unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(MdData {
|
||||||
|
frontmatter,
|
||||||
|
body: body.to_string(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// No frontmatter, treat entire content as body
|
||||||
|
Ok(MdData {
|
||||||
|
frontmatter: HashMap::new(),
|
||||||
|
body: content.trim().to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write markdown file with YAML frontmatter
|
||||||
|
async fn write_md_file(
|
||||||
|
file_path: &Path,
|
||||||
|
frontmatter: &HashMap<String, Value>,
|
||||||
|
body: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let yaml_str = serde_yaml::to_string(frontmatter)?;
|
||||||
|
let content = format!("---\n{}---\n\n{}", yaml_str, body);
|
||||||
|
|
||||||
|
fs::write(file_path, content).await?;
|
||||||
|
info!("Successfully wrote markdown file: {}", file_path.display());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get information about where agent configuration is stored
|
||||||
|
pub async fn get_agent_sources(agent_name: &str) -> Result<ConfigSources> {
|
||||||
|
ensure_dirs().await?;
|
||||||
|
|
||||||
|
let md_path = get_agent_dir().join(format!("{}.md", agent_name));
|
||||||
|
let md_exists = md_path.exists();
|
||||||
|
|
||||||
|
let mut md_fields = Vec::new();
|
||||||
|
if md_exists {
|
||||||
|
let md_data = parse_md_file(&md_path).await?;
|
||||||
|
md_fields.extend(md_data.frontmatter.keys().cloned());
|
||||||
|
if !md_data.body.trim().is_empty() {
|
||||||
|
md_fields.push("prompt".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = read_config().await?;
|
||||||
|
let json_section = config
|
||||||
|
.get("agent")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.and_then(|obj| obj.get(agent_name));
|
||||||
|
|
||||||
|
let json_fields = json_section
|
||||||
|
.and_then(|value| value.as_object())
|
||||||
|
.map(|obj| obj.keys().cloned().collect::<Vec<_>>())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let sources = ConfigSources {
|
||||||
|
md: SourceInfo {
|
||||||
|
exists: md_exists,
|
||||||
|
path: md_exists.then(|| md_path.display().to_string()),
|
||||||
|
fields: md_fields,
|
||||||
|
},
|
||||||
|
json: SourceInfo {
|
||||||
|
exists: json_section.is_some(),
|
||||||
|
path: Some(get_config_file().display().to_string()),
|
||||||
|
fields: json_fields,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create new agent as .md file
|
||||||
|
pub async fn create_agent(agent_name: &str, config: &HashMap<String, Value>) -> Result<()> {
|
||||||
|
ensure_dirs().await?;
|
||||||
|
|
||||||
|
let md_path = get_agent_dir().join(format!("{}.md", agent_name));
|
||||||
|
|
||||||
|
// Check if agent already exists
|
||||||
|
if md_path.exists() {
|
||||||
|
return Err(anyhow!("Agent {} already exists as .md file", agent_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing_config = read_config().await?;
|
||||||
|
if let Some(agents) = existing_config.get("agent").and_then(|v| v.as_object()) {
|
||||||
|
if agents.contains_key(agent_name) {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"Agent {} already exists in opencode.json",
|
||||||
|
agent_name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract prompt from config
|
||||||
|
let mut frontmatter = config.clone();
|
||||||
|
let prompt = frontmatter
|
||||||
|
.remove("prompt")
|
||||||
|
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Write .md file
|
||||||
|
write_md_file(&md_path, &frontmatter, &prompt).await?;
|
||||||
|
info!("Created new agent: {}", agent_name);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update existing agent using field-level logic
|
||||||
|
pub async fn update_agent(agent_name: &str, updates: &HashMap<String, Value>) -> Result<()> {
|
||||||
|
ensure_dirs().await?;
|
||||||
|
|
||||||
|
let md_path = get_agent_dir().join(format!("{}.md", agent_name));
|
||||||
|
let md_exists = md_path.exists();
|
||||||
|
|
||||||
|
let mut md_data = if md_exists {
|
||||||
|
Some(parse_md_file(&md_path).await?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut config = read_config().await?;
|
||||||
|
let mut existing_agent = config
|
||||||
|
.get("agent")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.and_then(|obj| obj.get(agent_name))
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(Map::new);
|
||||||
|
let had_json_fields = !existing_agent.is_empty();
|
||||||
|
|
||||||
|
let mut md_modified = false;
|
||||||
|
let mut json_modified = false;
|
||||||
|
|
||||||
|
for (field, value) in updates.iter() {
|
||||||
|
// Handle explicit removals (null payload) for scalar/frontmatter/JSON fields
|
||||||
|
if value.is_null() {
|
||||||
|
if md_exists {
|
||||||
|
if let Some(ref mut data) = md_data {
|
||||||
|
if data.frontmatter.remove(field).is_some() {
|
||||||
|
md_modified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if existing_agent.remove(field).is_some() {
|
||||||
|
json_modified = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special handling for prompt field
|
||||||
|
if field == "prompt" {
|
||||||
|
let normalized_value = value.as_str().unwrap_or("").to_string();
|
||||||
|
|
||||||
|
if md_exists {
|
||||||
|
if let Some(ref mut data) = md_data {
|
||||||
|
data.body = normalized_value.clone();
|
||||||
|
md_modified = true;
|
||||||
|
}
|
||||||
|
} else if let Some(prompt_ref) = existing_agent.get("prompt").and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
if is_prompt_file_reference(prompt_ref) {
|
||||||
|
if let Some(prompt_file_path) = resolve_prompt_file_path(prompt_ref) {
|
||||||
|
write_prompt_file(&prompt_file_path, &normalized_value).await?;
|
||||||
|
} else {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"Invalid prompt file reference for agent {}",
|
||||||
|
agent_name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write prompt directly to JSON entry (file ref or inline string)
|
||||||
|
existing_agent.insert("prompt".to_string(), Value::String(normalized_value));
|
||||||
|
json_modified = true;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check where field is currently defined
|
||||||
|
let in_md = md_data
|
||||||
|
.as_ref()
|
||||||
|
.map(|data| data.frontmatter.contains_key(field))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let in_json = existing_agent.contains_key(field);
|
||||||
|
|
||||||
|
if in_md {
|
||||||
|
// Update in .md frontmatter
|
||||||
|
if let Some(ref mut data) = md_data {
|
||||||
|
data.frontmatter.insert(field.clone(), value.clone());
|
||||||
|
md_modified = true;
|
||||||
|
}
|
||||||
|
} else if in_json {
|
||||||
|
// Update in opencode.json while preserving existing fields
|
||||||
|
existing_agent.insert(field.clone(), value.clone());
|
||||||
|
json_modified = true;
|
||||||
|
} else {
|
||||||
|
// Field not defined - apply priority rules
|
||||||
|
if md_exists && !existing_agent.is_empty() {
|
||||||
|
// Both exist → add to opencode.json (higher priority) without dropping other keys
|
||||||
|
existing_agent.insert(field.clone(), value.clone());
|
||||||
|
json_modified = true;
|
||||||
|
} else if md_exists {
|
||||||
|
// Only .md exists → add to frontmatter
|
||||||
|
if let Some(ref mut data) = md_data {
|
||||||
|
data.frontmatter.insert(field.clone(), value.clone());
|
||||||
|
md_modified = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Only JSON or built-in → add/create section in opencode.json
|
||||||
|
existing_agent.insert(field.clone(), value.clone());
|
||||||
|
json_modified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write changes
|
||||||
|
if md_modified {
|
||||||
|
if let Some(data) = md_data {
|
||||||
|
write_md_file(&md_path, &data.frontmatter, &data.body).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if json_modified {
|
||||||
|
// Avoid creating a new JSON section for agents that already live exclusively in .md
|
||||||
|
if md_exists && !had_json_fields {
|
||||||
|
json_modified = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if json_modified {
|
||||||
|
if !config.is_object() {
|
||||||
|
config = Value::Object(Map::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let config_obj = config.as_object_mut().unwrap();
|
||||||
|
let agents_entry = config_obj
|
||||||
|
.entry("agent".to_string())
|
||||||
|
.or_insert_with(|| Value::Object(Map::new()));
|
||||||
|
|
||||||
|
if !agents_entry.is_object() {
|
||||||
|
*agents_entry = Value::Object(Map::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let agents_obj = agents_entry.as_object_mut().unwrap();
|
||||||
|
agents_obj.insert(agent_name.to_string(), Value::Object(existing_agent));
|
||||||
|
|
||||||
|
write_config(&config).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Updated agent: {} (md: {}, json: {})",
|
||||||
|
agent_name, md_modified, json_modified
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete agent configuration
|
||||||
|
pub async fn delete_agent(agent_name: &str) -> Result<()> {
|
||||||
|
let md_path = get_agent_dir().join(format!("{}.md", agent_name));
|
||||||
|
let mut deleted = false;
|
||||||
|
|
||||||
|
// 1. Delete .md file if exists
|
||||||
|
if md_path.exists() {
|
||||||
|
fs::remove_file(&md_path).await?;
|
||||||
|
info!("Deleted agent .md file: {}", md_path.display());
|
||||||
|
deleted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Remove section from opencode.json if exists
|
||||||
|
let mut config = read_config().await?;
|
||||||
|
if let Some(agents) = config.get_mut("agent").and_then(|v| v.as_object_mut()) {
|
||||||
|
if agents.remove(agent_name).is_some() {
|
||||||
|
write_config(&config).await?;
|
||||||
|
info!("Removed agent from opencode.json: {}", agent_name);
|
||||||
|
deleted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. If nothing was deleted (built-in agent), disable it
|
||||||
|
if !deleted {
|
||||||
|
if !config.is_object() {
|
||||||
|
config = Value::Object(serde_json::Map::new());
|
||||||
|
}
|
||||||
|
let config_obj = config.as_object_mut().unwrap();
|
||||||
|
if !config_obj.contains_key("agent") {
|
||||||
|
config_obj.insert("agent".to_string(), Value::Object(serde_json::Map::new()));
|
||||||
|
}
|
||||||
|
let agents = config_obj.get_mut("agent").unwrap();
|
||||||
|
if !agents.is_object() {
|
||||||
|
*agents = Value::Object(serde_json::Map::new());
|
||||||
|
}
|
||||||
|
let mut disable_obj = serde_json::Map::new();
|
||||||
|
disable_obj.insert("disable".to_string(), Value::Bool(true));
|
||||||
|
agents
|
||||||
|
.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.insert(agent_name.to_string(), Value::Object(disable_obj));
|
||||||
|
write_config(&config).await?;
|
||||||
|
info!("Disabled built-in agent: {}", agent_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get information about where command configuration is stored
|
||||||
|
pub async fn get_command_sources(command_name: &str) -> Result<ConfigSources> {
|
||||||
|
ensure_dirs().await?;
|
||||||
|
|
||||||
|
let md_path = get_command_dir().join(format!("{}.md", command_name));
|
||||||
|
let md_exists = md_path.exists();
|
||||||
|
|
||||||
|
let mut md_fields = Vec::new();
|
||||||
|
if md_exists {
|
||||||
|
let md_data = parse_md_file(&md_path).await?;
|
||||||
|
md_fields.extend(md_data.frontmatter.keys().cloned());
|
||||||
|
if !md_data.body.trim().is_empty() {
|
||||||
|
md_fields.push("template".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = read_config().await?;
|
||||||
|
let json_section = config
|
||||||
|
.get("command")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.and_then(|obj| obj.get(command_name));
|
||||||
|
|
||||||
|
let json_fields = json_section
|
||||||
|
.and_then(|value| value.as_object())
|
||||||
|
.map(|obj| obj.keys().cloned().collect::<Vec<_>>())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let sources = ConfigSources {
|
||||||
|
md: SourceInfo {
|
||||||
|
exists: md_exists,
|
||||||
|
path: md_exists.then(|| md_path.display().to_string()),
|
||||||
|
fields: md_fields,
|
||||||
|
},
|
||||||
|
json: SourceInfo {
|
||||||
|
exists: json_section.is_some(),
|
||||||
|
path: Some(get_config_file().display().to_string()),
|
||||||
|
fields: json_fields,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create new command as .md file
|
||||||
|
pub async fn create_command(command_name: &str, config: &HashMap<String, Value>) -> Result<()> {
|
||||||
|
ensure_dirs().await?;
|
||||||
|
|
||||||
|
let md_path = get_command_dir().join(format!("{}.md", command_name));
|
||||||
|
|
||||||
|
// Check if command already exists
|
||||||
|
if md_path.exists() {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"Command {} already exists as .md file",
|
||||||
|
command_name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing_config = read_config().await?;
|
||||||
|
if let Some(commands) = existing_config.get("command").and_then(|v| v.as_object()) {
|
||||||
|
if commands.contains_key(command_name) {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"Command {} already exists in opencode.json",
|
||||||
|
command_name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract template from config
|
||||||
|
let mut frontmatter = config.clone();
|
||||||
|
let template = frontmatter
|
||||||
|
.remove("template")
|
||||||
|
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Write .md file
|
||||||
|
write_md_file(&md_path, &frontmatter, &template).await?;
|
||||||
|
info!("Created new command: {}", command_name);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update existing command using field-level logic
|
||||||
|
pub async fn update_command(
|
||||||
|
command_name: &str,
|
||||||
|
updates: &HashMap<String, Value>,
|
||||||
|
) -> Result<()> {
|
||||||
|
ensure_dirs().await?;
|
||||||
|
|
||||||
|
let md_path = get_command_dir().join(format!("{}.md", command_name));
|
||||||
|
let md_exists = md_path.exists();
|
||||||
|
|
||||||
|
let mut md_data = if md_exists {
|
||||||
|
Some(parse_md_file(&md_path).await?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut config = read_config().await?;
|
||||||
|
let mut existing_command = config
|
||||||
|
.get("command")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.and_then(|obj| obj.get(command_name))
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(Map::new);
|
||||||
|
let had_json_fields = !existing_command.is_empty();
|
||||||
|
|
||||||
|
let mut md_modified = false;
|
||||||
|
let mut json_modified = false;
|
||||||
|
|
||||||
|
for (field, value) in updates.iter() {
|
||||||
|
// Handle explicit removals (null payload) for scalar/frontmatter/JSON fields
|
||||||
|
if value.is_null() {
|
||||||
|
if md_exists {
|
||||||
|
if let Some(ref mut data) = md_data {
|
||||||
|
if data.frontmatter.remove(field).is_some() {
|
||||||
|
md_modified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if existing_command.remove(field).is_some() {
|
||||||
|
json_modified = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special handling for template field
|
||||||
|
if field == "template" {
|
||||||
|
let normalized_value = value.as_str().unwrap_or("").to_string();
|
||||||
|
|
||||||
|
if md_exists {
|
||||||
|
if let Some(ref mut data) = md_data {
|
||||||
|
data.body = normalized_value.clone();
|
||||||
|
md_modified = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
} else if let Some(template_ref) = existing_command.get("template").and_then(|v| v.as_str()) {
|
||||||
|
if is_prompt_file_reference(template_ref) {
|
||||||
|
if let Some(template_file_path) = resolve_prompt_file_path(template_ref) {
|
||||||
|
write_prompt_file(&template_file_path, &normalized_value).await?;
|
||||||
|
} else {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"Invalid template file reference for command {}",
|
||||||
|
command_name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write template directly to JSON entry (file ref or inline string)
|
||||||
|
existing_command.insert("template".to_string(), Value::String(normalized_value));
|
||||||
|
json_modified = true;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check where field is currently defined
|
||||||
|
let in_md = md_data
|
||||||
|
.as_ref()
|
||||||
|
.map(|data| data.frontmatter.contains_key(field))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let in_json = existing_command.contains_key(field);
|
||||||
|
|
||||||
|
if in_md {
|
||||||
|
// Update in .md frontmatter
|
||||||
|
if let Some(ref mut data) = md_data {
|
||||||
|
data.frontmatter.insert(field.clone(), value.clone());
|
||||||
|
md_modified = true;
|
||||||
|
}
|
||||||
|
} else if in_json {
|
||||||
|
// Update in opencode.json while preserving existing fields
|
||||||
|
existing_command.insert(field.clone(), value.clone());
|
||||||
|
json_modified = true;
|
||||||
|
} else {
|
||||||
|
// Field not defined - apply priority rules
|
||||||
|
if md_exists && !existing_command.is_empty() {
|
||||||
|
// Both exist → add to opencode.json (higher priority)
|
||||||
|
existing_command.insert(field.clone(), value.clone());
|
||||||
|
json_modified = true;
|
||||||
|
} else if md_exists {
|
||||||
|
// Only .md exists → add to frontmatter
|
||||||
|
if let Some(ref mut data) = md_data {
|
||||||
|
data.frontmatter.insert(field.clone(), value.clone());
|
||||||
|
md_modified = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Only JSON or built-in → add/create section in opencode.json
|
||||||
|
existing_command.insert(field.clone(), value.clone());
|
||||||
|
json_modified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write changes
|
||||||
|
if md_modified {
|
||||||
|
if let Some(data) = md_data {
|
||||||
|
write_md_file(&md_path, &data.frontmatter, &data.body).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if json_modified {
|
||||||
|
// Avoid creating a new JSON section for commands that already live exclusively in .md
|
||||||
|
if md_exists && !had_json_fields {
|
||||||
|
json_modified = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if json_modified {
|
||||||
|
if !config.is_object() {
|
||||||
|
config = Value::Object(Map::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let config_obj = config.as_object_mut().unwrap();
|
||||||
|
let commands_entry = config_obj
|
||||||
|
.entry("command".to_string())
|
||||||
|
.or_insert_with(|| Value::Object(Map::new()));
|
||||||
|
|
||||||
|
if !commands_entry.is_object() {
|
||||||
|
*commands_entry = Value::Object(Map::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let commands_obj = commands_entry.as_object_mut().unwrap();
|
||||||
|
commands_obj.insert(command_name.to_string(), Value::Object(existing_command));
|
||||||
|
|
||||||
|
write_config(&config).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Updated command: {} (md: {}, json: {})",
|
||||||
|
command_name, md_modified, json_modified
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete command configuration
|
||||||
|
pub async fn delete_command(command_name: &str) -> Result<()> {
|
||||||
|
let md_path = get_command_dir().join(format!("{}.md", command_name));
|
||||||
|
let mut deleted = false;
|
||||||
|
|
||||||
|
// 1. Delete .md file if exists
|
||||||
|
if md_path.exists() {
|
||||||
|
fs::remove_file(&md_path).await?;
|
||||||
|
info!("Deleted command .md file: {}", md_path.display());
|
||||||
|
deleted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Remove section from opencode.json if exists
|
||||||
|
let mut config = read_config().await?;
|
||||||
|
if let Some(commands) = config.get_mut("command").and_then(|v| v.as_object_mut()) {
|
||||||
|
if commands.remove(command_name).is_some() {
|
||||||
|
write_config(&config).await?;
|
||||||
|
info!("Removed command from opencode.json: {}", command_name);
|
||||||
|
deleted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. If nothing was deleted, throw error
|
||||||
|
if !deleted {
|
||||||
|
return Err(anyhow!("Command \"{}\" not found", command_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,577 @@
|
|||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use log::{debug, info, warn};
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
use regex::Regex;
|
||||||
|
use reqwest::Client;
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
Arc,
|
||||||
|
},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncBufReadExt, BufReader},
|
||||||
|
process::{Child, Command},
|
||||||
|
sync::Mutex,
|
||||||
|
time::timeout,
|
||||||
|
};
|
||||||
|
|
||||||
|
static URL_REGEX: Lazy<Regex> = Lazy::new(|| {
|
||||||
|
Regex::new(r#"https?://[^:\s]+:(?P<port>\d+)(?P<path>/[^\s"']*)?"#).expect("valid regex")
|
||||||
|
});
|
||||||
|
|
||||||
|
const FIRST_SIGNAL_TIMEOUT_MS: u64 = 750;
|
||||||
|
const READY_CHECK_TIMEOUT_MS: u64 = 20000;
|
||||||
|
const READY_CHECK_INTERVAL_MS: u64 = 400;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct OpenCodeManager {
|
||||||
|
binary: Option<String>,
|
||||||
|
args: Vec<String>,
|
||||||
|
env: HashMap<String, String>,
|
||||||
|
working_dir: Arc<RwLock<PathBuf>>,
|
||||||
|
desired_port: u16,
|
||||||
|
child: Arc<Mutex<Option<Child>>>,
|
||||||
|
port: Arc<RwLock<Option<u16>>>,
|
||||||
|
api_prefix: Arc<RwLock<String>>,
|
||||||
|
is_ready: Arc<AtomicBool>,
|
||||||
|
shutting_down: Arc<AtomicBool>,
|
||||||
|
http_client: Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_api_prefix(prefix: &str) -> String {
|
||||||
|
let trimmed = prefix.trim();
|
||||||
|
if trimmed.is_empty() || trimmed == "/" {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut normalized = trimmed.trim_end_matches('/').to_string();
|
||||||
|
if !normalized.starts_with('/') {
|
||||||
|
normalized.insert(0, '/');
|
||||||
|
}
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenCodeManager {
|
||||||
|
pub fn new_with_directory(initial_dir: Option<PathBuf>) -> Self {
|
||||||
|
let desired_port = std::env::var("OPENCHAMBER_OPENCODE_PORT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|raw| raw.parse::<u16>().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let binary = resolve_opencode_binary();
|
||||||
|
|
||||||
|
if let Some(ref bin) = binary {
|
||||||
|
if !Path::new(bin).is_absolute() {
|
||||||
|
info!("[desktop:opencode] using PATH-resolved binary: {}", bin);
|
||||||
|
} else {
|
||||||
|
info!("[desktop:opencode] using binary: {}", bin);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!("[desktop:opencode] OpenCode CLI not found - app will run in limited mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut args = vec![
|
||||||
|
"serve".to_string(),
|
||||||
|
"--port".to_string(),
|
||||||
|
desired_port.to_string(),
|
||||||
|
];
|
||||||
|
if let Ok(config) = std::env::var("OPENCHAMBER_OPENCODE_CONFIG") {
|
||||||
|
if !config.is_empty() {
|
||||||
|
args.push("--config".to_string());
|
||||||
|
args.push(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let env = build_augmented_env();
|
||||||
|
let working_dir = initial_dir
|
||||||
|
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"[desktop:opencode] Initial working directory: {:?}",
|
||||||
|
working_dir
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
binary,
|
||||||
|
args,
|
||||||
|
env,
|
||||||
|
working_dir: Arc::new(RwLock::new(working_dir)),
|
||||||
|
desired_port,
|
||||||
|
child: Arc::new(Mutex::new(None)),
|
||||||
|
port: Arc::new(RwLock::new(None)),
|
||||||
|
api_prefix: Arc::new(RwLock::new(String::new())),
|
||||||
|
is_ready: Arc::new(AtomicBool::new(false)),
|
||||||
|
shutting_down: Arc::new(AtomicBool::new(false)),
|
||||||
|
http_client: Client::builder()
|
||||||
|
.timeout(Duration::from_secs(5))
|
||||||
|
.build()
|
||||||
|
.unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_cli_available(&self) -> bool {
|
||||||
|
self.binary.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_running(&self) -> Result<()> {
|
||||||
|
if self.binary.is_none() {
|
||||||
|
return Err(anyhow!("OpenCode CLI is not available"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut guard = self.child.lock().await;
|
||||||
|
if let Some(child) = guard.as_mut() {
|
||||||
|
if child.try_wait()?.is_none() && self.is_ready.load(Ordering::SeqCst) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.is_ready.store(false, Ordering::SeqCst);
|
||||||
|
let child = self.spawn_process().await?;
|
||||||
|
*guard = Some(child);
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
// Wait for port detection from logs
|
||||||
|
if self.desired_port == 0 {
|
||||||
|
self.wait_for_port_detection().await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect API prefix early so proxy can forward correctly
|
||||||
|
let _ = self.detect_api_prefix().await;
|
||||||
|
|
||||||
|
// Wait for OpenCode to become ready by polling endpoints
|
||||||
|
self.wait_for_ready().await?;
|
||||||
|
|
||||||
|
self.is_ready.store(true, Ordering::SeqCst);
|
||||||
|
if let Some(port) = self.current_port() {
|
||||||
|
info!("[desktop:opencode] ready on port {port}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn restart(&self) -> Result<()> {
|
||||||
|
info!("[desktop:opencode] restarting...");
|
||||||
|
self.is_ready.store(false, Ordering::SeqCst);
|
||||||
|
|
||||||
|
self.graceful_stop().await?;
|
||||||
|
|
||||||
|
// Brief delay to let OS release resources
|
||||||
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
if self.desired_port == 0 {
|
||||||
|
*self.port.write() = None;
|
||||||
|
}
|
||||||
|
*self.api_prefix.write() = String::new();
|
||||||
|
|
||||||
|
self.ensure_running().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn shutdown(&self) -> Result<()> {
|
||||||
|
self.shutting_down.store(true, Ordering::SeqCst);
|
||||||
|
self.is_ready.store(false, Ordering::SeqCst);
|
||||||
|
self.graceful_stop().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_working_directory(&self, new_dir: PathBuf) -> Result<()> {
|
||||||
|
*self.working_dir.write() = new_dir;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_working_directory(&self) -> PathBuf {
|
||||||
|
self.working_dir.read().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn detect_api_prefix(&self) -> Result<()> {
|
||||||
|
let Some(port) = self.current_port() else {
|
||||||
|
return Err(anyhow!("Cannot detect API prefix without port"));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try empty prefix first (OpenCode default), then /api (some installations)
|
||||||
|
let candidates = ["", "/api"];
|
||||||
|
for candidate in candidates {
|
||||||
|
let base = if candidate.is_empty() {
|
||||||
|
format!("http://127.0.0.1:{port}")
|
||||||
|
} else {
|
||||||
|
format!("http://127.0.0.1:{port}{candidate}")
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!("{base}/config");
|
||||||
|
match self.http_client.get(&url).send().await {
|
||||||
|
Ok(resp) if resp.status().is_success() => {
|
||||||
|
// Validate it's actually JSON config, not HTML
|
||||||
|
if let Ok(text) = resp.text().await {
|
||||||
|
if text.trim().starts_with('{') || text.trim().starts_with('[') {
|
||||||
|
info!("[desktop:opencode] Detected API prefix: {:?}", candidate);
|
||||||
|
*self.api_prefix.write() = normalize_api_prefix(candidate);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("[desktop:opencode] No API prefix detected, using empty prefix");
|
||||||
|
*self.api_prefix.write() = String::new();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_port(&self) -> Option<u16> {
|
||||||
|
*self.port.read()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn api_prefix(&self) -> String {
|
||||||
|
self.api_prefix.read().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_ready(&self) -> bool {
|
||||||
|
self.is_ready.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rewrite_path(&self, incoming_path: &str) -> String {
|
||||||
|
// Strip /api prefix to get OpenCode path
|
||||||
|
let result = incoming_path
|
||||||
|
.strip_prefix("/api")
|
||||||
|
.map(|rest| if rest.is_empty() { "/" } else { rest })
|
||||||
|
.unwrap_or(incoming_path)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"[opencode_manager] rewrite_path: '{}' -> '{}'",
|
||||||
|
incoming_path, result
|
||||||
|
);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_process(&self) -> Result<Child> {
|
||||||
|
let binary = self.binary.as_ref().ok_or_else(|| {
|
||||||
|
anyhow!("Cannot spawn process: OpenCode CLI is not available")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"[desktop:opencode] launching {} {:?}",
|
||||||
|
binary, self.args
|
||||||
|
);
|
||||||
|
|
||||||
|
let working_dir = self.working_dir.read().clone();
|
||||||
|
let mut cmd = Command::new(binary);
|
||||||
|
cmd.args(&self.args)
|
||||||
|
.current_dir(&working_dir)
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.kill_on_drop(false);
|
||||||
|
|
||||||
|
for (key, value) in &self.env {
|
||||||
|
cmd.env(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut child = cmd.spawn().map_err(|e| {
|
||||||
|
if e.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
anyhow!(
|
||||||
|
"OpenCode binary '{}' not found. Set OPENCODE_BINARY or ensure it's in PATH.",
|
||||||
|
binary
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
anyhow!("Failed to spawn OpenCode: {}", e)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Set port immediately if pre-configured
|
||||||
|
if self.desired_port > 0 {
|
||||||
|
*self.port.write() = Some(self.desired_port);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for first signal (stdout/stderr) within 750ms to confirm startup
|
||||||
|
let first_signal_received = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
|
if let Some(stdout) = child.stdout.take() {
|
||||||
|
let signal_flag = first_signal_received.clone();
|
||||||
|
self.spawn_output_reader(stdout, "stdout", move || {
|
||||||
|
signal_flag.store(true, Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(stderr) = child.stderr.take() {
|
||||||
|
let signal_flag = first_signal_received.clone();
|
||||||
|
self.spawn_output_reader(stderr, "stderr", move || {
|
||||||
|
signal_flag.store(true, Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for first signal or timeout
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
while start.elapsed() < Duration::from_millis(FIRST_SIGNAL_TIMEOUT_MS) {
|
||||||
|
if first_signal_received.load(Ordering::SeqCst) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Ok(Some(_)) = child.try_wait() {
|
||||||
|
return Err(anyhow!("OpenCode process exited immediately after spawn"));
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(child)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_output_reader<F>(
|
||||||
|
&self,
|
||||||
|
stream: impl tokio::io::AsyncRead + Unpin + Send + 'static,
|
||||||
|
label: &'static str,
|
||||||
|
on_first_line: F,
|
||||||
|
) where
|
||||||
|
F: FnOnce() + Send + 'static,
|
||||||
|
{
|
||||||
|
let manager = self.clone();
|
||||||
|
let first_line_flag = Arc::new(Mutex::new(Some(on_first_line)));
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let reader = BufReader::new(stream);
|
||||||
|
let mut lines = reader.lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
// Trigger first signal callback
|
||||||
|
if let Some(callback) = first_line_flag.lock().await.take() {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("[opencode:{label}] {line}");
|
||||||
|
manager.ingest_output_line(&line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ingest_output_line(&self, line: &str) {
|
||||||
|
if let Some(captures) = URL_REGEX.captures(line) {
|
||||||
|
if let Some(port_match) = captures
|
||||||
|
.name("port")
|
||||||
|
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||||
|
{
|
||||||
|
*self.port.write() = Some(port_match);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(path_match) = captures.name("path") {
|
||||||
|
let value = path_match.as_str();
|
||||||
|
if !value.is_empty() && value != "/" {
|
||||||
|
*self.api_prefix.write() = value.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_port_detection(&self) -> Result<()> {
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let timeout_duration = Duration::from_secs(15);
|
||||||
|
|
||||||
|
while start.elapsed() < timeout_duration {
|
||||||
|
if self.current_port().is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(anyhow!("OpenCode did not report port within 15 seconds"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_ready(&self) -> Result<()> {
|
||||||
|
let Some(port) = self.current_port() else {
|
||||||
|
return Err(anyhow!("Cannot check readiness without port"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let deadline = tokio::time::Instant::now() + Duration::from_millis(READY_CHECK_TIMEOUT_MS);
|
||||||
|
let mut last_error: Option<String> = None;
|
||||||
|
|
||||||
|
while tokio::time::Instant::now() < deadline {
|
||||||
|
let api_prefix = self.api_prefix();
|
||||||
|
|
||||||
|
// Try /health, /config, /agent endpoints
|
||||||
|
match self.check_endpoints(port, &api_prefix).await {
|
||||||
|
Ok(()) => {
|
||||||
|
// Once ready, attempt to detect and persist the API prefix for proxying
|
||||||
|
let _ = self.detect_api_prefix().await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
last_error = Some(e.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_millis(READY_CHECK_INTERVAL_MS)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(anyhow!(
|
||||||
|
"OpenCode not ready after {}ms: {}",
|
||||||
|
READY_CHECK_TIMEOUT_MS,
|
||||||
|
last_error.unwrap_or_else(|| "no error details".to_string())
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_endpoints(&self, port: u16, prefix: &str) -> Result<()> {
|
||||||
|
let base_url = format!("http://127.0.0.1:{port}{prefix}");
|
||||||
|
|
||||||
|
// Check /health
|
||||||
|
let health_url = format!("{base_url}/health");
|
||||||
|
let health_resp = self.http_client.get(&health_url).send().await?;
|
||||||
|
if !health_resp.status().is_success() {
|
||||||
|
return Err(anyhow!("/health returned {}", health_resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check /config
|
||||||
|
let config_url = format!("{base_url}/config");
|
||||||
|
let config_resp = self.http_client.get(&config_url).send().await?;
|
||||||
|
if !config_resp.status().is_success() {
|
||||||
|
return Err(anyhow!("/config returned {}", config_resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check /agent
|
||||||
|
let agent_url = format!("{base_url}/agent");
|
||||||
|
let agent_resp = self.http_client.get(&agent_url).send().await?;
|
||||||
|
if !agent_resp.status().is_success() {
|
||||||
|
return Err(anyhow!("/agent returned {}", agent_resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn graceful_stop(&self) -> Result<()> {
|
||||||
|
let mut guard = self.child.lock().await;
|
||||||
|
let Some(mut child) = guard.take() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if child.try_wait()?.is_some() {
|
||||||
|
// Already exited
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// SIGTERM
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use nix::{
|
||||||
|
sys::signal::{kill, Signal},
|
||||||
|
unistd::Pid,
|
||||||
|
};
|
||||||
|
if let Some(id) = child.id() {
|
||||||
|
let _ = kill(Pid::from_raw(id as i32), Signal::SIGTERM);
|
||||||
|
info!("[desktop:opencode] sent SIGTERM");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let _ = child.kill().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait 3 seconds for graceful exit
|
||||||
|
match timeout(Duration::from_secs(3), child.wait()).await {
|
||||||
|
Ok(_) => {
|
||||||
|
info!("[desktop:opencode] exited gracefully");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
warn!("[desktop:opencode] did not exit after SIGTERM, sending SIGKILL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SIGKILL
|
||||||
|
let _ = child.kill().await;
|
||||||
|
|
||||||
|
// Wait up to 5 seconds for hard kill
|
||||||
|
match timeout(Duration::from_secs(5), child.wait()).await {
|
||||||
|
Ok(_) => {
|
||||||
|
info!("[desktop:opencode] exited after SIGKILL");
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
warn!("[desktop:opencode] unresponsive after SIGKILL, continuing anyway");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if CLI binary exists (can be called dynamically for polling)
|
||||||
|
pub fn check_cli_exists() -> bool {
|
||||||
|
if std::env::var("OPENCHAMBER_DISABLE_CLI").is_ok() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
resolve_opencode_binary().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_opencode_binary() -> Option<String> {
|
||||||
|
if std::env::var("OPENCHAMBER_DISABLE_CLI").is_ok() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check explicit override
|
||||||
|
if let Ok(value) = std::env::var("OPENCODE_BINARY") {
|
||||||
|
if !value.is_empty() && Path::new(&value).exists() {
|
||||||
|
info!("[desktop:opencode] using binary from OPENCODE_BINARY: {}", value);
|
||||||
|
return Some(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find in PATH
|
||||||
|
if let Ok(output) = std::process::Command::new("which")
|
||||||
|
.arg("opencode")
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
if output.status.success() {
|
||||||
|
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
if !path.is_empty() {
|
||||||
|
info!("[desktop:opencode] found binary in PATH: {}", path);
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
warn!("[desktop:opencode] opencode binary not found in PATH");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_augmented_env() -> HashMap<String, String> {
|
||||||
|
let mut env: HashMap<String, String> = std::env::vars().collect();
|
||||||
|
if let Ok(login_path) = detect_login_shell_path() {
|
||||||
|
let current = env.get("PATH").cloned().unwrap_or_default();
|
||||||
|
env.insert("PATH".to_string(), merge_paths(&login_path, ¤t));
|
||||||
|
}
|
||||||
|
env
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_paths(login_path: &str, current: &str) -> String {
|
||||||
|
let mut segments = Vec::new();
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
for part in login_path.split(':').chain(current.split(':')) {
|
||||||
|
if part.is_empty() || seen.contains(part) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.insert(part.to_string());
|
||||||
|
segments.push(part);
|
||||||
|
}
|
||||||
|
|
||||||
|
segments.join(":")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_login_shell_path() -> Result<String> {
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
Err(anyhow!("login shell path unsupported"))
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::process::Command;
|
||||||
|
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".into());
|
||||||
|
let output = Command::new(&shell)
|
||||||
|
.arg("-lic")
|
||||||
|
.arg("echo -n $PATH")
|
||||||
|
.output()?;
|
||||||
|
if output.status.success() {
|
||||||
|
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("shell PATH detection failed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
sync::Arc,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use futures_util::TryStreamExt;
|
||||||
|
use log::{debug, info, warn};
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tokio_util::io::StreamReader;
|
||||||
|
|
||||||
|
use crate::DesktopRuntime;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct EventEnvelope {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
event_type: String,
|
||||||
|
#[serde(default)]
|
||||||
|
properties: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum ActivityPhase {
|
||||||
|
Idle,
|
||||||
|
Busy,
|
||||||
|
Cooldown,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_session_activity_tracker(
|
||||||
|
app: AppHandle,
|
||||||
|
runtime: DesktopRuntime,
|
||||||
|
) -> tauri::async_runtime::JoinHandle<()> {
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(Duration::from_secs(24 * 60 * 60))
|
||||||
|
.tcp_keepalive(Some(Duration::from_secs(30)))
|
||||||
|
.build()
|
||||||
|
.expect("failed to build reqwest client");
|
||||||
|
|
||||||
|
let mut shutdown_rx = runtime.subscribe_shutdown();
|
||||||
|
let phases = Arc::new(Mutex::new(HashMap::<String, ActivityPhase>::new()));
|
||||||
|
let cooldowns = Arc::new(Mutex::new(HashMap::<String, tauri::async_runtime::JoinHandle<()>>::new()));
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = shutdown_rx.recv() => {
|
||||||
|
info!("[desktop:activity] Shutdown received, stopping SSE listener");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ = async {
|
||||||
|
// Reset stale phases to idle before connecting so UI doesn't stay stuck on "working" after wake.
|
||||||
|
reset_and_emit_all_phases(&app, phases.clone(), cooldowns.clone()).await;
|
||||||
|
|
||||||
|
if let Err(err) = run_once(&app, &runtime, &client, phases.clone(), cooldowns.clone()).await {
|
||||||
|
warn!("[desktop:activity] SSE loop error: {err:?}");
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
} => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_once(
|
||||||
|
app: &AppHandle,
|
||||||
|
runtime: &DesktopRuntime,
|
||||||
|
client: &Client,
|
||||||
|
phases: Arc<Mutex<HashMap<String, ActivityPhase>>>,
|
||||||
|
cooldowns: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let opencode = runtime.opencode_manager();
|
||||||
|
|
||||||
|
let port = match opencode.current_port() {
|
||||||
|
Some(port) => port,
|
||||||
|
None => {
|
||||||
|
warn!("[desktop:activity] OpenCode port unavailable; will retry");
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let prefix = opencode.api_prefix();
|
||||||
|
let mut url = format!("http://127.0.0.1:{port}{}/event", prefix);
|
||||||
|
|
||||||
|
if let Some(dir) = opencode.get_working_directory().to_str().map(|s| s.to_string()) {
|
||||||
|
let mut parsed = reqwest::Url::parse(&url)?;
|
||||||
|
parsed
|
||||||
|
.query_pairs_mut()
|
||||||
|
.append_pair("directory", &dir);
|
||||||
|
url = parsed.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("[desktop:activity] Connecting SSE for activity phases: {url}");
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(&url)
|
||||||
|
.header("accept", "text/event-stream")
|
||||||
|
.header("accept-encoding", "identity")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"[desktop:activity] SSE response status={} headers={:?}",
|
||||||
|
response.status(),
|
||||||
|
response.headers()
|
||||||
|
);
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
warn!(
|
||||||
|
"[desktop:activity] SSE connect failed with status {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
use tokio::io::AsyncBufReadExt;
|
||||||
|
|
||||||
|
let stream = response
|
||||||
|
.bytes_stream()
|
||||||
|
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err));
|
||||||
|
let mut reader = StreamReader::new(stream);
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut data_lines: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
buf.clear();
|
||||||
|
let bytes_read = match reader.read_until(b'\n', &mut buf).await {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(err) => {
|
||||||
|
warn!("[desktop:activity] Read error in SSE stream: {err:?}");
|
||||||
|
return Err(err.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if bytes_read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let line = match std::str::from_utf8(&buf) {
|
||||||
|
Ok(s) => s.trim_end_matches(&['\r', '\n'][..]).to_string(),
|
||||||
|
Err(err) => {
|
||||||
|
warn!("[desktop:activity] Non-UTF8 SSE chunk: {err}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if line.is_empty() {
|
||||||
|
if data_lines.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let raw = data_lines.join("\n");
|
||||||
|
data_lines.clear();
|
||||||
|
|
||||||
|
match serde_json::from_str::<EventEnvelope>(&raw) {
|
||||||
|
Ok(event) => handle_event(app, event, phases.clone(), cooldowns.clone()).await,
|
||||||
|
Err(err) => {
|
||||||
|
warn!("[desktop:activity] Failed to parse SSE data: {err}; raw={raw}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(rest) = line.strip_prefix("data:") {
|
||||||
|
data_lines.push(rest.trim_start().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_event(
|
||||||
|
app: &AppHandle,
|
||||||
|
event: EventEnvelope,
|
||||||
|
phases: Arc<Mutex<HashMap<String, ActivityPhase>>>,
|
||||||
|
cooldowns: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
|
||||||
|
) {
|
||||||
|
match event.event_type.as_str() {
|
||||||
|
"session.status" => {
|
||||||
|
let session_id = event
|
||||||
|
.properties
|
||||||
|
.get("sessionID")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
let status = event
|
||||||
|
.properties
|
||||||
|
.get("status")
|
||||||
|
.and_then(|s| s.get("type"))
|
||||||
|
.and_then(Value::as_str);
|
||||||
|
|
||||||
|
if let (Some(id), Some(status_type)) = (session_id, status) {
|
||||||
|
let phase = if status_type == "busy" || status_type == "retry" {
|
||||||
|
ActivityPhase::Busy
|
||||||
|
} else {
|
||||||
|
ActivityPhase::Idle
|
||||||
|
};
|
||||||
|
set_phase(app, &id, phase, phases.clone(), cooldowns.clone()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"message.updated" => {
|
||||||
|
if let Some(info) = event.properties.get("info") {
|
||||||
|
let role = info.get("role").and_then(Value::as_str).unwrap_or_default();
|
||||||
|
if role != "assistant" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let finish = info.get("finish").and_then(Value::as_str);
|
||||||
|
if finish != Some("stop") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let session_id = info
|
||||||
|
.get("sessionID")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
if let Some(id) = session_id {
|
||||||
|
// If current phase is busy, move to cooldown for 2s then idle
|
||||||
|
let current = { phases.lock().await.get(&id).cloned() };
|
||||||
|
if matches!(current, Some(ActivityPhase::Busy)) {
|
||||||
|
set_phase(app, &id, ActivityPhase::Cooldown, phases.clone(), cooldowns.clone()).await;
|
||||||
|
|
||||||
|
let app_clone = app.clone();
|
||||||
|
let phases_clone = phases.clone();
|
||||||
|
let cooldowns_clone = cooldowns.clone();
|
||||||
|
let id_clone = id.clone();
|
||||||
|
let handle = tauri::async_runtime::spawn(async move {
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
let current = { phases_clone.lock().await.get(&id_clone).cloned() };
|
||||||
|
if matches!(current, Some(ActivityPhase::Cooldown)) {
|
||||||
|
set_phase(&app_clone, &id_clone, ActivityPhase::Idle, phases_clone, cooldowns_clone).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store cooldown handle to cancel if phase changes earlier
|
||||||
|
let mut cd = cooldowns.lock().await;
|
||||||
|
if let Some(prev) = cd.remove(&id) {
|
||||||
|
prev.abort();
|
||||||
|
}
|
||||||
|
cd.insert(id, handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_phase(
|
||||||
|
app: &AppHandle,
|
||||||
|
session_id: &str,
|
||||||
|
phase: ActivityPhase,
|
||||||
|
phases: Arc<Mutex<HashMap<String, ActivityPhase>>>,
|
||||||
|
cooldowns: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
|
||||||
|
) {
|
||||||
|
{
|
||||||
|
let mut map = phases.lock().await;
|
||||||
|
let current = map.get(session_id);
|
||||||
|
if current == Some(&phase) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
map.insert(session_id.to_string(), phase.clone());
|
||||||
|
|
||||||
|
// Cancel cooldown timer when leaving cooldown
|
||||||
|
if !matches!(phase, ActivityPhase::Cooldown) {
|
||||||
|
if let Some(handle) = cooldowns.lock().await.remove(session_id) {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit to webview so UI stays in sync
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"sessionId": session_id,
|
||||||
|
"phase": match phase {
|
||||||
|
ActivityPhase::Idle => "idle",
|
||||||
|
ActivityPhase::Busy => "busy",
|
||||||
|
ActivityPhase::Cooldown => "cooldown",
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = app.emit("openchamber:session-activity", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reset_and_emit_all_phases(
|
||||||
|
app: &AppHandle,
|
||||||
|
phases: Arc<Mutex<HashMap<String, ActivityPhase>>>,
|
||||||
|
cooldowns: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
|
||||||
|
) {
|
||||||
|
// Cancel any cooldown timers and set all phases to idle to avoid stale "busy" after wake.
|
||||||
|
{
|
||||||
|
let mut cd = cooldowns.lock().await;
|
||||||
|
for handle in cd.values() {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
cd.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot = {
|
||||||
|
let mut guard = phases.lock().await;
|
||||||
|
for value in guard.values_mut() {
|
||||||
|
*value = ActivityPhase::Idle;
|
||||||
|
}
|
||||||
|
guard.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if snapshot.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (session_id, phase) in snapshot {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"sessionId": session_id,
|
||||||
|
"phase": match phase {
|
||||||
|
ActivityPhase::Idle => "idle",
|
||||||
|
ActivityPhase::Busy => "busy",
|
||||||
|
ActivityPhase::Cooldown => "cooldown",
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let _ = app.emit("openchamber:session-activity", payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
path::PathBuf,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
use tauri::{LogicalPosition, LogicalSize, WebviewWindow, Window};
|
||||||
|
use tokio::fs as async_fs;
|
||||||
|
|
||||||
|
const WINDOW_STATE_FILE: &str = "window-state.json";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct WindowState {
|
||||||
|
pub width: f64,
|
||||||
|
pub height: f64,
|
||||||
|
pub x: f64,
|
||||||
|
pub y: f64,
|
||||||
|
pub is_maximized: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WindowState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
width: 1280.0,
|
||||||
|
height: 800.0,
|
||||||
|
x: 0.0,
|
||||||
|
y: 0.0,
|
||||||
|
is_maximized: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct WindowStateFile {
|
||||||
|
#[serde(rename = "windowState")]
|
||||||
|
pub window_state: WindowState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct WindowStateManager {
|
||||||
|
inner: Arc<Mutex<WindowState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowStateManager {
|
||||||
|
pub fn new(initial: WindowState) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(Mutex::new(initial)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot(&self) -> WindowState {
|
||||||
|
self.inner.lock().expect("window state poisoned").clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_position(&self, x: f64, y: f64, is_maximized: bool) {
|
||||||
|
if is_maximized {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Ok(mut state) = self.inner.lock() {
|
||||||
|
if !state.is_maximized {
|
||||||
|
state.x = x;
|
||||||
|
state.y = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_size(&self, width: f64, height: f64, is_maximized: bool) {
|
||||||
|
if let Ok(mut state) = self.inner.lock() {
|
||||||
|
if !is_maximized {
|
||||||
|
state.width = width;
|
||||||
|
state.height = height;
|
||||||
|
}
|
||||||
|
state.is_maximized = is_maximized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_file_path() -> Result<PathBuf> {
|
||||||
|
let mut path = dirs::home_dir().ok_or_else(|| anyhow!("No home directory"))?;
|
||||||
|
path.push(".config");
|
||||||
|
path.push("openchamber");
|
||||||
|
path.push(WINDOW_STATE_FILE);
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_window_state() -> Result<Option<WindowState>> {
|
||||||
|
let path = state_file_path()?;
|
||||||
|
match async_fs::read(&path).await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
let file: WindowStateFile = serde_json::from_slice(&bytes)?;
|
||||||
|
Ok(Some(file.window_state))
|
||||||
|
}
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(err) => Err(err.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_window_state(state: &WindowState) -> Result<()> {
|
||||||
|
let path = state_file_path()?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
async_fs::create_dir_all(parent).await?;
|
||||||
|
}
|
||||||
|
let payload = WindowStateFile {
|
||||||
|
window_state: state.clone(),
|
||||||
|
};
|
||||||
|
let data = serde_json::to_vec_pretty(&payload)?;
|
||||||
|
async_fs::write(&path, data).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_window_state(window: &WebviewWindow, state: &WindowState) -> Result<()> {
|
||||||
|
let mut normalized = state.clone();
|
||||||
|
clamp_to_visible_region(window, &mut normalized);
|
||||||
|
|
||||||
|
if normalized.width > 0.0 && normalized.height > 0.0 {
|
||||||
|
let _ = window.set_size(LogicalSize::new(normalized.width, normalized.height));
|
||||||
|
}
|
||||||
|
let _ = window.set_position(LogicalPosition::new(normalized.x, normalized.y));
|
||||||
|
if state.is_maximized {
|
||||||
|
let _ = window.maximize();
|
||||||
|
} else {
|
||||||
|
let _ = window.unmaximize();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn persist_window_state(window: &Window, manager: &WindowStateManager) -> Result<()> {
|
||||||
|
let mut snapshot = manager.snapshot();
|
||||||
|
let is_maximized = window.is_maximized().unwrap_or(snapshot.is_maximized);
|
||||||
|
snapshot.is_maximized = is_maximized;
|
||||||
|
|
||||||
|
if !is_maximized {
|
||||||
|
let scale_factor = window.scale_factor().unwrap_or(1.0);
|
||||||
|
if let Ok(size) = window.outer_size() {
|
||||||
|
let logical: LogicalSize<f64> = size.to_logical(scale_factor);
|
||||||
|
snapshot.width = logical.width.max(200.0);
|
||||||
|
snapshot.height = logical.height.max(200.0);
|
||||||
|
}
|
||||||
|
if let Ok(position) = window.outer_position() {
|
||||||
|
let logical: LogicalPosition<f64> = position.to_logical(scale_factor);
|
||||||
|
snapshot.x = logical.x;
|
||||||
|
snapshot.y = logical.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
save_window_state(&snapshot).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clamp_to_visible_region(window: &WebviewWindow, state: &mut WindowState) {
|
||||||
|
let monitor = match window.current_monitor() {
|
||||||
|
Ok(Some(monitor)) => monitor,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let scale_factor = monitor.scale_factor();
|
||||||
|
let monitor_size: LogicalSize<f64> = monitor.size().to_logical(scale_factor);
|
||||||
|
let monitor_position: LogicalPosition<f64> = monitor.position().to_logical(scale_factor);
|
||||||
|
|
||||||
|
state.width = state.width.clamp(400.0, monitor_size.width);
|
||||||
|
state.height = state.height.clamp(300.0, monitor_size.height);
|
||||||
|
|
||||||
|
let max_x = monitor_position.x + (monitor_size.width - state.width).max(0.0);
|
||||||
|
let max_y = monitor_position.y + (monitor_size.height - state.height).max(0.0);
|
||||||
|
|
||||||
|
state.x = state.x.clamp(monitor_position.x, max_x);
|
||||||
|
state.y = state.y.clamp(monitor_position.y, max_y);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
||||||
|
"productName": "OpenChamber",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"identifier": "ai.opencode.openchamber",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
"beforeBuildCommand": "pnpm build",
|
||||||
|
"devUrl": "http://127.0.0.1:1421",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"label": "main",
|
||||||
|
"title": "OpenChamber",
|
||||||
|
"transparent": true,
|
||||||
|
"width": 1280,
|
||||||
|
"height": 800,
|
||||||
|
"resizable": true,
|
||||||
|
"fullscreen": false,
|
||||||
|
"decorations": true,
|
||||||
|
"hiddenTitle": true,
|
||||||
|
"titleBarStyle": "Overlay",
|
||||||
|
"trafficLightPosition": {
|
||||||
|
"x": 17,
|
||||||
|
"y": 26
|
||||||
|
},
|
||||||
|
"visible": true,
|
||||||
|
"backgroundThrottling": "disabled"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
},
|
||||||
|
"macOSPrivateApi": true
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"icon": [
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.png"
|
||||||
|
],
|
||||||
|
"macOS": {
|
||||||
|
"exceptionDomain": "localhost",
|
||||||
|
"minimumSystemVersion": "14.0",
|
||||||
|
"signingIdentity": null,
|
||||||
|
"infoPlist": "Info.plist"
|
||||||
|
},
|
||||||
|
"createUpdaterArtifacts": true
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"updater": {
|
||||||
|
"endpoints": [
|
||||||
|
"https://github.com/btriapitsyn/openchamber/releases/latest/download/latest.json"
|
||||||
|
],
|
||||||
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU0NjI5NDJGNEU0QzFEMTYKUldRV0hVeE9MNVJpNUdRemdsbm8wQ2YxQkU4KzBOOEg3TkpXZzIzb244N3Y0R3I4N2FtUk1NMUEK"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
|
||||||
|
import type { DiagnosticsAPI } from '@openchamber/ui/lib/api/types';
|
||||||
|
|
||||||
|
type LogResponse = {
|
||||||
|
fileName?: string;
|
||||||
|
content?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizePayload = (payload: LogResponse): { fileName: string; content: string } => ({
|
||||||
|
fileName: typeof payload.fileName === 'string' && payload.fileName.trim().length > 0 ? payload.fileName : 'desktop.log',
|
||||||
|
content: typeof payload.content === 'string' ? payload.content : '',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createDesktopDiagnosticsAPI = (): DiagnosticsAPI => ({
|
||||||
|
async downloadLogs() {
|
||||||
|
try {
|
||||||
|
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||||
|
const result = await safeInvoke<LogResponse>('fetch_desktop_logs', {}, {
|
||||||
|
timeout: 10000,
|
||||||
|
onCancel: () => {
|
||||||
|
console.warn('[DiagnosticsAPI] Fetch desktop logs operation timed out');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return normalizePayload(result ?? {});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error('Failed to download desktop logs');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
|
||||||
|
import { safeInvoke } from '../lib/tauriCallbackManager';
|
||||||
|
import type { DirectoryListResult, FileSearchQuery, FileSearchResult, FilesAPI } from '@openchamber/ui/lib/api/types';
|
||||||
|
|
||||||
|
type ListDirectoryResponse = DirectoryListResult & {
|
||||||
|
path?: string;
|
||||||
|
entries: Array<
|
||||||
|
DirectoryListResult['entries'][number] & {
|
||||||
|
isFile?: boolean;
|
||||||
|
isSymbolicLink?: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchFilesResponse = {
|
||||||
|
root: string;
|
||||||
|
count: number;
|
||||||
|
files: Array<{
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
relativePath: string;
|
||||||
|
extension?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizePath = (path: string): string => path.replace(/\\/g, '/');
|
||||||
|
|
||||||
|
const normalizeDirectoryPayload = (result: ListDirectoryResponse): DirectoryListResult => ({
|
||||||
|
directory: normalizePath(result.directory || result.path || ''),
|
||||||
|
entries: Array.isArray(result.entries)
|
||||||
|
? result.entries.map((entry) => ({
|
||||||
|
name: entry.name || '',
|
||||||
|
path: normalizePath(entry.path || ''),
|
||||||
|
isDirectory: entry.isDirectory ?? false,
|
||||||
|
size: entry.size ?? 0,
|
||||||
|
modified: (entry as { modified?: string }).modified ?? new Date().toISOString(),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createDesktopFilesAPI = (): FilesAPI => ({
|
||||||
|
async listDirectory(path: string): Promise<DirectoryListResult> {
|
||||||
|
try {
|
||||||
|
const result = await safeInvoke<ListDirectoryResponse>('list_directory', {
|
||||||
|
path: normalizePath(path),
|
||||||
|
includeHidden: false
|
||||||
|
}, {
|
||||||
|
timeout: 10000,
|
||||||
|
onCancel: () => {
|
||||||
|
console.warn('[FilesAPI] List directory operation timed out');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizeDirectoryPayload(result);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(message || 'Failed to list directory');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async search(payload: FileSearchQuery): Promise<FileSearchResult[]> {
|
||||||
|
try {
|
||||||
|
const normalizedDirectory =
|
||||||
|
typeof payload.directory === 'string' && payload.directory.length > 0
|
||||||
|
? normalizePath(payload.directory)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const result = await safeInvoke<SearchFilesResponse>('search_files', {
|
||||||
|
directory: normalizedDirectory,
|
||||||
|
query: payload.query,
|
||||||
|
max_results: payload.maxResults || 100
|
||||||
|
}, {
|
||||||
|
timeout: 15000,
|
||||||
|
onCancel: () => {
|
||||||
|
console.warn('[FilesAPI] Search files operation timed out');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result || !Array.isArray(result.files)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.files.map<FileSearchResult>((file) => ({
|
||||||
|
path: normalizePath(file.path),
|
||||||
|
preview: file.relativePath ? [normalizePath(file.relativePath)] : undefined,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(message || 'Failed to search files');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async createDirectory(path: string): Promise<{ success: boolean; path: string }> {
|
||||||
|
try {
|
||||||
|
const normalizedPath = normalizePath(path);
|
||||||
|
const result = await safeInvoke<{ success: boolean; path: string }>('create_directory', {
|
||||||
|
path: normalizedPath
|
||||||
|
}, {
|
||||||
|
timeout: 5000,
|
||||||
|
onCancel: () => {
|
||||||
|
console.warn('[FilesAPI] Create directory operation timed out');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: Boolean(result?.success),
|
||||||
|
path: result?.path ? normalizePath(result.path) : normalizedPath,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(message || 'Failed to create directory');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
|
||||||
|
import { safeInvoke } from '../lib/tauriCallbackManager';
|
||||||
|
import type {
|
||||||
|
GitAPI,
|
||||||
|
GitStatus,
|
||||||
|
GitDiffResponse,
|
||||||
|
GetGitDiffOptions,
|
||||||
|
GitFileDiffResponse,
|
||||||
|
GitBranch,
|
||||||
|
GitDeleteBranchPayload,
|
||||||
|
GitDeleteRemoteBranchPayload,
|
||||||
|
GeneratedCommitMessage,
|
||||||
|
GitWorktreeInfo,
|
||||||
|
GitAddWorktreePayload,
|
||||||
|
GitRemoveWorktreePayload,
|
||||||
|
CreateGitCommitOptions,
|
||||||
|
GitCommitResult,
|
||||||
|
GitPushResult,
|
||||||
|
GitPullResult,
|
||||||
|
GitLogOptions,
|
||||||
|
GitLogResponse,
|
||||||
|
GitCommitFilesResponse,
|
||||||
|
GitIdentitySummary,
|
||||||
|
GitIdentityProfile
|
||||||
|
} from '@openchamber/ui/lib/api/types';
|
||||||
|
|
||||||
|
async function safeGitInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await safeInvoke<T>(command, args, {
|
||||||
|
timeout: 120000,
|
||||||
|
onCancel: () => {
|
||||||
|
console.warn(`[GitAPI] Git operation ${command} did not complete within 120s; it may still be running.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = typeof error === 'string' ? error : (error as Error).message || 'Unknown error';
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createDesktopGitAPI = (): GitAPI => ({
|
||||||
|
async checkIsGitRepository(directory: string): Promise<boolean> {
|
||||||
|
return safeGitInvoke<boolean>('check_is_git_repository', { directory });
|
||||||
|
},
|
||||||
|
|
||||||
|
async getGitStatus(directory: string): Promise<GitStatus> {
|
||||||
|
return safeGitInvoke<GitStatus>('get_git_status', { directory });
|
||||||
|
},
|
||||||
|
|
||||||
|
async getGitDiff(directory: string, options: GetGitDiffOptions): Promise<GitDiffResponse> {
|
||||||
|
const diff = await safeGitInvoke<string>('get_git_diff', {
|
||||||
|
directory,
|
||||||
|
pathStr: options.path,
|
||||||
|
staged: options.staged,
|
||||||
|
contextLines: options.contextLines
|
||||||
|
});
|
||||||
|
return { diff };
|
||||||
|
},
|
||||||
|
|
||||||
|
async getGitFileDiff(directory: string, options: { path: string }): Promise<GitFileDiffResponse> {
|
||||||
|
const [original, modified] = await safeGitInvoke<[string, string]>('get_git_file_diff', {
|
||||||
|
directory,
|
||||||
|
pathStr: options.path,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
original: original ?? '',
|
||||||
|
modified: modified ?? '',
|
||||||
|
path: options.path,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async revertGitFile(directory: string, filePath: string): Promise<void> {
|
||||||
|
return safeGitInvoke<void>('revert_git_file', { directory, filePath });
|
||||||
|
},
|
||||||
|
|
||||||
|
async isLinkedWorktree(directory: string): Promise<boolean> {
|
||||||
|
return safeGitInvoke<boolean>('is_linked_worktree', { directory });
|
||||||
|
},
|
||||||
|
|
||||||
|
async getGitBranches(directory: string): Promise<GitBranch> {
|
||||||
|
return safeGitInvoke<GitBranch>('get_git_branches', { directory });
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }> {
|
||||||
|
await safeGitInvoke<void>('delete_git_branch', {
|
||||||
|
directory,
|
||||||
|
branch: payload.branch,
|
||||||
|
force: payload.force
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }> {
|
||||||
|
await safeGitInvoke<void>('delete_remote_branch', {
|
||||||
|
directory,
|
||||||
|
branch: payload.branch,
|
||||||
|
remote: payload.remote
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateCommitMessage(directory: string, files: string[]): Promise<{ message: GeneratedCommitMessage }> {
|
||||||
|
const response = await safeGitInvoke<{ message: GeneratedCommitMessage }>('generate_commit_message', {
|
||||||
|
directory,
|
||||||
|
files
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]> {
|
||||||
|
return safeGitInvoke<GitWorktreeInfo[]>('list_git_worktrees', { directory });
|
||||||
|
},
|
||||||
|
|
||||||
|
async addGitWorktree(directory: string, payload: GitAddWorktreePayload): Promise<{ success: boolean; path: string; branch: string }> {
|
||||||
|
await safeGitInvoke<void>('add_git_worktree', {
|
||||||
|
directory,
|
||||||
|
pathStr: payload.path,
|
||||||
|
branch: payload.branch,
|
||||||
|
createBranch: payload.createBranch
|
||||||
|
});
|
||||||
|
return { success: true, path: payload.path, branch: payload.branch };
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeGitWorktree(directory: string, payload: GitRemoveWorktreePayload): Promise<{ success: boolean }> {
|
||||||
|
await safeGitInvoke<void>('remove_git_worktree', {
|
||||||
|
directory,
|
||||||
|
pathStr: payload.path,
|
||||||
|
force: payload.force
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
async ensureOpenChamberIgnored(directory: string): Promise<void> {
|
||||||
|
return safeGitInvoke<void>('ensure_openchamber_ignored', { directory });
|
||||||
|
},
|
||||||
|
|
||||||
|
async createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions): Promise<GitCommitResult> {
|
||||||
|
return safeGitInvoke<GitCommitResult>('create_git_commit', {
|
||||||
|
directory,
|
||||||
|
message,
|
||||||
|
addAll: options?.addAll,
|
||||||
|
files: options?.files
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async gitPush(directory: string, options?: { remote?: string; branch?: string; options?: string[] | Record<string, unknown> }): Promise<GitPushResult> {
|
||||||
|
return safeGitInvoke<GitPushResult>('git_push', {
|
||||||
|
directory,
|
||||||
|
remote: options?.remote,
|
||||||
|
branch: options?.branch,
|
||||||
|
options: options?.options
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async gitPull(directory: string, options?: { remote?: string; branch?: string }): Promise<GitPullResult> {
|
||||||
|
return safeGitInvoke<GitPullResult>('git_pull', {
|
||||||
|
directory,
|
||||||
|
remote: options?.remote,
|
||||||
|
branch: options?.branch
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async gitFetch(directory: string, options?: { remote?: string; branch?: string }): Promise<{ success: boolean }> {
|
||||||
|
await safeGitInvoke<void>('git_fetch', {
|
||||||
|
directory,
|
||||||
|
remote: options?.remote
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
async checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }> {
|
||||||
|
await safeGitInvoke<void>('checkout_branch', { directory, branch });
|
||||||
|
return { success: true, branch };
|
||||||
|
},
|
||||||
|
|
||||||
|
async createBranch(directory: string, name: string, startPoint?: string): Promise<{ success: boolean; branch: string }> {
|
||||||
|
await safeGitInvoke<void>('create_branch', {
|
||||||
|
directory,
|
||||||
|
name,
|
||||||
|
startPoint
|
||||||
|
});
|
||||||
|
return { success: true, branch: name };
|
||||||
|
},
|
||||||
|
|
||||||
|
async getGitLog(directory: string, options?: GitLogOptions): Promise<GitLogResponse> {
|
||||||
|
return safeGitInvoke<GitLogResponse>('get_git_log', {
|
||||||
|
directory,
|
||||||
|
maxCount: options?.maxCount,
|
||||||
|
from: options?.from,
|
||||||
|
to: options?.to,
|
||||||
|
file: options?.file
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCommitFiles(directory: string, hash: string): Promise<GitCommitFilesResponse> {
|
||||||
|
return safeGitInvoke<GitCommitFilesResponse>('get_commit_files', {
|
||||||
|
directory,
|
||||||
|
hash
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCurrentGitIdentity(directory: string): Promise<GitIdentitySummary | null> {
|
||||||
|
try {
|
||||||
|
return await safeGitInvoke<GitIdentitySummary>('get_current_git_identity', { directory });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async setGitIdentity(directory: string, profileId: string): Promise<{ success: boolean; profile: GitIdentityProfile }> {
|
||||||
|
const profile = await safeGitInvoke<GitIdentityProfile>('set_git_identity', { directory, profileId });
|
||||||
|
return { success: true, profile };
|
||||||
|
},
|
||||||
|
|
||||||
|
async getGitIdentities(): Promise<GitIdentityProfile[]> {
|
||||||
|
return safeGitInvoke<GitIdentityProfile[]>('get_git_identities');
|
||||||
|
},
|
||||||
|
|
||||||
|
async createGitIdentity(profile: GitIdentityProfile): Promise<GitIdentityProfile> {
|
||||||
|
return safeGitInvoke<GitIdentityProfile>('create_git_identity', { profile });
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateGitIdentity(id: string, updates: GitIdentityProfile): Promise<GitIdentityProfile> {
|
||||||
|
return safeGitInvoke<GitIdentityProfile>('update_git_identity', { id, updates });
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteGitIdentity(id: string): Promise<void> {
|
||||||
|
return safeGitInvoke<void>('delete_git_identity', { id });
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { RuntimeAPIs, TerminalHandlers } from '@openchamber/ui/lib/api/types';
|
||||||
|
import { createDesktopTerminalAPI } from './terminal';
|
||||||
|
import { createDesktopGitAPI } from './git';
|
||||||
|
import { createDesktopFilesAPI } from './files';
|
||||||
|
import { createDesktopSettingsAPI } from './settings';
|
||||||
|
import { createDesktopPermissionsAPI } from './permissions';
|
||||||
|
import { createDesktopDiagnosticsAPI } from './diagnostics';
|
||||||
|
import { createDesktopNotificationsAPI } from './notifications';
|
||||||
|
import { createDesktopToolsAPI } from './tools';
|
||||||
|
|
||||||
|
const activeTerminalConnections = new Set<string>();
|
||||||
|
|
||||||
|
export const createDesktopAPIs = (): RuntimeAPIs & { cleanup?: () => void } => {
|
||||||
|
const terminalAPI = createDesktopTerminalAPI();
|
||||||
|
const originalConnect = terminalAPI.connect.bind(terminalAPI);
|
||||||
|
|
||||||
|
const wrappedTerminalAPI = {
|
||||||
|
...terminalAPI,
|
||||||
|
connect: (sessionId: string, handlers: TerminalHandlers) => {
|
||||||
|
activeTerminalConnections.add(sessionId);
|
||||||
|
const connection = originalConnect(sessionId, handlers);
|
||||||
|
|
||||||
|
const originalClose = connection.close;
|
||||||
|
return {
|
||||||
|
...connection,
|
||||||
|
close: () => {
|
||||||
|
activeTerminalConnections.delete(sessionId);
|
||||||
|
originalClose();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
runtime: { platform: 'desktop', isDesktop: true, label: 'tauri-bootstrap' },
|
||||||
|
terminal: wrappedTerminalAPI,
|
||||||
|
git: createDesktopGitAPI(),
|
||||||
|
files: createDesktopFilesAPI(),
|
||||||
|
settings: createDesktopSettingsAPI(),
|
||||||
|
permissions: createDesktopPermissionsAPI(),
|
||||||
|
notifications: createDesktopNotificationsAPI(),
|
||||||
|
diagnostics: createDesktopDiagnosticsAPI(),
|
||||||
|
tools: createDesktopToolsAPI(),
|
||||||
|
cleanup: () => {
|
||||||
|
console.info('[DesktopAPIs] Performing cleanup...');
|
||||||
|
|
||||||
|
const activeConnections = Array.from(activeTerminalConnections);
|
||||||
|
activeConnections.forEach(sessionId => {
|
||||||
|
console.info(`[DesktopAPIs] Closing terminal session: ${sessionId}`);
|
||||||
|
|
||||||
|
activeTerminalConnections.delete(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.info(`[DesktopAPIs] Cleanup completed, closed ${activeConnections.length} terminal connections`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
|
||||||
|
import type { NotificationsAPI, NotificationPayload } from '@openchamber/ui/lib/api/types';
|
||||||
|
import { isPermissionGranted, requestPermission } from '@tauri-apps/plugin-notification';
|
||||||
|
import { safeInvoke } from '../lib/tauriCallbackManager';
|
||||||
|
|
||||||
|
export const requestInitialNotificationPermission = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined' && 'Notification' in window) {
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== 'granted') {
|
||||||
|
console.warn('[notifications] Notification permission not granted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[notifications] Failed to request permission:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createDesktopNotificationsAPI = (): NotificationsAPI => ({
|
||||||
|
async notifyAgentCompletion(payload?: NotificationPayload): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
let granted = await isPermissionGranted();
|
||||||
|
if (!granted) {
|
||||||
|
const permission = await requestPermission();
|
||||||
|
granted = permission === 'granted';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!granted) {
|
||||||
|
console.warn('[notifications] Cannot send notification: Permission denied');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await safeInvoke(
|
||||||
|
'desktop_notify',
|
||||||
|
{ payload },
|
||||||
|
{
|
||||||
|
timeout: 5000,
|
||||||
|
onCancel: () => {
|
||||||
|
console.warn('[NotificationsAPI] Notify operation timed out');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[notifications] Failed to send notification:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async canNotify(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await isPermissionGranted();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[notifications] Failed to check notification permission:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { DirectoryPermissionRequest, DirectoryPermissionResult, PermissionsAPI, StartAccessingResult } from '@openchamber/ui/lib/api/types';
|
||||||
|
|
||||||
|
export const createDesktopPermissionsAPI = (): PermissionsAPI => ({
|
||||||
|
async requestDirectoryAccess(request: DirectoryPermissionRequest): Promise<DirectoryPermissionResult> {
|
||||||
|
try {
|
||||||
|
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||||
|
const result = await safeInvoke<DirectoryPermissionResult>('request_directory_access', { request }, {
|
||||||
|
timeout: 30000,
|
||||||
|
onCancel: () => {
|
||||||
|
console.warn('[PermissionsAPI] Request directory access operation timed out');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[desktop] Error requesting directory access:', error);
|
||||||
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async startAccessingDirectory(path: string): Promise<StartAccessingResult> {
|
||||||
|
try {
|
||||||
|
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||||
|
const result = await safeInvoke<StartAccessingResult>('start_accessing_directory', { path }, {
|
||||||
|
timeout: 10000,
|
||||||
|
onCancel: () => {
|
||||||
|
console.warn('[PermissionsAPI] Start accessing directory operation timed out');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[desktop] Error starting directory access:', error);
|
||||||
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async stopAccessingDirectory(path: string): Promise<StartAccessingResult> {
|
||||||
|
try {
|
||||||
|
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||||
|
const result = await safeInvoke<StartAccessingResult>('stop_accessing_directory', { path }, {
|
||||||
|
timeout: 5000,
|
||||||
|
onCancel: () => {
|
||||||
|
console.warn('[PermissionsAPI] Stop accessing directory operation timed out');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[desktop] Error stopping directory access:', error);
|
||||||
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { SettingsAPI, SettingsLoadResult, SettingsPayload } from '@openchamber/ui/lib/api/types';
|
||||||
|
|
||||||
|
const sanitizePayload = (data: unknown): SettingsPayload => {
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return data as SettingsPayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createDesktopSettingsAPI = (): SettingsAPI => ({
|
||||||
|
async load(): Promise<SettingsLoadResult> {
|
||||||
|
try {
|
||||||
|
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||||
|
const result = await safeInvoke<{ settings: unknown; source: 'desktop' | 'web' }>('load_settings', {}, {
|
||||||
|
timeout: 5000,
|
||||||
|
onCancel: () => {
|
||||||
|
console.warn('[SettingsAPI] Load settings operation timed out');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
settings: sanitizePayload(result.settings),
|
||||||
|
source: result.source,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to load settings: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async save(changes: Partial<SettingsPayload>): Promise<SettingsPayload> {
|
||||||
|
try {
|
||||||
|
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||||
|
const result = await safeInvoke<unknown>('save_settings', { changes }, {
|
||||||
|
timeout: 5000,
|
||||||
|
onCancel: () => {
|
||||||
|
console.warn('[SettingsAPI] Save settings operation timed out');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return sanitizePayload(result);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to save settings: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async restartOpenCode(): Promise<{ restarted: boolean }> {
|
||||||
|
try {
|
||||||
|
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
||||||
|
const result = await safeInvoke<{ restarted: boolean }>('restart_opencode', {}, {
|
||||||
|
timeout: 10000,
|
||||||
|
onCancel: () => {
|
||||||
|
console.warn('[SettingsAPI] Restart OpenCode operation timed out');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { restarted: result.restarted };
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to restart OpenCode: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { safeInvoke, safeListen } from '../lib/tauriCallbackManager';
|
||||||
|
import type {
|
||||||
|
TerminalAPI,
|
||||||
|
TerminalHandlers,
|
||||||
|
CreateTerminalOptions,
|
||||||
|
ResizeTerminalPayload,
|
||||||
|
TerminalSession,
|
||||||
|
TerminalStreamEvent
|
||||||
|
} from '@openchamber/ui/lib/api/types';
|
||||||
|
|
||||||
|
async function safeTerminalInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await safeInvoke<T>(command, args, {
|
||||||
|
timeout: 10000,
|
||||||
|
onCancel: () => {
|
||||||
|
console.warn(`[TerminalAPI] Command ${command} timed out`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = typeof error === 'string' ? error : (error as Error).message || 'Unknown error';
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createDesktopTerminalAPI = (): TerminalAPI => ({
|
||||||
|
async createSession(options: CreateTerminalOptions): Promise<TerminalSession> {
|
||||||
|
const cols = options.cols ?? 80;
|
||||||
|
const rows = options.rows ?? 24;
|
||||||
|
|
||||||
|
const res = await safeTerminalInvoke<{ session_id: string }>('create_terminal_session', {
|
||||||
|
payload: {
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
cwd: options.cwd
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: res.session_id,
|
||||||
|
cols,
|
||||||
|
rows
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
connect(sessionId: string, handlers: TerminalHandlers) {
|
||||||
|
let unlistenFn: (() => void) | undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
let isConnected = false;
|
||||||
|
|
||||||
|
const stopListening = () => {
|
||||||
|
if (unlistenFn) {
|
||||||
|
unlistenFn();
|
||||||
|
unlistenFn = undefined;
|
||||||
|
isConnected = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startListening = async () => {
|
||||||
|
try {
|
||||||
|
const unlisten = await safeListen<TerminalStreamEvent>(`terminal://${sessionId}`, (event) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers.onEvent(event.payload);
|
||||||
|
|
||||||
|
if (event.payload?.type === 'exit') {
|
||||||
|
stopListening();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
unlisten();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unlistenFn = unlisten;
|
||||||
|
isConnected = true;
|
||||||
|
handlers.onEvent({ type: 'connected' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to listen to terminal events:', err);
|
||||||
|
if (!cancelled) {
|
||||||
|
handlers.onError?.(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
startListening();
|
||||||
|
|
||||||
|
return {
|
||||||
|
close: () => {
|
||||||
|
cancelled = true;
|
||||||
|
stopListening();
|
||||||
|
},
|
||||||
|
isConnected: () => isConnected,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendInput(sessionId: string, input: string): Promise<void> {
|
||||||
|
await safeTerminalInvoke('send_terminal_input', {
|
||||||
|
|
||||||
|
sessionId,
|
||||||
|
session_id: sessionId,
|
||||||
|
data: input,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async resize(payload: ResizeTerminalPayload): Promise<void> {
|
||||||
|
await safeTerminalInvoke('resize_terminal', {
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
session_id: payload.sessionId,
|
||||||
|
cols: payload.cols,
|
||||||
|
rows: payload.rows,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async close(sessionId: string): Promise<void> {
|
||||||
|
await safeTerminalInvoke('close_terminal', {
|
||||||
|
sessionId,
|
||||||
|
session_id: sessionId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { ToolsAPI } from '@openchamber/ui/lib/api/types';
|
||||||
|
|
||||||
|
export const createDesktopToolsAPI = (): ToolsAPI => ({
|
||||||
|
async getAvailableTools(): Promise<string[]> {
|
||||||
|
|
||||||
|
const response = await fetch('/api/experimental/tool/ids');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Tools API returned ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
throw new Error('Tools API returned invalid data format');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
.filter((tool: unknown): tool is string => typeof tool === 'string' && tool !== 'invalid')
|
||||||
|
.sort();
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
export interface UpdateInfo {
|
||||||
|
available: boolean;
|
||||||
|
version?: string;
|
||||||
|
currentVersion: string;
|
||||||
|
body?: string;
|
||||||
|
date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProgress {
|
||||||
|
downloaded: number;
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Update {
|
||||||
|
version: string;
|
||||||
|
body?: string;
|
||||||
|
date?: string;
|
||||||
|
downloadAndInstall: (
|
||||||
|
onEvent?: (event: DownloadEvent) => void
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadEvent =
|
||||||
|
| { event: 'Started'; data: { contentLength?: number } }
|
||||||
|
| { event: 'Progress'; data: { chunkLength: number } }
|
||||||
|
| { event: 'Finished' };
|
||||||
|
|
||||||
|
let cachedUpdate: Update | null = null;
|
||||||
|
|
||||||
|
export async function checkForUpdates(): Promise<UpdateInfo> {
|
||||||
|
try {
|
||||||
|
const { check } = await import('@tauri-apps/plugin-updater');
|
||||||
|
const update = await check();
|
||||||
|
cachedUpdate = update;
|
||||||
|
|
||||||
|
if (!update) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
currentVersion: await getCurrentVersion(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
version: update.version,
|
||||||
|
currentVersion: await getCurrentVersion(),
|
||||||
|
body: update.body ?? undefined,
|
||||||
|
date: update.date ?? undefined,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[updater] Failed to check for updates:', error);
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
currentVersion: await getCurrentVersion(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadUpdate(
|
||||||
|
onProgress?: (progress: UpdateProgress) => void
|
||||||
|
): Promise<void> {
|
||||||
|
let update = cachedUpdate;
|
||||||
|
if (!update) {
|
||||||
|
const { check } = await import('@tauri-apps/plugin-updater');
|
||||||
|
const checked = await check();
|
||||||
|
if (!checked) {
|
||||||
|
throw new Error('No update available');
|
||||||
|
}
|
||||||
|
update = checked;
|
||||||
|
cachedUpdate = checked;
|
||||||
|
}
|
||||||
|
|
||||||
|
let downloaded = 0;
|
||||||
|
let total: number | undefined;
|
||||||
|
|
||||||
|
await update.downloadAndInstall((event: DownloadEvent) => {
|
||||||
|
switch (event.event) {
|
||||||
|
case 'Started':
|
||||||
|
total = event.data.contentLength;
|
||||||
|
onProgress?.({ downloaded: 0, total });
|
||||||
|
break;
|
||||||
|
case 'Progress':
|
||||||
|
downloaded += event.data.chunkLength;
|
||||||
|
onProgress?.({ downloaded, total });
|
||||||
|
break;
|
||||||
|
case 'Finished':
|
||||||
|
onProgress?.({ downloaded: total ?? downloaded, total });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restartToUpdate(): Promise<void> {
|
||||||
|
const { relaunch } = await import('@tauri-apps/plugin-process');
|
||||||
|
await relaunch();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCurrentVersion(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const { getVersion } = await import('@tauri-apps/api/app');
|
||||||
|
return await getVersion();
|
||||||
|
} catch {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
|
||||||
|
import { safeInvoke, cleanupAllTauriCallbacks } from './tauriCallbackManager';
|
||||||
|
|
||||||
|
type ServerInfo = {
|
||||||
|
server_port: number;
|
||||||
|
opencode_port?: number | null;
|
||||||
|
api_prefix?: string | null;
|
||||||
|
cli_available?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__OPENCHAMBER_DESKTOP_SERVER__?: {
|
||||||
|
origin: string;
|
||||||
|
opencodePort: number | null;
|
||||||
|
apiPrefix: string;
|
||||||
|
cliAvailable: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let bridgePromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
export function initializeDesktopBridge(): Promise<void> {
|
||||||
|
if (!bridgePromise) {
|
||||||
|
bridgePromise = setupBridge();
|
||||||
|
}
|
||||||
|
return bridgePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupBridge(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const info = await safeInvoke<ServerInfo>('desktop_server_info', {}, {
|
||||||
|
timeout: 10000,
|
||||||
|
onCancel: () => {
|
||||||
|
console.warn('[Bridge] Server info request timed out');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const origin = `http://127.0.0.1:${info.server_port}`;
|
||||||
|
|
||||||
|
window.__OPENCHAMBER_DESKTOP_SERVER__ = {
|
||||||
|
origin,
|
||||||
|
opencodePort: info.opencode_port ?? null,
|
||||||
|
apiPrefix: info.api_prefix ?? '',
|
||||||
|
cliAvailable: info.cli_available ?? false,
|
||||||
|
};
|
||||||
|
|
||||||
|
patchFetch(origin);
|
||||||
|
patchEventSource(origin);
|
||||||
|
|
||||||
|
const cleanupDevtools = registerDevtoolsShortcut();
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
(window as { __openchamberCleanup?: () => void }).__openchamberCleanup = () => {
|
||||||
|
cleanupDevtools();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[bridge] Failed to initialize bridge:', error);
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && (window as { __openchamberCleanup?: () => void }).__openchamberCleanup) {
|
||||||
|
try {
|
||||||
|
(window as { __openchamberCleanup?: () => void }).__openchamberCleanup?.();
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn('[bridge] Cleanup during failed initialization failed:', cleanupError);
|
||||||
|
}
|
||||||
|
delete (window as { __openchamberCleanup?: () => void }).__openchamberCleanup;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupAllTauriCallbacks();
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchFetch(origin: string) {
|
||||||
|
const originalFetch = window.fetch.bind(window);
|
||||||
|
|
||||||
|
const rewrite = (value: string): string => {
|
||||||
|
if (value.startsWith('http://') || value.startsWith('https://')) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value.startsWith('//')) {
|
||||||
|
return `http:${value}`;
|
||||||
|
}
|
||||||
|
if (value.startsWith('/')) {
|
||||||
|
return `${origin}${value}`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
return originalFetch(rewrite(input), init);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input instanceof Request) {
|
||||||
|
const rewritten = rewrite(input.url);
|
||||||
|
if (rewritten === input.url) {
|
||||||
|
return originalFetch(input, init);
|
||||||
|
}
|
||||||
|
const cloned = new Request(rewritten, input);
|
||||||
|
return originalFetch(cloned, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input instanceof URL) {
|
||||||
|
return originalFetch(rewrite(input.toString()), init);
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalFetch(input, init);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchEventSource(origin: string) {
|
||||||
|
if (typeof window.EventSource === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OriginalEventSource = window.EventSource;
|
||||||
|
|
||||||
|
class DesktopEventSource extends OriginalEventSource {
|
||||||
|
constructor(url: string | URL, eventSourceInit?: EventSourceInit) {
|
||||||
|
const normalized = typeof url === 'string' ? url : url.toString();
|
||||||
|
super(normalized.startsWith('/') ? `${origin}${normalized}` : normalized, eventSourceInit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(DesktopEventSource, 'name', { value: 'DesktopEventSource' });
|
||||||
|
Object.setPrototypeOf(DesktopEventSource.prototype, OriginalEventSource.prototype);
|
||||||
|
Object.setPrototypeOf(DesktopEventSource, OriginalEventSource);
|
||||||
|
|
||||||
|
window.EventSource = DesktopEventSource as unknown as typeof EventSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerDevtoolsShortcut() {
|
||||||
|
const handler = (event: KeyboardEvent) => {
|
||||||
|
const key = event.key?.toLowerCase();
|
||||||
|
if ((event.metaKey || event.ctrlKey) && event.altKey && key === 'i') {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const devtoolsPromise = safeInvoke('desktop_open_devtools', {}, {
|
||||||
|
timeout: 2000,
|
||||||
|
onCancel: () => {
|
||||||
|
console.warn('[Bridge] Devtools invocation timed out');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
devtoolsPromise.catch(() => {
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handler);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
|
||||||
|
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
|
interface PendingCallback {
|
||||||
|
id: string;
|
||||||
|
timestamp: number;
|
||||||
|
type: 'invoke' | 'listen';
|
||||||
|
cleanup?: () => void;
|
||||||
|
timeout?: NodeJS.Timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CallbackManagerConfig {
|
||||||
|
maxCallbackAge?: number;
|
||||||
|
cleanupInterval?: number;
|
||||||
|
invokeTimeout?: number;
|
||||||
|
listenTimeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TauriCallbackManager {
|
||||||
|
private callbacks = new Map<string, PendingCallback>();
|
||||||
|
private isShuttingDown = false;
|
||||||
|
private cleanupTimer?: NodeJS.Timeout;
|
||||||
|
private config: Required<CallbackManagerConfig>;
|
||||||
|
private windowUnloadHandler?: () => void;
|
||||||
|
|
||||||
|
constructor(config: CallbackManagerConfig = {}) {
|
||||||
|
this.config = {
|
||||||
|
maxCallbackAge: 30000,
|
||||||
|
cleanupInterval: 5000,
|
||||||
|
invokeTimeout: 10000,
|
||||||
|
listenTimeout: 30000,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.setupWindowUnloadHandler();
|
||||||
|
this.startCleanupTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
register(callback: Omit<PendingCallback, 'timestamp'>): string {
|
||||||
|
if (this.isShuttingDown) {
|
||||||
|
console.warn('[TauriCallbackManager] Attempted to register callback during shutdown');
|
||||||
|
return callback.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullCallback: PendingCallback = {
|
||||||
|
...callback,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.callbacks.set(callback.id, fullCallback);
|
||||||
|
|
||||||
|
if (callback.type === 'listen' && this.config.listenTimeout > 0) {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
this.cleanupCallback(callback.id, 'timeout');
|
||||||
|
}, this.config.listenTimeout);
|
||||||
|
fullCallback.timeout = timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
return callback.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
unregister(callbackId: string): void {
|
||||||
|
const callback = this.callbacks.get(callbackId);
|
||||||
|
if (!callback) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callback.timeout) {
|
||||||
|
clearTimeout(callback.timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callback.cleanup) {
|
||||||
|
try {
|
||||||
|
callback.cleanup();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[TauriCallbackManager] Cleanup function failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.callbacks.delete(callbackId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupCallback(callbackId: string, reason: 'timeout' | 'shutdown' | 'expired'): void {
|
||||||
|
const callback = this.callbacks.get(callbackId);
|
||||||
|
if (!callback) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reason === 'expired') {
|
||||||
|
console.warn(`[TauriCallbackManager] Callback ${callbackId} expired and was cleaned up`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.unregister(callbackId);
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupAll(): void {
|
||||||
|
this.isShuttingDown = true;
|
||||||
|
|
||||||
|
if (this.cleanupTimer) {
|
||||||
|
clearInterval(this.cleanupTimer);
|
||||||
|
this.cleanupTimer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const callbackIds = Array.from(this.callbacks.keys());
|
||||||
|
callbackIds.forEach(id => this.cleanupCallback(id, 'shutdown'));
|
||||||
|
|
||||||
|
this.callbacks.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private startCleanupTimer(): void {
|
||||||
|
this.cleanupTimer = setInterval(() => {
|
||||||
|
if (this.isShuttingDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const expiredCallbacks: string[] = [];
|
||||||
|
|
||||||
|
this.callbacks.forEach((callback, id) => {
|
||||||
|
const age = now - callback.timestamp;
|
||||||
|
if (age > this.config.maxCallbackAge) {
|
||||||
|
expiredCallbacks.push(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expiredCallbacks.forEach(id => this.cleanupCallback(id, 'expired'));
|
||||||
|
}, this.config.cleanupInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupWindowUnloadHandler(): void {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.windowUnloadHandler = () => {
|
||||||
|
console.info('[TauriCallbackManager] Window unloading, cleaning up callbacks...');
|
||||||
|
this.cleanupAll();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', this.windowUnloadHandler);
|
||||||
|
window.addEventListener('pagehide', this.windowUnloadHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeWindowHandlers(): void {
|
||||||
|
if (this.windowUnloadHandler && typeof window !== 'undefined') {
|
||||||
|
window.removeEventListener('beforeunload', this.windowUnloadHandler);
|
||||||
|
window.removeEventListener('pagehide', this.windowUnloadHandler);
|
||||||
|
this.windowUnloadHandler = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getStats(): { total: number; invoke: number; listen: number } {
|
||||||
|
const stats = { total: 0, invoke: 0, listen: 0 };
|
||||||
|
|
||||||
|
this.callbacks.forEach(callback => {
|
||||||
|
stats.total++;
|
||||||
|
stats[callback.type]++;
|
||||||
|
});
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let globalCallbackManager: TauriCallbackManager | null = null;
|
||||||
|
|
||||||
|
export function getTauriCallbackManager(config?: CallbackManagerConfig): TauriCallbackManager {
|
||||||
|
if (!globalCallbackManager) {
|
||||||
|
globalCallbackManager = new TauriCallbackManager(config);
|
||||||
|
}
|
||||||
|
return globalCallbackManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function safeInvoke<T>(
|
||||||
|
command: string,
|
||||||
|
args?: Record<string, unknown>,
|
||||||
|
options?: {
|
||||||
|
timeout?: number;
|
||||||
|
onCancel?: () => void;
|
||||||
|
}
|
||||||
|
): Promise<T> {
|
||||||
|
const manager = getTauriCallbackManager();
|
||||||
|
const callbackId = `invoke:${command}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
|
let timeoutHandle: NodeJS.Timeout | undefined;
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
manager.register({
|
||||||
|
id: callbackId,
|
||||||
|
type: 'invoke',
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearAndUnregister = () => {
|
||||||
|
if (timeoutHandle) {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
timeoutHandle = undefined;
|
||||||
|
}
|
||||||
|
manager.unregister(callbackId);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!options?.timeout || options.timeout <= 0) {
|
||||||
|
try {
|
||||||
|
const result = await invoke<T>(command, args);
|
||||||
|
clearAndUnregister();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
clearAndUnregister();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
timeoutHandle = setTimeout(() => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
|
||||||
|
console.warn(`[safeInvoke] Command ${command} timed out after ${options.timeout}ms`);
|
||||||
|
try {
|
||||||
|
options.onCancel?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[safeInvoke] onCancel handler threw:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAndUnregister();
|
||||||
|
reject(new Error(`Command ${command} timed out after ${options.timeout}ms`));
|
||||||
|
}, options.timeout);
|
||||||
|
|
||||||
|
invoke<T>(command, args)
|
||||||
|
.then((result) => {
|
||||||
|
if (settled) {
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
clearAndUnregister();
|
||||||
|
resolve(result);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
clearAndUnregister();
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function safeListen<T>(
|
||||||
|
event: string,
|
||||||
|
handler: (event: { payload: T }) => void,
|
||||||
|
options?: {
|
||||||
|
timeout?: number;
|
||||||
|
onCancel?: () => void;
|
||||||
|
}
|
||||||
|
): Promise<UnlistenFn> {
|
||||||
|
const manager = getTauriCallbackManager();
|
||||||
|
const callbackId = `listen:${event}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
manager.register({
|
||||||
|
id: callbackId,
|
||||||
|
type: 'listen',
|
||||||
|
cleanup: options?.onCancel,
|
||||||
|
});
|
||||||
|
|
||||||
|
const unlisten = await listen<T>(event, (event) => {
|
||||||
|
|
||||||
|
const currentManager = getTauriCallbackManager();
|
||||||
|
if (currentManager.getStats().total === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
handler(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[safeListen] Handler error for event ${event}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const enhancedUnlisten = () => {
|
||||||
|
try {
|
||||||
|
unlisten();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[safeListen] Failed to unlisten from ${event}:`, error);
|
||||||
|
}
|
||||||
|
manager.unregister(callbackId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return enhancedUnlisten;
|
||||||
|
} catch (error) {
|
||||||
|
|
||||||
|
manager.unregister(callbackId);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanupAllTauriCallbacks(): void {
|
||||||
|
if (globalCallbackManager) {
|
||||||
|
globalCallbackManager.cleanupAll();
|
||||||
|
globalCallbackManager.removeWindowHandlers();
|
||||||
|
globalCallbackManager = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { createDesktopAPIs } from './api';
|
||||||
|
import { requestInitialNotificationPermission } from './api/notifications';
|
||||||
|
import { checkForUpdates, downloadUpdate, restartToUpdate, type UpdateInfo, type UpdateProgress } from './api/updater';
|
||||||
|
import { initializeDesktopBridge } from './lib/bridge';
|
||||||
|
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
|
||||||
|
import type { DesktopApi, DesktopSettings } from '@openchamber/ui/lib/desktop';
|
||||||
|
import '@openchamber/ui/index.css';
|
||||||
|
import '@openchamber/ui/styles/fonts';
|
||||||
|
|
||||||
|
if (!(window as typeof globalThis & { process?: unknown }).process) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(window as typeof globalThis & { process?: any }).process = {
|
||||||
|
env: {},
|
||||||
|
platform: 'darwin',
|
||||||
|
version: 'v20.0.0',
|
||||||
|
versions: {},
|
||||||
|
cwd: () => '/',
|
||||||
|
nextTick: (fn: () => void) => Promise.resolve().then(() => fn()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
|
||||||
|
__OPENCHAMBER_HOME__?: string;
|
||||||
|
opencodeDesktop?: DesktopApi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanupFunctions: Array<() => void | Promise<void>> = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await initializeDesktopBridge();
|
||||||
|
|
||||||
|
const activityUnlisten = await listen('openchamber:session-activity', (event) => {
|
||||||
|
window.dispatchEvent(new CustomEvent('openchamber:session-activity', { detail: event.payload }));
|
||||||
|
});
|
||||||
|
cleanupFunctions.push(() => activityUnlisten());
|
||||||
|
|
||||||
|
requestInitialNotificationPermission().catch(err => {
|
||||||
|
console.error('[main] Failed to request notification permission:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.__OPENCHAMBER_RUNTIME_APIS__ = createDesktopAPIs();
|
||||||
|
|
||||||
|
cleanupFunctions.push(() => {
|
||||||
|
console.info('[main] Cleaning up runtime APIs');
|
||||||
|
|
||||||
|
if (window.__OPENCHAMBER_RUNTIME_APIS__) {
|
||||||
|
/* cleanup placeholder */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[main] FATAL: Failed to initialize desktop runtime:', error);
|
||||||
|
|
||||||
|
for (const cleanup of cleanupFunctions) {
|
||||||
|
try {
|
||||||
|
const result = cleanup();
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
await result;
|
||||||
|
}
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn('[main] Cleanup function failed during error handling:', cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div style="padding: 40px; font-family: monospace; color: #ff6b6b; background: #1a1a1a; height: 100vh;">
|
||||||
|
<h1>Desktop Runtime Initialization Failed</h1>
|
||||||
|
<pre style="background: #2a2a2a; padding: 20px; border-radius: 8px; overflow: auto;">
|
||||||
|
${error instanceof Error ? error.stack : String(error)}
|
||||||
|
</pre>
|
||||||
|
<p style="margin-top: 20px; color: #999;">Press Cmd+Option+I to open DevTools for more details</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let homeDirectory: string | undefined;
|
||||||
|
try {
|
||||||
|
const { homeDir } = await import('@tauri-apps/api/path');
|
||||||
|
homeDirectory = await homeDir();
|
||||||
|
} catch {
|
||||||
|
homeDirectory = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (homeDirectory) {
|
||||||
|
window.__OPENCHAMBER_HOME__ = homeDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.opencodeDesktop = {
|
||||||
|
homeDirectory,
|
||||||
|
async getServerInfo() {
|
||||||
|
const server = window.__OPENCHAMBER_DESKTOP_SERVER__;
|
||||||
|
return {
|
||||||
|
webPort: server?.origin ? parseInt(server.origin.split(':')[2] || '0', 10) : null,
|
||||||
|
openCodePort: server?.opencodePort ?? null,
|
||||||
|
host: '127.0.0.1',
|
||||||
|
ready: true,
|
||||||
|
cliAvailable: server?.cliAvailable ?? false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async getSettings(): Promise<DesktopSettings> {
|
||||||
|
try {
|
||||||
|
const result = await invoke<{ settings: DesktopSettings; source: string }>('load_settings');
|
||||||
|
return result.settings;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[desktop] Error loading settings:', error);
|
||||||
|
return {} as DesktopSettings;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async updateSettings(changes: Partial<DesktopSettings>): Promise<DesktopSettings> {
|
||||||
|
try {
|
||||||
|
const result = await invoke<DesktopSettings>('save_settings', { changes });
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[desktop] Error updating settings:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async restartOpenCode() {
|
||||||
|
try {
|
||||||
|
await invoke('restart_opencode');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[desktop] Error restarting OpenCode:', error);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async shutdown() {
|
||||||
|
return { success: false };
|
||||||
|
},
|
||||||
|
async getHomeDirectory() {
|
||||||
|
return { success: true, path: homeDirectory || null };
|
||||||
|
},
|
||||||
|
markRendererReady() {
|
||||||
|
|
||||||
|
},
|
||||||
|
async requestDirectoryAccess() {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||||
|
const selected = await open({
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
title: 'Select Working Directory'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!selected || typeof selected !== 'string') {
|
||||||
|
return { success: false, error: 'Directory selection cancelled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await invoke<{ success: boolean; path?: string; error?: string }>('process_directory_selection', {
|
||||||
|
path: selected
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[desktop] Error requesting directory access:', error);
|
||||||
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async startAccessingDirectory(directoryPath: string) {
|
||||||
|
try {
|
||||||
|
const result = await invoke<{ success: boolean; error?: string }>('start_accessing_directory', { path: directoryPath });
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[desktop] Error starting directory access:', error);
|
||||||
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async stopAccessingDirectory(directoryPath: string) {
|
||||||
|
try {
|
||||||
|
const result = await invoke<{ success: boolean; error?: string }>('stop_accessing_directory', { path: directoryPath });
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[desktop] Error stopping directory access:', error);
|
||||||
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async notifyAssistantCompletion(payload) {
|
||||||
|
try {
|
||||||
|
const { createDesktopNotificationsAPI } = await import('./api/notifications');
|
||||||
|
const result = await createDesktopNotificationsAPI().notifyAgentCompletion(payload);
|
||||||
|
return { success: result };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[desktop] Error sending notification:', error);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async checkForUpdates(): Promise<UpdateInfo> {
|
||||||
|
return checkForUpdates();
|
||||||
|
},
|
||||||
|
async downloadUpdate(onProgress?: (progress: UpdateProgress) => void): Promise<void> {
|
||||||
|
return downloadUpdate(onProgress);
|
||||||
|
},
|
||||||
|
async restartToUpdate(): Promise<void> {
|
||||||
|
return restartToUpdate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.info('[main] window.opencodeDesktop assigned');
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const handleBeforeUnload = () => {
|
||||||
|
console.info('[main] App is unloading, performing cleanup...');
|
||||||
|
|
||||||
|
cleanupFunctions.forEach((cleanup) => {
|
||||||
|
try {
|
||||||
|
const result = cleanup();
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
|
||||||
|
result.catch(cleanupError => {
|
||||||
|
console.warn('[main] Cleanup function failed during unload:', cleanupError);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn('[main] Cleanup function failed during unload:', cleanupError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.info('[main] Cleanup initiated');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
|
||||||
|
window.addEventListener('pagehide', handleBeforeUnload);
|
||||||
|
|
||||||
|
cleanupFunctions.push(() => {
|
||||||
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
window.removeEventListener('pagehide', handleBeforeUnload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await import('@openchamber/ui/main');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[main] FATAL: Failed to load UI module:', error);
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div style="padding: 40px; font-family: monospace; color: #ff6b6b; background: #1a1a1a; height: 100vh;">
|
||||||
|
<h1>UI Module Load Failed</h1>
|
||||||
|
<pre style="background: #2a2a2a; padding: 20px; border-radius: 8px; overflow: auto;">
|
||||||
|
${error instanceof Error ? error.stack : String(error)}
|
||||||
|
</pre>
|
||||||
|
<p style="margin-top: 20px; color: #999;">Check DevTools console for details</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["../ui/src/*"],
|
||||||
|
"@desktop/*": ["./src/*"],
|
||||||
|
"@openchamber/ui/*": ["../ui/src/*"],
|
||||||
|
"@openchamber/desktop/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src", "../ui/src", "../ui/src/types/**/*"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { themeStoragePlugin } from '../../vite-theme-plugin';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: path.resolve(__dirname, '.'),
|
||||||
|
plugins: [react(), themeStoragePlugin()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@desktop': path.resolve(__dirname, './src'),
|
||||||
|
'@openchamber/ui': path.resolve(__dirname, '../ui/src'),
|
||||||
|
'@': path.resolve(__dirname, '../ui/src'),
|
||||||
|
'@opencode-ai/sdk': path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/client.js'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
'process.env': {},
|
||||||
|
'process.platform': JSON.stringify('darwin'),
|
||||||
|
'process.version': JSON.stringify('v20.0.0'),
|
||||||
|
'process.versions': JSON.stringify({}),
|
||||||
|
global: 'globalThis',
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['@opencode-ai/sdk'],
|
||||||
|
exclude: [
|
||||||
|
'@tauri-apps/plugin-dialog',
|
||||||
|
'@tauri-apps/api/core',
|
||||||
|
'@tauri-apps/api/path',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 1421,
|
||||||
|
strictPort: true,
|
||||||
|
hmr: {
|
||||||
|
protocol: 'ws',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 1421,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: path.resolve(__dirname, 'dist'),
|
||||||
|
emptyOutDir: true,
|
||||||
|
chunkSizeWarningLimit: 1200,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks(id) {
|
||||||
|
if (!id.includes('node_modules')) return undefined;
|
||||||
|
|
||||||
|
const match = id.split('node_modules/')[1];
|
||||||
|
if (!match) return undefined;
|
||||||
|
|
||||||
|
const segments = match.split('/');
|
||||||
|
const packageName = match.startsWith('@') ? `${segments[0]}/${segments[1]}` : segments[0];
|
||||||
|
|
||||||
|
if (packageName === 'react' || packageName === 'react-dom') return 'vendor-react';
|
||||||
|
if (packageName === 'zustand' || packageName === 'zustand/middleware') return 'vendor-zustand';
|
||||||
|
|
||||||
|
if (packageName === '@opencode-ai/sdk') return 'vendor-opencode-sdk';
|
||||||
|
if (packageName.includes('remark') || packageName.includes('rehype') || packageName === 'react-markdown') return 'vendor-markdown';
|
||||||
|
if (packageName.startsWith('@radix-ui')) return 'vendor-radix';
|
||||||
|
if (packageName.includes('react-syntax-highlighter') || packageName.includes('highlight.js')) return 'vendor-syntax';
|
||||||
|
if (packageName.startsWith('@tauri-apps')) return 'vendor-tauri';
|
||||||
|
|
||||||
|
const sanitized = packageName.replace(/^@/, '').replace(/\//g, '-');
|
||||||
|
return `vendor-${sanitized}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"name": "@openchamber/ui",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/main.tsx",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsc --noEmit --watch",
|
||||||
|
"build": "tsc --noEmit",
|
||||||
|
"type-check": "tsc --noEmit",
|
||||||
|
"lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||||
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
|
"@ibm/plex": "^6.4.1",
|
||||||
|
"@opencode-ai/sdk": "^1.0.65",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@remixicon/react": "^4.7.0",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"@xterm/xterm": "^5.3.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"http-proxy-middleware": "^3.0.5",
|
||||||
|
"motion": "^12.23.24",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"node-pty": "^1.0.0",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
|
"@monaco-editor/react": "^4.6.0",
|
||||||
|
"react-syntax-highlighter": "^15.6.6",
|
||||||
|
"simple-git": "^3.28.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"streamdown": "^1.6.10",
|
||||||
|
"strip-json-comments": "^5.0.3",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"yaml": "^2.8.1",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.33.0",
|
||||||
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
|
"@tauri-apps/api": "^2.9.0",
|
||||||
|
"@types/node": "^24.3.1",
|
||||||
|
"@types/react": "^19.1.10",
|
||||||
|
"@types/react-dom": "^19.1.7",
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"electron": "^38.2.0",
|
||||||
|
"electron-builder": "^24.13.3",
|
||||||
|
"eslint": "^9.33.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
|
"globals": "^16.3.0",
|
||||||
|
"nodemon": "^3.1.7",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
|
"tw-animate-css": "^1.3.8",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"typescript-eslint": "^8.39.1",
|
||||||
|
"vite": "^7.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { MainLayout } from '@/components/layout/MainLayout';
|
||||||
|
import { FireworksProvider } from '@/contexts/FireworksContext';
|
||||||
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
|
import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel';
|
||||||
|
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||||
|
import { useEventStream } from '@/hooks/useEventStream';
|
||||||
|
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
||||||
|
import { useMessageSync } from '@/hooks/useMessageSync';
|
||||||
|
import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap';
|
||||||
|
import { GitPollingProvider } from '@/hooks/useGitPolling';
|
||||||
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
|
import { useFontPreferences } from '@/hooks/useFontPreferences';
|
||||||
|
import { CODE_FONT_OPTION_MAP, DEFAULT_MONO_FONT, DEFAULT_UI_FONT, UI_FONT_OPTION_MAP } from '@/lib/fontOptions';
|
||||||
|
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
|
||||||
|
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
|
||||||
|
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||||
|
import { OnboardingScreen } from '@/components/onboarding/OnboardingScreen';
|
||||||
|
import { isCliAvailable } from '@/lib/desktop';
|
||||||
|
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||||
|
|
||||||
|
type AppProps = {
|
||||||
|
apis: RuntimeAPIs;
|
||||||
|
};
|
||||||
|
|
||||||
|
function App({ apis }: AppProps) {
|
||||||
|
const { initializeApp, loadProviders, isInitialized } = useConfigStore();
|
||||||
|
const { error, clearError, loadSessions } = useSessionStore();
|
||||||
|
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||||
|
const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory);
|
||||||
|
const [showMemoryDebug, setShowMemoryDebug] = React.useState(false);
|
||||||
|
const { uiFont, monoFont } = useFontPreferences();
|
||||||
|
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => apis.runtime.isDesktop);
|
||||||
|
const [cliAvailable, setCliAvailable] = React.useState<boolean>(() => {
|
||||||
|
if (!apis.runtime.isDesktop) return true;
|
||||||
|
return isCliAvailable();
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setIsDesktopRuntime(apis.runtime.isDesktop);
|
||||||
|
}, [apis.runtime.isDesktop]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
registerRuntimeAPIs(apis);
|
||||||
|
return () => registerRuntimeAPIs(null);
|
||||||
|
}, [apis]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const root = document.documentElement;
|
||||||
|
const uiStack = UI_FONT_OPTION_MAP[uiFont]?.stack ?? UI_FONT_OPTION_MAP[DEFAULT_UI_FONT].stack;
|
||||||
|
const monoStack = CODE_FONT_OPTION_MAP[monoFont]?.stack ?? CODE_FONT_OPTION_MAP[DEFAULT_MONO_FONT].stack;
|
||||||
|
|
||||||
|
root.style.setProperty('--font-sans', uiStack);
|
||||||
|
root.style.setProperty('--font-heading', uiStack);
|
||||||
|
root.style.setProperty('--font-family-sans', uiStack);
|
||||||
|
root.style.setProperty('--font-mono', monoStack);
|
||||||
|
root.style.setProperty('--font-family-mono', monoStack);
|
||||||
|
root.style.setProperty('--ui-regular-font-weight', '400');
|
||||||
|
|
||||||
|
if (document.body) {
|
||||||
|
document.body.style.fontFamily = uiStack;
|
||||||
|
}
|
||||||
|
}, [uiFont, monoFont]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isInitialized) {
|
||||||
|
const hideInitialLoading = () => {
|
||||||
|
const loadingElement = document.getElementById('initial-loading');
|
||||||
|
if (loadingElement) {
|
||||||
|
loadingElement.classList.add('fade-out');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
loadingElement.remove();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(hideInitialLoading, 150);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isInitialized]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const fallbackTimer = setTimeout(() => {
|
||||||
|
const loadingElement = document.getElementById('initial-loading');
|
||||||
|
if (loadingElement && !isInitialized) {
|
||||||
|
loadingElement.classList.add('fade-out');
|
||||||
|
setTimeout(() => {
|
||||||
|
loadingElement.remove();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => clearTimeout(fallbackTimer);
|
||||||
|
}, [isInitialized]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const init = async () => {
|
||||||
|
await initializeApp();
|
||||||
|
await loadProviders();
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
}, [initializeApp, loadProviders]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isSwitchingDirectory) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncDirectoryAndSessions = async () => {
|
||||||
|
opencodeClient.setDirectory(currentDirectory);
|
||||||
|
|
||||||
|
await loadSessions();
|
||||||
|
};
|
||||||
|
|
||||||
|
syncDirectoryAndSessions();
|
||||||
|
}, [currentDirectory, isSwitchingDirectory, loadSessions]);
|
||||||
|
|
||||||
|
useEventStream();
|
||||||
|
|
||||||
|
useKeyboardShortcuts();
|
||||||
|
|
||||||
|
useMessageSync();
|
||||||
|
|
||||||
|
useSessionStatusBootstrap();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'M') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowMemoryDebug(prev => !prev);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
|
||||||
|
setTimeout(() => clearError(), 5000);
|
||||||
|
}
|
||||||
|
}, [error, clearError]);
|
||||||
|
|
||||||
|
const handleCliAvailable = React.useCallback(() => {
|
||||||
|
setCliAvailable(true);
|
||||||
|
window.location.reload();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isDesktopRuntime && !cliAvailable) {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<div className={`h-full text-foreground bg-transparent`}>
|
||||||
|
<OnboardingScreen onCliAvailable={handleCliAvailable} />
|
||||||
|
</div>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<RuntimeAPIProvider apis={apis}>
|
||||||
|
<GitPollingProvider>
|
||||||
|
<FireworksProvider>
|
||||||
|
<div className={`h-full text-foreground ${isDesktopRuntime ? 'bg-transparent' : 'bg-background'}`}>
|
||||||
|
<MainLayout />
|
||||||
|
<Toaster />
|
||||||
|
<ConfigUpdateOverlay />
|
||||||
|
{showMemoryDebug && (
|
||||||
|
<MemoryDebugPanel onClose={() => setShowMemoryDebug(false)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FireworksProvider>
|
||||||
|
</GitPollingProvider>
|
||||||
|
</RuntimeAPIProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" role="img" xmlns="http://www.w3.org/2000/svg"><path d="M20.702 2.29c-2.494-1.554-5.778-1.187-8.706.654C9.076 1.104 5.79.736 3.3 2.29c-3.941 2.463-4.42 8.806-1.07 14.167 2.47 3.954 6.333 6.269 9.77 6.226 3.439.043 7.301-2.273 9.771-6.226 3.347-5.361 2.872-11.704-1.069-14.167zM4.042 15.328a12.838 12.838 0 0 1-1.546-3.541 10.12 10.12 0 0 1-.336-3.338c.15-1.98.956-3.524 2.27-4.345 1.315-.822 3.052-.87 4.903-.137.281.113.556.24.825.382A15.11 15.11 0 0 0 7.5 7.54c-2.035 3.255-2.655 6.878-1.945 9.765a13.247 13.247 0 0 1-1.514-1.98zm17.465-3.541a12.866 12.866 0 0 1-1.547 3.54 13.25 13.25 0 0 1-1.513 1.984c.635-2.589.203-5.76-1.353-8.734a.39.39 0 0 0-.563-.153l-4.852 3.032a.397.397 0 0 0-.126.546l.712 1.139a.395.395 0 0 0 .547.126l3.145-1.965c.101.306.203.606.28.916.296 1.086.41 2.214.335 3.337-.15 1.982-.956 3.525-2.27 4.347a4.437 4.437 0 0 1-2.25.65h-.101a4.432 4.432 0 0 1-2.25-.65c-1.314-.822-2.121-2.365-2.27-4.347a10.153 10.153 0 0 1 .335-3.337 13.212 13.212 0 0 1 4.05-6.482 10.148 10.148 0 0 1 2.849-1.765c1.845-.733 3.586-.685 4.9.137 1.316.822 2.122 2.365 2.271 4.345a10.146 10.146 0 0 1-.33 3.334z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>MoonshotAI</title><path d="M1.052 16.916l9.539 2.552a21.007 21.007 0 00.06 2.033l5.956 1.593a11.997 11.997 0 01-5.586.865l-.18-.016-.044-.004-.084-.009-.094-.01a11.605 11.605 0 01-.157-.02l-.107-.014-.11-.016a11.962 11.962 0 01-.32-.051l-.042-.008-.075-.013-.107-.02-.07-.015-.093-.019-.075-.016-.095-.02-.097-.023-.094-.022-.068-.017-.088-.022-.09-.024-.095-.025-.082-.023-.109-.03-.062-.02-.084-.025-.093-.028-.105-.034-.058-.019-.08-.026-.09-.031-.066-.024a6.293 6.293 0 01-.044-.015l-.068-.025-.101-.037-.057-.022-.08-.03-.087-.035-.088-.035-.079-.032-.095-.04-.063-.028-.063-.027a5.655 5.655 0 01-.041-.018l-.066-.03-.103-.047-.052-.024-.096-.046-.062-.03-.084-.04-.086-.044-.093-.047-.052-.027-.103-.055-.057-.03-.058-.032a6.49 6.49 0 01-.046-.026l-.094-.053-.06-.034-.051-.03-.072-.041-.082-.05-.093-.056-.052-.032-.084-.053-.061-.039-.079-.05-.07-.047-.053-.035a7.785 7.785 0 01-.054-.036l-.044-.03-.044-.03a6.066 6.066 0 01-.04-.028l-.057-.04-.076-.054-.069-.05-.074-.054-.056-.042-.076-.057-.076-.059-.086-.067-.045-.035-.064-.052-.074-.06-.089-.073-.046-.039-.046-.039a7.516 7.516 0 01-.043-.037l-.045-.04-.061-.053-.07-.062-.068-.06-.062-.058-.067-.062-.053-.05-.088-.084a13.28 13.28 0 01-.099-.097l-.029-.028-.041-.042-.069-.07-.05-.051-.05-.053a6.457 6.457 0 01-.168-.179l-.08-.088-.062-.07-.071-.08-.042-.049-.053-.062-.058-.068-.046-.056a7.175 7.175 0 01-.027-.033l-.045-.055-.066-.082-.041-.052-.05-.064-.02-.025a11.99 11.99 0 01-1.44-2.402zm-1.02-5.794l11.353 3.037a20.468 20.468 0 00-.469 2.011l10.817 2.894a12.076 12.076 0 01-1.845 2.005L.657 15.923l-.016-.046-.035-.104a11.965 11.965 0 01-.05-.153l-.007-.023a11.896 11.896 0 01-.207-.741l-.03-.126-.018-.08-.021-.097-.018-.081-.018-.09-.017-.084-.018-.094c-.026-.141-.05-.283-.071-.426l-.017-.118-.011-.083-.013-.102a12.01 12.01 0 01-.019-.161l-.005-.047a12.12 12.12 0 01-.034-2.145zm1.593-5.15l11.948 3.196c-.368.605-.705 1.231-1.01 1.875l11.295 3.022c-.142.82-.368 1.612-.668 2.365l-11.55-3.09L.124 10.26l.015-.1.008-.049.01-.067.015-.087.018-.098c.026-.148.056-.295.088-.442l.028-.124.02-.085.024-.097c.022-.09.045-.18.07-.268l.028-.102.023-.083.03-.1.025-.082.03-.096.026-.082.031-.095a11.896 11.896 0 011.01-2.232zm4.442-4.4L17.352 4.59a20.77 20.77 0 00-1.688 1.721l7.823 2.093c.267.852.442 1.744.513 2.665L2.106 5.213l.045-.065.027-.04.04-.055.046-.065.055-.076.054-.072.064-.086.05-.065.057-.073.055-.07.06-.074.055-.069.065-.077.054-.066.066-.077.053-.06.072-.082.053-.06.067-.074.054-.058.073-.078.058-.06.063-.067.168-.17.1-.098.059-.056.076-.071a12.084 12.084 0 012.272-1.677zM12.017 0h.097l.082.001.069.001.054.002.068.002.046.001.076.003.047.002.06.003.054.002.087.005.105.007.144.011.088.007.044.004.077.008.082.008.047.005.102.012.05.006.108.014.081.01.042.006.065.01.207.032.07.012.065.011.14.026.092.018.11.022.046.01.075.016.041.01L14.7.3l.042.01.065.015.049.012.071.017.096.024.112.03.113.03.113.032.05.015.07.02.078.024.073.023.05.016.05.016.076.025.099.033.102.036.048.017.064.023.093.034.11.041.116.045.1.04.047.02.06.024.041.018.063.026.04.018.057.025.11.048.1.046.074.035.075.036.06.028.092.046.091.045.102.052.053.028.049.026.046.024.06.033.041.022.052.029.088.05.106.06.087.051.057.034.053.032.096.059.088.055.098.062.036.024.064.041.084.056.04.027.062.042.062.043.023.017c.054.037.108.075.161.114l.083.06.065.048.056.043.086.065.082.064.04.03.05.041.086.069.079.065.085.071c.712.6 1.353 1.283 1.909 2.031L7.222.994l.062-.027.065-.028.081-.034.086-.035c.113-.045.227-.09.341-.131l.096-.035.093-.033.084-.03.096-.031c.087-.03.176-.058.264-.085l.091-.027.086-.025.102-.03.085-.023.1-.026L9.04.37l.09-.023.091-.022.095-.022.09-.02.098-.021.091-.02.095-.018.092-.018.1-.018.091-.016.098-.017.092-.014.097-.015.092-.013.102-.013.091-.012.105-.012.09-.01.105-.01c.093-.01.186-.018.28-.024l.106-.008.09-.005.11-.006.093-.004.1-.004.097-.002.099-.002.197-.002z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,258 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { OpenCodeIcon } from '@/components/ui/OpenCodeIcon';
|
||||||
|
import { isDesktopRuntime } from '@/lib/desktop';
|
||||||
|
import { syncDesktopSettings, initializeAppearancePreferences } from '@/lib/persistence';
|
||||||
|
import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence';
|
||||||
|
|
||||||
|
const STATUS_CHECK_ENDPOINT = '/auth/session';
|
||||||
|
|
||||||
|
const fetchSessionStatus = async (): Promise<Response> => {
|
||||||
|
return fetch(STATUS_CHECK_ENDPOINT, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitPassword = async (password: string): Promise<Response> => {
|
||||||
|
return fetch(STATUS_CHECK_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||||
|
<div
|
||||||
|
className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background text-foreground"
|
||||||
|
style={{ fontFamily: '"Inter", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 opacity-55"
|
||||||
|
style={{
|
||||||
|
background: 'radial-gradient(120% 140% at 50% -20%, var(--surface-overlay) 0%, transparent 68%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--surface-subtle)',
|
||||||
|
opacity: 0.22,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="relative z-10 flex w-full justify-center px-4 py-12 sm:px-6">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const LoadingScreen: React.FC<{ message?: string }> = ({ message = 'Preparing workspace…' }) => (
|
||||||
|
<AuthShell>
|
||||||
|
<div className="w-full max-w-sm rounded-3xl border border-border/40 bg-card/90 px-6 py-5 text-center shadow-none backdrop-blur">
|
||||||
|
<p className="typography-ui-label text-muted-foreground">{message}</p>
|
||||||
|
</div>
|
||||||
|
</AuthShell>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ErrorScreen: React.FC<{ onRetry: () => void }> = ({ onRetry }) => (
|
||||||
|
<AuthShell>
|
||||||
|
<div className="w-full max-w-sm rounded-3xl border border-destructive/30 bg-card/90 p-6 shadow-none backdrop-blur">
|
||||||
|
<div className="space-y-3 text-center">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<OpenCodeIcon width={28} height={28} className="text-destructive" />
|
||||||
|
</div>
|
||||||
|
<h1 className="typography-ui-header font-semibold text-destructive">Unable to reach server</h1>
|
||||||
|
<p className="typography-meta text-muted-foreground">
|
||||||
|
We couldn't verify the UI session. Check that the service is running and try again.
|
||||||
|
</p>
|
||||||
|
<Button type="button" onClick={onRetry} className="w-full">
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AuthShell>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface SessionAuthGateProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
type GateState = 'pending' | 'authenticated' | 'locked' | 'error';
|
||||||
|
|
||||||
|
export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) => {
|
||||||
|
const desktopRuntime = React.useMemo(() => isDesktopRuntime(), []);
|
||||||
|
const [state, setState] = React.useState<GateState>(() => (desktopRuntime ? 'authenticated' : 'pending'));
|
||||||
|
const [password, setPassword] = React.useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = React.useState('');
|
||||||
|
const passwordInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
|
const hasResyncedRef = React.useRef(desktopRuntime);
|
||||||
|
|
||||||
|
const checkStatus = React.useCallback(async () => {
|
||||||
|
if (desktopRuntime) {
|
||||||
|
setState('authenticated');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState((prev) => (prev === 'authenticated' ? prev : 'pending'));
|
||||||
|
try {
|
||||||
|
const response = await fetchSessionStatus();
|
||||||
|
if (response.ok) {
|
||||||
|
setState('authenticated');
|
||||||
|
setErrorMessage('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.status === 401) {
|
||||||
|
setState('locked');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState('error');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to check session status:', error);
|
||||||
|
setState('error');
|
||||||
|
}
|
||||||
|
}, [desktopRuntime]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (desktopRuntime) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void checkStatus();
|
||||||
|
}, [checkStatus, desktopRuntime]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!desktopRuntime && state === 'locked') {
|
||||||
|
hasResyncedRef.current = false;
|
||||||
|
}
|
||||||
|
}, [desktopRuntime, state]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (state === 'locked' && passwordInputRef.current) {
|
||||||
|
passwordInputRef.current.focus();
|
||||||
|
passwordInputRef.current.select();
|
||||||
|
}
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (desktopRuntime) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state === 'authenticated' && !hasResyncedRef.current) {
|
||||||
|
hasResyncedRef.current = true;
|
||||||
|
void (async () => {
|
||||||
|
await syncDesktopSettings();
|
||||||
|
await initializeAppearancePreferences();
|
||||||
|
await applyPersistedDirectoryPreferences();
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
}, [desktopRuntime, state]);
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!password || isSubmitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setErrorMessage('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await submitPassword(password);
|
||||||
|
if (response.ok) {
|
||||||
|
setPassword('');
|
||||||
|
setState('authenticated');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
setErrorMessage('Incorrect password. Try again.');
|
||||||
|
setState('locked');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrorMessage('Unexpected response from server.');
|
||||||
|
setState('error');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to submit UI password:', error);
|
||||||
|
setErrorMessage('Network error. Check connection and retry.');
|
||||||
|
setState('error');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state === 'pending') {
|
||||||
|
return <LoadingScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'error') {
|
||||||
|
return <ErrorScreen onRetry={() => void checkStatus()} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'locked') {
|
||||||
|
return (
|
||||||
|
<AuthShell>
|
||||||
|
<div className="w-full max-w-sm rounded-3xl border border-border/40 bg-card/95 p-8 shadow-none backdrop-blur">
|
||||||
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/15 text-primary">
|
||||||
|
<OpenCodeIcon width={24} height={24} className="opacity-80" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="typography-ui-header font-semibold text-foreground">Unlock OpenChamber</h1>
|
||||||
|
<p className="typography-meta text-muted-foreground">
|
||||||
|
Enter the password configured for this web session.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="openchamber-ui-password"
|
||||||
|
className="typography-ui-label text-left font-medium text-foreground"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="openchamber-ui-password"
|
||||||
|
ref={passwordInputRef}
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
placeholder="Enter password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => {
|
||||||
|
setPassword(event.target.value);
|
||||||
|
if (errorMessage) {
|
||||||
|
setErrorMessage('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-invalid={Boolean(errorMessage) || undefined}
|
||||||
|
aria-describedby={errorMessage ? 'oc-ui-auth-error' : undefined}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
{errorMessage && (
|
||||||
|
<p id="oc-ui-auth-error" className="typography-meta text-destructive">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={!password || isSubmitting}>
|
||||||
|
{isSubmitting ? 'Unlocking…' : 'Unlock'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</AuthShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
|
|
||||||
|
interface AgentInfo {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
mode?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentMentionAutocompleteHandle {
|
||||||
|
handleKeyDown: (key: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentMentionAutocompleteProps {
|
||||||
|
searchQuery: string;
|
||||||
|
onAgentSelect: (agentName: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMentionable = (mode?: string | null): boolean => {
|
||||||
|
if (!mode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return mode !== 'primary';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocompleteHandle, AgentMentionAutocompleteProps>(({
|
||||||
|
searchQuery,
|
||||||
|
onAgentSelect,
|
||||||
|
onClose,
|
||||||
|
}, ref) => {
|
||||||
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||||
|
const [agents, setAgents] = React.useState<AgentInfo[]>([]);
|
||||||
|
const { agents: allAgents } = useConfigStore();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const filtered = allAgents
|
||||||
|
.filter((agent) => isMentionable(agent.mode))
|
||||||
|
.map((agent) => ({
|
||||||
|
name: agent.name,
|
||||||
|
description: agent.description,
|
||||||
|
mode: agent.mode ?? undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const normalizedQuery = searchQuery.trim().toLowerCase();
|
||||||
|
const matches = normalizedQuery.length
|
||||||
|
? filtered.filter((agent) => agent.name.toLowerCase().includes(normalizedQuery))
|
||||||
|
: filtered;
|
||||||
|
|
||||||
|
matches.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
setAgents(matches);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}, [allAgents, searchQuery]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
||||||
|
const target = event.target as Node | null;
|
||||||
|
if (!target || !containerRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!containerRef.current.contains(target)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
React.useImperativeHandle(ref, () => ({
|
||||||
|
handleKeyDown: (key: string) => {
|
||||||
|
if (key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!agents.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'ArrowDown') {
|
||||||
|
setSelectedIndex((prev) => (prev + 1) % agents.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'ArrowUp') {
|
||||||
|
setSelectedIndex((prev) => (prev - 1 + agents.length) % agents.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'Enter' || key === 'Tab') {
|
||||||
|
const agent = agents[(selectedIndex + agents.length) % agents.length];
|
||||||
|
if (agent) {
|
||||||
|
onAgentSelect(agent.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}), [agents, onAgentSelect, onClose, selectedIndex]);
|
||||||
|
|
||||||
|
const renderAgent = (agent: AgentInfo, index: number) => (
|
||||||
|
<div
|
||||||
|
key={agent.name}
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label',
|
||||||
|
index === selectedIndex && 'bg-accent'
|
||||||
|
)}
|
||||||
|
onClick={() => onAgentSelect(agent.name)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold">#{agent.name}</span>
|
||||||
|
</div>
|
||||||
|
{agent.description && (
|
||||||
|
<div className="typography-meta text-muted-foreground truncate">
|
||||||
|
{agent.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="absolute z-[100] min-w-[240px] max-w-[360px] max-h-60 bg-popover border border-border rounded-xl shadow-none bottom-full mb-2 left-0 w-max flex flex-col"
|
||||||
|
>
|
||||||
|
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0">
|
||||||
|
{agents.length ? (
|
||||||
|
<div>
|
||||||
|
{agents.map((agent, index) => renderAgent(agent, index))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
||||||
|
No agents found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollableOverlay>
|
||||||
|
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
|
||||||
|
↑↓ navigate • Enter select • Esc close
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
AgentMentionAutocomplete.displayName = 'AgentMentionAutocomplete';
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { RiArrowDownLine } from '@remixicon/react';
|
||||||
|
|
||||||
|
import { ChatInput } from './ChatInput';
|
||||||
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||||
|
import MessageList from './MessageList';
|
||||||
|
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||||
|
import { useChatScrollManager } from '@/hooks/useChatScrollManager';
|
||||||
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
||||||
|
|
||||||
|
export const ChatContainer: React.FC = () => {
|
||||||
|
const {
|
||||||
|
currentSessionId,
|
||||||
|
messages,
|
||||||
|
permissions,
|
||||||
|
streamingMessageIds,
|
||||||
|
isLoading,
|
||||||
|
loadMessages,
|
||||||
|
loadMoreMessages,
|
||||||
|
updateViewportAnchor,
|
||||||
|
sessionMemoryState,
|
||||||
|
isSyncing,
|
||||||
|
messageStreamStates,
|
||||||
|
trimToViewportWindow,
|
||||||
|
sessionActivityPhase,
|
||||||
|
} = useSessionStore();
|
||||||
|
|
||||||
|
const streamingMessageId = React.useMemo(() => {
|
||||||
|
if (!currentSessionId) return null;
|
||||||
|
return streamingMessageIds.get(currentSessionId) ?? null;
|
||||||
|
}, [currentSessionId, streamingMessageIds]);
|
||||||
|
|
||||||
|
const { isMobile } = useDeviceInfo();
|
||||||
|
|
||||||
|
const sessionMessages = React.useMemo(() => {
|
||||||
|
|
||||||
|
return currentSessionId ? messages.get(currentSessionId) || [] : [];
|
||||||
|
}, [currentSessionId, messages]);
|
||||||
|
|
||||||
|
const sessionPermissions = React.useMemo(() => {
|
||||||
|
return currentSessionId ? permissions.get(currentSessionId) || [] : [];
|
||||||
|
}, [currentSessionId, permissions]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
scrollRef,
|
||||||
|
handleMessageContentChange,
|
||||||
|
getAnimationHandlers,
|
||||||
|
showScrollButton,
|
||||||
|
scrollToBottom,
|
||||||
|
spacerHeight,
|
||||||
|
pendingAnchorId,
|
||||||
|
hasActiveAnchor,
|
||||||
|
} = useChatScrollManager({
|
||||||
|
currentSessionId,
|
||||||
|
sessionMessages,
|
||||||
|
streamingMessageId,
|
||||||
|
sessionMemoryState,
|
||||||
|
updateViewportAnchor,
|
||||||
|
isSyncing,
|
||||||
|
isMobile,
|
||||||
|
messageStreamStates,
|
||||||
|
sessionPermissions,
|
||||||
|
trimToViewportWindow,
|
||||||
|
sessionActivityPhase,
|
||||||
|
});
|
||||||
|
|
||||||
|
const memoryState = React.useMemo(() => {
|
||||||
|
if (!currentSessionId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return sessionMemoryState.get(currentSessionId) ?? null;
|
||||||
|
}, [currentSessionId, sessionMemoryState]);
|
||||||
|
const hasMoreAbove = Boolean(memoryState?.hasMoreAbove);
|
||||||
|
const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);
|
||||||
|
React.useEffect(() => {
|
||||||
|
setIsLoadingOlder(false);
|
||||||
|
}, [currentSessionId]);
|
||||||
|
|
||||||
|
const lastScrolledSessionRef = React.useRef<string | null>(null);
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (!currentSessionId || currentSessionId === lastScrolledSessionRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastScrolledSessionRef.current = currentSessionId;
|
||||||
|
|
||||||
|
const container = scrollRef.current;
|
||||||
|
if (container) {
|
||||||
|
container.scrollTop = container.scrollHeight - container.clientHeight;
|
||||||
|
}
|
||||||
|
}, [currentSessionId, scrollRef]);
|
||||||
|
|
||||||
|
const handleLoadOlder = React.useCallback(async () => {
|
||||||
|
if (!currentSessionId || isLoadingOlder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = scrollRef.current;
|
||||||
|
const prevHeight = container?.scrollHeight ?? null;
|
||||||
|
const prevTop = container?.scrollTop ?? null;
|
||||||
|
|
||||||
|
setIsLoadingOlder(true);
|
||||||
|
try {
|
||||||
|
await loadMoreMessages(currentSessionId, 'up');
|
||||||
|
if (container && prevHeight !== null && prevTop !== null) {
|
||||||
|
const heightDiff = container.scrollHeight - prevHeight;
|
||||||
|
container.scrollTop = prevTop + heightDiff;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoadingOlder(false);
|
||||||
|
}
|
||||||
|
}, [currentSessionId, isLoadingOlder, loadMoreMessages, scrollRef]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!currentSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSessionMessages = messages.has(currentSessionId);
|
||||||
|
const existingMessages = hasSessionMessages ? messages.get(currentSessionId) ?? [] : [];
|
||||||
|
|
||||||
|
if (existingMessages.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
await loadMessages(currentSessionId);
|
||||||
|
} finally {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
scrollToBottom();
|
||||||
|
} else {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void load();
|
||||||
|
}, [currentSessionId, loadMessages, messages, scrollToBottom]);
|
||||||
|
|
||||||
|
if (!currentSessionId) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-background">
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading && sessionMessages.length === 0 && !streamingMessageId) {
|
||||||
|
const hasMessagesEntry = messages.has(currentSessionId);
|
||||||
|
if (!hasMessagesEntry) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-background gap-0">
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 bg-background">
|
||||||
|
<div className="chat-column space-y-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="flex gap-3 p-4">
|
||||||
|
<Skeleton className="h-8 w-8 rounded-full" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChatInput scrollToBottom={scrollToBottom} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionMessages.length === 0 && !streamingMessageId) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-background">
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated />
|
||||||
|
</div>
|
||||||
|
<div className="relative bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 z-10">
|
||||||
|
<ChatInput scrollToBottom={scrollToBottom} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-background">
|
||||||
|
<div className="relative flex-1 min-h-0">
|
||||||
|
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<ScrollShadow
|
||||||
|
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
|
||||||
|
ref={scrollRef}
|
||||||
|
style={{
|
||||||
|
contain: 'strict',
|
||||||
|
|
||||||
|
['--scroll-shadow-size' as string]: '48px',
|
||||||
|
}}
|
||||||
|
data-scroll-shadow="true"
|
||||||
|
data-scrollbar="chat"
|
||||||
|
hideBottomShadow={!!pendingAnchorId}
|
||||||
|
>
|
||||||
|
<div className="relative z-0 min-h-full">
|
||||||
|
<MessageList
|
||||||
|
messages={sessionMessages}
|
||||||
|
permissions={sessionPermissions}
|
||||||
|
onMessageContentChange={handleMessageContentChange}
|
||||||
|
getAnimationHandlers={getAnimationHandlers}
|
||||||
|
hasMoreAbove={hasMoreAbove}
|
||||||
|
isLoadingOlder={isLoadingOlder}
|
||||||
|
onLoadOlder={handleLoadOlder}
|
||||||
|
scrollToBottom={scrollToBottom}
|
||||||
|
pendingAnchorId={pendingAnchorId}
|
||||||
|
/>
|
||||||
|
{}
|
||||||
|
{spacerHeight > 0 && hasActiveAnchor && (
|
||||||
|
<div
|
||||||
|
data-role="active-turn-spacer"
|
||||||
|
style={{ height: spacerHeight }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollShadow>
|
||||||
|
<OverlayScrollbar containerRef={scrollRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 z-10">
|
||||||
|
{showScrollButton && sessionMessages.length > 0 && (
|
||||||
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => scrollToBottom()}
|
||||||
|
className="rounded-full h-8 w-8 p-0 shadow-none bg-background/95 hover:bg-accent"
|
||||||
|
aria-label="Scroll to bottom"
|
||||||
|
>
|
||||||
|
|
||||||
|
<RiArrowDownLine className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ChatInput scrollToBottom={scrollToBottom} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||||
|
|
||||||
|
const ChatEmptyState: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-full w-full">
|
||||||
|
<OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(ChatEmptyState);
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { RiChat3Line, RiRestartLine } from '@remixicon/react';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
||||||
|
|
||||||
|
interface ChatErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
error?: Error;
|
||||||
|
errorInfo?: React.ErrorInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatErrorBoundaryProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
sessionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChatErrorBoundary extends React.Component<ChatErrorBoundaryProps, ChatErrorBoundaryState> {
|
||||||
|
constructor(props: ChatErrorBoundaryProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ChatErrorBoundaryState {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||||
|
this.setState({ error, errorInfo });
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.error('Chat error caught by boundary:', error, errorInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReset = () => {
|
||||||
|
this.setState({ hasError: false, error: undefined, errorInfo: undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="flex items-center justify-center gap-2 text-destructive">
|
||||||
|
<RiChat3Line className="h-5 w-5" />
|
||||||
|
Chat Error
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
The chat interface encountered an error. This might be due to a temporary network issue or corrupted message data.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{this.props.sessionId && (
|
||||||
|
<div className="text-xs text-muted-foreground text-center">
|
||||||
|
Session: {this.props.sessionId}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{this.state.error && (
|
||||||
|
<details className="text-xs font-mono bg-muted p-3 rounded">
|
||||||
|
<summary className="cursor-pointer hover:bg-muted/80">Error details</summary>
|
||||||
|
<pre className="mt-2 overflow-x-auto">
|
||||||
|
{this.state.error.toString()}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={this.handleReset} variant="outline" className="flex-1">
|
||||||
|
<RiRestartLine className="h-4 w-4 mr-2" />
|
||||||
|
Reset Chat
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground text-center">
|
||||||
|
If the problem persists, try refreshing the page.
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,882 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { RiAiAgentLine, RiCloseCircleLine, RiFileUploadLine, RiSendPlane2Line } from '@remixicon/react';
|
||||||
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import type { EditPermissionMode } from '@/stores/types/sessionTypes';
|
||||||
|
import { getEditModeColors } from '@/lib/permissions/editModeColors';
|
||||||
|
import { FileAttachmentButton, AttachedFilesList } from './FileAttachment';
|
||||||
|
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
|
||||||
|
import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete';
|
||||||
|
import { AgentMentionAutocomplete, type AgentMentionAutocompleteHandle } from './AgentMentionAutocomplete';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ServerFilePicker } from './ServerFilePicker';
|
||||||
|
import { ModelControls } from './ModelControls';
|
||||||
|
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
||||||
|
import { WorkingPlaceholder } from './message/parts/WorkingPlaceholder';
|
||||||
|
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
|
import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults';
|
||||||
|
|
||||||
|
const MAX_VISIBLE_TEXTAREA_LINES = 8;
|
||||||
|
|
||||||
|
interface ChatInputProps {
|
||||||
|
onOpenSettings?: () => void;
|
||||||
|
scrollToBottom?: (options?: { instant?: boolean }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPrimaryMode = (mode?: string) => mode === 'primary' || mode === 'all' || mode === undefined || mode === null;
|
||||||
|
|
||||||
|
export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => {
|
||||||
|
const [message, setMessage] = React.useState('');
|
||||||
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
|
const [showFileMention, setShowFileMention] = React.useState(false);
|
||||||
|
const [mentionQuery, setMentionQuery] = React.useState('');
|
||||||
|
const [showCommandAutocomplete, setShowCommandAutocomplete] = React.useState(false);
|
||||||
|
const [commandQuery, setCommandQuery] = React.useState('');
|
||||||
|
const [showAgentAutocomplete, setShowAgentAutocomplete] = React.useState(false);
|
||||||
|
const [agentQuery, setAgentQuery] = React.useState('');
|
||||||
|
const [textareaSize, setTextareaSize] = React.useState<{ height: number; maxHeight: number } | null>(null);
|
||||||
|
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||||
|
const dropZoneRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const mentionRef = React.useRef<FileMentionHandle>(null);
|
||||||
|
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
|
||||||
|
const agentRef = React.useRef<AgentMentionAutocompleteHandle>(null);
|
||||||
|
|
||||||
|
const sendMessage = useSessionStore((state) => state.sendMessage);
|
||||||
|
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||||
|
const abortCurrentOperation = useSessionStore((state) => state.abortCurrentOperation);
|
||||||
|
const acknowledgeSessionAbort = useSessionStore((state) => state.acknowledgeSessionAbort);
|
||||||
|
const abortPromptSessionId = useSessionStore((state) => state.abortPromptSessionId);
|
||||||
|
const abortPromptExpiresAt = useSessionStore((state) => state.abortPromptExpiresAt);
|
||||||
|
const clearAbortPrompt = useSessionStore((state) => state.clearAbortPrompt);
|
||||||
|
const attachedFiles = useSessionStore((state) => state.attachedFiles);
|
||||||
|
const addAttachedFile = useSessionStore((state) => state.addAttachedFile);
|
||||||
|
const addServerFile = useSessionStore((state) => state.addServerFile);
|
||||||
|
const clearAttachedFiles = useSessionStore((state) => state.clearAttachedFiles);
|
||||||
|
const saveSessionAgentSelection = useSessionStore((state) => state.saveSessionAgentSelection);
|
||||||
|
|
||||||
|
const { currentProviderId, currentModelId, currentAgentName, agents, setAgent } = useConfigStore();
|
||||||
|
const { isMobile } = useUIStore();
|
||||||
|
const { working } = useAssistantStatus();
|
||||||
|
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
|
||||||
|
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const prevWasAbortedRef = React.useRef(false);
|
||||||
|
|
||||||
|
const currentAgent = React.useMemo(() => {
|
||||||
|
if (!currentAgentName) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return agents.find((agent) => agent.name === currentAgentName);
|
||||||
|
}, [agents, currentAgentName]);
|
||||||
|
|
||||||
|
const agentDefaultEditMode = React.useMemo<EditPermissionMode>(() => {
|
||||||
|
const agentPermissionRaw = currentAgent?.permission?.edit;
|
||||||
|
let defaultMode: EditPermissionMode = 'ask';
|
||||||
|
|
||||||
|
if (agentPermissionRaw === 'allow' || agentPermissionRaw === 'ask' || agentPermissionRaw === 'deny' || agentPermissionRaw === 'full') {
|
||||||
|
defaultMode = agentPermissionRaw;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editToolConfigured = currentAgent ? (currentAgent.tools?.['edit'] !== false) : false;
|
||||||
|
if (!currentAgent || !editToolConfigured) {
|
||||||
|
defaultMode = 'deny';
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultMode;
|
||||||
|
}, [currentAgent]);
|
||||||
|
|
||||||
|
const sessionAgentEditOverride = useSessionStore(
|
||||||
|
React.useCallback((state) => {
|
||||||
|
if (!currentSessionId || !currentAgentName) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const sessionMap = state.sessionAgentEditModes.get(currentSessionId);
|
||||||
|
return sessionMap?.get(currentAgentName);
|
||||||
|
}, [currentSessionId, currentAgentName])
|
||||||
|
);
|
||||||
|
|
||||||
|
const agentWebfetchPermission = currentAgent?.permission?.webfetch;
|
||||||
|
const agentBashPermission = currentAgent?.permission?.bash as BashPermissionSetting | undefined;
|
||||||
|
|
||||||
|
const permissionUiState = React.useMemo(() => calculateEditPermissionUIState({
|
||||||
|
agentDefaultEditMode,
|
||||||
|
webfetchPermission: agentWebfetchPermission,
|
||||||
|
bashPermission: agentBashPermission,
|
||||||
|
}), [agentDefaultEditMode, agentWebfetchPermission, agentBashPermission]);
|
||||||
|
|
||||||
|
const selectionContextReady = Boolean(currentSessionId && currentAgentName);
|
||||||
|
|
||||||
|
const effectiveEditPermission = React.useMemo<EditPermissionMode>(() => {
|
||||||
|
if (selectionContextReady && sessionAgentEditOverride && permissionUiState.modeAvailability[sessionAgentEditOverride]) {
|
||||||
|
return sessionAgentEditOverride;
|
||||||
|
}
|
||||||
|
return permissionUiState.cascadeDefaultMode;
|
||||||
|
}, [permissionUiState, selectionContextReady, sessionAgentEditOverride]);
|
||||||
|
|
||||||
|
const chatInputAccent = React.useMemo(() => getEditModeColors(effectiveEditPermission), [effectiveEditPermission]);
|
||||||
|
|
||||||
|
const chatInputWrapperStyle = React.useMemo<React.CSSProperties | undefined>(() => {
|
||||||
|
if (!chatInputAccent) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
borderColor: chatInputAccent.border ?? chatInputAccent.text,
|
||||||
|
borderWidth: chatInputAccent.borderWidth ?? 1,
|
||||||
|
};
|
||||||
|
}, [chatInputAccent]);
|
||||||
|
|
||||||
|
const hasContent = message.trim() || attachedFiles.length > 0;
|
||||||
|
|
||||||
|
const canAbort = working.isWorking;
|
||||||
|
|
||||||
|
const isAbortPromptActive = React.useMemo(() => {
|
||||||
|
if (!currentSessionId) return false;
|
||||||
|
return abortPromptSessionId === currentSessionId && Boolean(abortPromptExpiresAt);
|
||||||
|
}, [abortPromptSessionId, abortPromptExpiresAt, currentSessionId]);
|
||||||
|
const canShowAbortButton = canAbort && (isMobile || isAbortPromptActive);
|
||||||
|
|
||||||
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
|
||||||
|
if (!hasContent || !currentSessionId) return;
|
||||||
|
|
||||||
|
const messageToSend = message.replace(/^\n+|\n+$/g, '');
|
||||||
|
|
||||||
|
scrollToBottom?.({ instant: true });
|
||||||
|
|
||||||
|
const normalizedCommand = messageToSend.trimStart();
|
||||||
|
if (normalizedCommand.startsWith('/')) {
|
||||||
|
const commandName = normalizedCommand
|
||||||
|
.slice(1)
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)[0]
|
||||||
|
?.toLowerCase();
|
||||||
|
if (commandName === 'summarize') {
|
||||||
|
scrollToBottom?.({ instant: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentProviderId || !currentModelId) {
|
||||||
|
|
||||||
|
console.warn('Cannot send message: provider or model not selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents);
|
||||||
|
const agentMentionName = mention?.name;
|
||||||
|
|
||||||
|
const attachmentsToSend = attachedFiles.map((file) => ({ ...file }));
|
||||||
|
if (attachmentsToSend.length > 0) {
|
||||||
|
clearAttachedFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessage('');
|
||||||
|
|
||||||
|
await sendMessage(sanitizedText, currentProviderId, currentModelId, currentAgentName, attachmentsToSend, agentMentionName)
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const rawMessage =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: typeof error === 'string'
|
||||||
|
? error
|
||||||
|
: String(error ?? '');
|
||||||
|
const normalized = rawMessage.toLowerCase();
|
||||||
|
|
||||||
|
console.error('Message send failed:', rawMessage || error);
|
||||||
|
|
||||||
|
const isSoftNetworkError =
|
||||||
|
normalized.includes('timeout') ||
|
||||||
|
normalized.includes('timed out') ||
|
||||||
|
normalized.includes('may still be processing') ||
|
||||||
|
normalized.includes('being processed') ||
|
||||||
|
normalized.includes('failed to fetch') ||
|
||||||
|
normalized.includes('networkerror') ||
|
||||||
|
normalized.includes('network error') ||
|
||||||
|
normalized.includes('gateway timeout') ||
|
||||||
|
normalized === 'failed to send message';
|
||||||
|
|
||||||
|
if (isSoftNetworkError) {
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachmentsToSend.length > 0) {
|
||||||
|
useFileStore.setState({ attachedFiles: attachmentsToSend });
|
||||||
|
}
|
||||||
|
toast.error(rawMessage || 'Message failed to send. Attachments restored.');
|
||||||
|
});
|
||||||
|
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
|
||||||
|
if (showCommandAutocomplete && commandRef.current) {
|
||||||
|
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
commandRef.current.handleKeyDown(e.key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAgentAutocomplete && agentRef.current) {
|
||||||
|
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
agentRef.current.handleKeyDown(e.key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showFileMention && mentionRef.current) {
|
||||||
|
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
mentionRef.current.handleKeyDown(e.key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Tab' && !showCommandAutocomplete && !showFileMention) {
|
||||||
|
e.preventDefault();
|
||||||
|
cycleAgent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && !isMobile) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startAbortIndicator = React.useCallback(() => {
|
||||||
|
if (abortTimeoutRef.current) {
|
||||||
|
clearTimeout(abortTimeoutRef.current);
|
||||||
|
abortTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowAbortStatus(true);
|
||||||
|
|
||||||
|
abortTimeoutRef.current = setTimeout(() => {
|
||||||
|
setShowAbortStatus(false);
|
||||||
|
abortTimeoutRef.current = null;
|
||||||
|
}, 1800);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAbort = React.useCallback(() => {
|
||||||
|
clearAbortPrompt();
|
||||||
|
startAbortIndicator();
|
||||||
|
|
||||||
|
void abortCurrentOperation();
|
||||||
|
}, [abortCurrentOperation, clearAbortPrompt, startAbortIndicator]);
|
||||||
|
|
||||||
|
const cycleAgent = () => {
|
||||||
|
const primaryAgents = agents.filter(agent => isPrimaryMode(agent.mode));
|
||||||
|
|
||||||
|
if (primaryAgents.length <= 1) return;
|
||||||
|
|
||||||
|
const currentIndex = primaryAgents.findIndex(agent => agent.name === currentAgentName);
|
||||||
|
const nextIndex = (currentIndex + 1) % primaryAgents.length;
|
||||||
|
const nextAgent = primaryAgents[nextIndex];
|
||||||
|
|
||||||
|
setAgent(nextAgent.name);
|
||||||
|
|
||||||
|
if (currentSessionId) {
|
||||||
|
|
||||||
|
saveSessionAgentSelection(currentSessionId, nextAgent.name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const adjustTextareaHeight = React.useCallback(() => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
|
||||||
|
const view = textarea.ownerDocument?.defaultView;
|
||||||
|
const computedStyle = view ? view.getComputedStyle(textarea) : null;
|
||||||
|
const lineHeight = computedStyle ? parseFloat(computedStyle.lineHeight) : NaN;
|
||||||
|
const paddingTop = computedStyle ? parseFloat(computedStyle.paddingTop) : NaN;
|
||||||
|
const paddingBottom = computedStyle ? parseFloat(computedStyle.paddingBottom) : NaN;
|
||||||
|
const fallbackLineHeight = 22;
|
||||||
|
const fallbackPadding = 16;
|
||||||
|
const paddingTotal = Number.isNaN(paddingTop) || Number.isNaN(paddingBottom)
|
||||||
|
? fallbackPadding
|
||||||
|
: paddingTop + paddingBottom;
|
||||||
|
const targetLineHeight = Number.isNaN(lineHeight) ? fallbackLineHeight : lineHeight;
|
||||||
|
const maxHeight = targetLineHeight * MAX_VISIBLE_TEXTAREA_LINES + paddingTotal;
|
||||||
|
const scrollHeight = textarea.scrollHeight || textarea.offsetHeight;
|
||||||
|
const nextHeight = Math.min(scrollHeight, maxHeight);
|
||||||
|
|
||||||
|
textarea.style.height = `${nextHeight}px`;
|
||||||
|
textarea.style.maxHeight = `${maxHeight}px`;
|
||||||
|
|
||||||
|
setTextareaSize((prev) => {
|
||||||
|
if (prev && prev.height === nextHeight && prev.maxHeight === maxHeight) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return { height: nextHeight, maxHeight };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
adjustTextareaHeight();
|
||||||
|
}, [adjustTextareaHeight, message, isMobile]);
|
||||||
|
|
||||||
|
const updateAutocompleteState = React.useCallback((value: string, cursorPosition: number) => {
|
||||||
|
if (value.startsWith('/')) {
|
||||||
|
const firstSpace = value.indexOf(' ');
|
||||||
|
const firstNewline = value.indexOf('\n');
|
||||||
|
const commandEnd = Math.min(
|
||||||
|
firstSpace === -1 ? value.length : firstSpace,
|
||||||
|
firstNewline === -1 ? value.length : firstNewline
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cursorPosition <= commandEnd && firstSpace === -1) {
|
||||||
|
const commandText = value.substring(1, commandEnd);
|
||||||
|
setCommandQuery(commandText);
|
||||||
|
setShowCommandAutocomplete(true);
|
||||||
|
setShowFileMention(false);
|
||||||
|
setShowAgentAutocomplete(false);
|
||||||
|
} else {
|
||||||
|
setShowCommandAutocomplete(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowCommandAutocomplete(false);
|
||||||
|
|
||||||
|
const textBeforeCursor = value.substring(0, cursorPosition);
|
||||||
|
|
||||||
|
const lastHashSymbol = textBeforeCursor.lastIndexOf('#');
|
||||||
|
if (lastHashSymbol !== -1) {
|
||||||
|
const charBefore = lastHashSymbol > 0 ? textBeforeCursor[lastHashSymbol - 1] : null;
|
||||||
|
const textAfterHash = textBeforeCursor.substring(lastHashSymbol + 1);
|
||||||
|
const hasSeparator = textAfterHash.includes(' ') || textAfterHash.includes('\n');
|
||||||
|
const isWordBoundary = !charBefore || /\s/.test(charBefore);
|
||||||
|
|
||||||
|
if (isWordBoundary && !hasSeparator) {
|
||||||
|
setAgentQuery(textAfterHash);
|
||||||
|
setShowAgentAutocomplete(true);
|
||||||
|
setShowFileMention(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowAgentAutocomplete(false);
|
||||||
|
setAgentQuery('');
|
||||||
|
|
||||||
|
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
|
||||||
|
if (lastAtSymbol !== -1) {
|
||||||
|
const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1);
|
||||||
|
if (!textAfterAt.includes(' ') && !textAfterAt.includes('\n')) {
|
||||||
|
setMentionQuery(textAfterAt);
|
||||||
|
setShowFileMention(true);
|
||||||
|
} else {
|
||||||
|
setShowFileMention(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setShowFileMention(false);
|
||||||
|
}
|
||||||
|
}, [setAgentQuery, setCommandQuery, setMentionQuery, setShowAgentAutocomplete, setShowCommandAutocomplete, setShowFileMention]);
|
||||||
|
|
||||||
|
const insertTextAtSelection = React.useCallback((text: string) => {
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) {
|
||||||
|
const nextValue = message + text;
|
||||||
|
setMessage(nextValue);
|
||||||
|
updateAutocompleteState(nextValue, nextValue.length);
|
||||||
|
requestAnimationFrame(() => adjustTextareaHeight());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = textarea.selectionStart ?? message.length;
|
||||||
|
const end = textarea.selectionEnd ?? message.length;
|
||||||
|
const nextValue = `${message.substring(0, start)}${text}${message.substring(end)}`;
|
||||||
|
setMessage(nextValue);
|
||||||
|
const cursorPosition = start + text.length;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const currentTextarea = textareaRef.current;
|
||||||
|
if (currentTextarea) {
|
||||||
|
currentTextarea.selectionStart = cursorPosition;
|
||||||
|
currentTextarea.selectionEnd = cursorPosition;
|
||||||
|
}
|
||||||
|
adjustTextareaHeight();
|
||||||
|
});
|
||||||
|
|
||||||
|
updateAutocompleteState(nextValue, cursorPosition);
|
||||||
|
}, [adjustTextareaHeight, message, updateAutocompleteState]);
|
||||||
|
|
||||||
|
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
const cursorPosition = e.target.selectionStart ?? value.length;
|
||||||
|
setMessage(value);
|
||||||
|
adjustTextareaHeight();
|
||||||
|
updateAutocompleteState(value, cursorPosition);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaste = React.useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
const fileMap = new Map<string, File>();
|
||||||
|
|
||||||
|
Array.from(e.clipboardData.files || []).forEach(file => {
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
fileMap.set(`${file.name}-${file.size}`, file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Array.from(e.clipboardData.items || []).forEach(item => {
|
||||||
|
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) {
|
||||||
|
fileMap.set(`${file.name}-${file.size}`, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageFiles = Array.from(fileMap.values());
|
||||||
|
if (imageFiles.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const pastedText = e.clipboardData.getData('text');
|
||||||
|
if (pastedText) {
|
||||||
|
insertTextAtSelection(pastedText);
|
||||||
|
}
|
||||||
|
|
||||||
|
let attachedCount = 0;
|
||||||
|
|
||||||
|
for (const file of imageFiles) {
|
||||||
|
const sizeBefore = useSessionStore.getState().attachedFiles.length;
|
||||||
|
try {
|
||||||
|
await addAttachedFile(file);
|
||||||
|
const sizeAfter = useSessionStore.getState().attachedFiles.length;
|
||||||
|
if (sizeAfter > sizeBefore) {
|
||||||
|
attachedCount += 1;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Clipboard image attach failed', error);
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to attach image from clipboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachedCount > 0) {
|
||||||
|
toast.success(`Attached ${attachedCount} image${attachedCount > 1 ? 's' : ''} from clipboard`);
|
||||||
|
}
|
||||||
|
}, [addAttachedFile, currentSessionId, insertTextAtSelection]);
|
||||||
|
|
||||||
|
const handleFileSelect = (file: { name: string; path: string }) => {
|
||||||
|
|
||||||
|
const cursorPosition = textareaRef.current?.selectionStart || 0;
|
||||||
|
const textBeforeCursor = message.substring(0, cursorPosition);
|
||||||
|
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
|
||||||
|
|
||||||
|
if (lastAtSymbol !== -1) {
|
||||||
|
const newMessage =
|
||||||
|
message.substring(0, lastAtSymbol) +
|
||||||
|
file.name +
|
||||||
|
message.substring(cursorPosition);
|
||||||
|
setMessage(newMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowFileMention(false);
|
||||||
|
setMentionQuery('');
|
||||||
|
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAgentSelect = (agentName: string) => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
const cursorPosition = textarea?.selectionStart ?? message.length;
|
||||||
|
const textBeforeCursor = message.substring(0, cursorPosition);
|
||||||
|
const lastHashSymbol = textBeforeCursor.lastIndexOf('#');
|
||||||
|
|
||||||
|
if (lastHashSymbol !== -1) {
|
||||||
|
const newMessage =
|
||||||
|
message.substring(0, lastHashSymbol) +
|
||||||
|
`#${agentName} ` +
|
||||||
|
message.substring(cursorPosition);
|
||||||
|
setMessage(newMessage);
|
||||||
|
|
||||||
|
const nextCursor = lastHashSymbol + agentName.length + 2;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.selectionStart = nextCursor;
|
||||||
|
textareaRef.current.selectionEnd = nextCursor;
|
||||||
|
}
|
||||||
|
adjustTextareaHeight();
|
||||||
|
updateAutocompleteState(newMessage, nextCursor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowAgentAutocomplete(false);
|
||||||
|
setAgentQuery('');
|
||||||
|
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommandSelect = (command: { name: string; description?: string; agent?: string; model?: string }) => {
|
||||||
|
|
||||||
|
setMessage(`/${command.name} `);
|
||||||
|
|
||||||
|
const textareaElement = textareaRef.current as HTMLTextAreaElement & { _commandMetadata?: typeof command };
|
||||||
|
if (textareaElement) {
|
||||||
|
textareaElement._commandMetadata = command;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowCommandAutocomplete(false);
|
||||||
|
setCommandQuery('');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.focus();
|
||||||
|
textareaRef.current.setSelectionRange(textareaRef.current.value.length, textareaRef.current.value.length);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
|
||||||
|
if (currentSessionId && textareaRef.current && !isMobile) {
|
||||||
|
textareaRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [currentSessionId, isMobile]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (abortPromptSessionId && abortPromptSessionId !== currentSessionId) {
|
||||||
|
clearAbortPrompt();
|
||||||
|
}
|
||||||
|
}, [abortPromptSessionId, currentSessionId, clearAbortPrompt]);
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (currentSessionId && !isDragging) {
|
||||||
|
setIsDragging(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.currentTarget === e.target) {
|
||||||
|
setIsDragging(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = async (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
if (!currentSessionId) return;
|
||||||
|
|
||||||
|
const files = Array.from(e.dataTransfer.files);
|
||||||
|
let attachedCount = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const sizeBefore = useSessionStore.getState().attachedFiles.length;
|
||||||
|
try {
|
||||||
|
await addAttachedFile(file);
|
||||||
|
const sizeAfter = useSessionStore.getState().attachedFiles.length;
|
||||||
|
if (sizeAfter > sizeBefore) {
|
||||||
|
attachedCount += 1;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('File attach failed', error);
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to attach file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachedCount > 0) {
|
||||||
|
toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServerFilesSelected = React.useCallback(async (files: Array<{ path: string; name: string }>) => {
|
||||||
|
let attachedCount = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const sizeBefore = useSessionStore.getState().attachedFiles.length;
|
||||||
|
try {
|
||||||
|
await addServerFile(file.path, file.name);
|
||||||
|
const sizeAfter = useSessionStore.getState().attachedFiles.length;
|
||||||
|
if (sizeAfter > sizeBefore) {
|
||||||
|
attachedCount += 1;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Server file attach failed', error);
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to attach file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachedCount > 0) {
|
||||||
|
toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
|
||||||
|
}
|
||||||
|
}, [addServerFile]);
|
||||||
|
|
||||||
|
const footerGapClass = 'gap-x-1.5 gap-y-0';
|
||||||
|
const footerPaddingClass = isMobile ? 'px-1.5 py-1.5' : 'px-2.5 py-1.5';
|
||||||
|
const footerHeightClass = isMobile ? 'h-9 w-9' : 'h-7 w-7';
|
||||||
|
const iconSizeClass = isMobile ? 'h-5 w-5' : 'h-[18px] w-[18px]';
|
||||||
|
|
||||||
|
const iconButtonBaseClass = cn(
|
||||||
|
footerHeightClass,
|
||||||
|
'flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0'
|
||||||
|
);
|
||||||
|
|
||||||
|
const actionButton = (
|
||||||
|
<button
|
||||||
|
type='submit'
|
||||||
|
disabled={!hasContent || !currentSessionId}
|
||||||
|
className={cn(
|
||||||
|
iconButtonBaseClass,
|
||||||
|
hasContent && currentSessionId
|
||||||
|
? 'text-primary hover:text-primary'
|
||||||
|
: 'opacity-30'
|
||||||
|
)}
|
||||||
|
aria-label='Send message'
|
||||||
|
>
|
||||||
|
<RiSendPlane2Line className={cn(iconSizeClass)} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectFileButton = (
|
||||||
|
<ServerFilePicker onFilesSelected={handleServerFilesSelected} multiSelect>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className={iconButtonBaseClass}
|
||||||
|
title='Attach files from project'
|
||||||
|
aria-label='Attach files from project'
|
||||||
|
>
|
||||||
|
<RiFileUploadLine className={cn(iconSizeClass, 'text-current')} />
|
||||||
|
</button>
|
||||||
|
</ServerFilePicker>
|
||||||
|
);
|
||||||
|
|
||||||
|
const settingsButton = onOpenSettings ? (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={onOpenSettings}
|
||||||
|
className={iconButtonBaseClass}
|
||||||
|
title='Model and agent settings'
|
||||||
|
aria-label='Model and agent settings'
|
||||||
|
>
|
||||||
|
<RiAiAgentLine className={cn(iconSizeClass, 'text-current')} />
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const attachmentsControls = (
|
||||||
|
<>
|
||||||
|
<FileAttachmentButton />
|
||||||
|
{projectFileButton}
|
||||||
|
{settingsButton}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const workingStatusText = working.statusText;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const pendingAbortBanner = Boolean(working.wasAborted);
|
||||||
|
if (!prevWasAbortedRef.current && pendingAbortBanner && !showAbortStatus) {
|
||||||
|
startAbortIndicator();
|
||||||
|
if (currentSessionId) {
|
||||||
|
acknowledgeSessionAbort(currentSessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevWasAbortedRef.current = pendingAbortBanner;
|
||||||
|
}, [
|
||||||
|
acknowledgeSessionAbort,
|
||||||
|
currentSessionId,
|
||||||
|
showAbortStatus,
|
||||||
|
startAbortIndicator,
|
||||||
|
working.wasAborted,
|
||||||
|
]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (abortTimeoutRef.current) {
|
||||||
|
clearTimeout(abortTimeoutRef.current);
|
||||||
|
abortTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const shouldRenderPlaceholder = !showAbortStatus && (working.wasAborted || !working.abortActive);
|
||||||
|
|
||||||
|
return (
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="pt-0 pb-4 bottom-safe-area">
|
||||||
|
<div className="chat-column mb-1.5 h-[1.2rem] flex items-center justify-between gap-2 overflow-visible">
|
||||||
|
<div className="flex-1 flex items-center overflow-hidden">
|
||||||
|
{showAbortStatus ? (
|
||||||
|
<div className="flex h-full items-center text-[var(--status-error)] pl-[2ch]">
|
||||||
|
<span className="flex items-center gap-1.5 typography-ui-header">
|
||||||
|
<RiCloseCircleLine size={18} aria-hidden="true" />
|
||||||
|
Aborted
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : shouldRenderPlaceholder ? (
|
||||||
|
<WorkingPlaceholder
|
||||||
|
key={currentSessionId ?? 'no-session'}
|
||||||
|
statusText={workingStatusText}
|
||||||
|
isWaitingForPermission={working.isWaitingForPermission}
|
||||||
|
wasAborted={working.wasAborted}
|
||||||
|
completionId={working.lastCompletionId}
|
||||||
|
isComplete={working.isComplete}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canShowAbortButton ? (
|
||||||
|
<div className="flex-shrink-0 pr-[2ch]">
|
||||||
|
{isMobile ? (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={handleAbort}
|
||||||
|
className='flex items-center justify-center h-[1.2rem] w-[1.2rem] text-[var(--status-error)] transition-opacity hover:opacity-80 focus-visible:outline-none'
|
||||||
|
aria-label='Stop generating'
|
||||||
|
>
|
||||||
|
<RiCloseCircleLine size={18} aria-hidden='true' />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={handleAbort}
|
||||||
|
className='inline-flex h-[1.2rem] items-center gap-0.5 rounded-md bg-[var(--status-error)]/70 px-1 text-[0.65rem] font-medium text-white hover:bg-[var(--status-error)]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--status-error)]/40'
|
||||||
|
aria-label='Stop generating'
|
||||||
|
>
|
||||||
|
<RiCloseCircleLine size={11} className='text-white' aria-hidden='true' />
|
||||||
|
Abort
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={dropZoneRef}
|
||||||
|
className={cn(
|
||||||
|
"chat-column relative overflow-visible",
|
||||||
|
isDragging && "ring-2 ring-primary ring-offset-2 rounded-xl"
|
||||||
|
)}
|
||||||
|
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
{isDragging && (
|
||||||
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-xl">
|
||||||
|
<div className="text-center">
|
||||||
|
<FileAttachmentButton />
|
||||||
|
<p className="mt-2 typography-ui-label text-muted-foreground">Drop files here to attach</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AttachedFilesList />
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border border-border/20 bg-input/10 dark:bg-input/30",
|
||||||
|
"flex flex-col relative overflow-visible"
|
||||||
|
)}
|
||||||
|
style={chatInputWrapperStyle}
|
||||||
|
>
|
||||||
|
{}
|
||||||
|
{showCommandAutocomplete && (
|
||||||
|
<CommandAutocomplete
|
||||||
|
ref={commandRef}
|
||||||
|
searchQuery={commandQuery}
|
||||||
|
onCommandSelect={handleCommandSelect}
|
||||||
|
onClose={() => setShowCommandAutocomplete(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{}
|
||||||
|
{showAgentAutocomplete && (
|
||||||
|
<AgentMentionAutocomplete
|
||||||
|
ref={agentRef}
|
||||||
|
searchQuery={agentQuery}
|
||||||
|
onAgentSelect={handleAgentSelect}
|
||||||
|
onClose={() => setShowAgentAutocomplete(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{}
|
||||||
|
{showFileMention && (
|
||||||
|
<FileMentionAutocomplete
|
||||||
|
ref={mentionRef}
|
||||||
|
searchQuery={mentionQuery}
|
||||||
|
onFileSelect={handleFileSelect}
|
||||||
|
onClose={() => setShowFileMention(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
data-chat-input="true"
|
||||||
|
value={message}
|
||||||
|
onChange={handleTextChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
placeholder={currentSessionId ? "# for agents; @ for files; / for commands" : "Select or create a session to start chatting"}
|
||||||
|
disabled={!currentSessionId}
|
||||||
|
|
||||||
|
className={cn(
|
||||||
|
'min-h-[52px] resize-none border-0 px-3 shadow-none rounded-t-xl rounded-b-none appearance-none focus:shadow-none focus-visible:shadow-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:ring-transparent hover:border-transparent bg-transparent',
|
||||||
|
isMobile ? "py-2.5" : "pt-4 pb-2",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-0"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
flex: 'none',
|
||||||
|
height: textareaSize ? `${textareaSize.height}px` : undefined,
|
||||||
|
maxHeight: textareaSize ? `${textareaSize.maxHeight}px` : undefined,
|
||||||
|
}}
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-b-xl bg-transparent',
|
||||||
|
footerPaddingClass,
|
||||||
|
isMobile ? 'flex items-center gap-x-1.5' : cn('flex items-center justify-between', footerGapClass)
|
||||||
|
)}
|
||||||
|
data-chat-input-footer="true"
|
||||||
|
>
|
||||||
|
{isMobile ? (
|
||||||
|
<div className="flex w-full items-center gap-x-1.5">
|
||||||
|
<div className="flex items-center flex-shrink-0 gap-x-1">
|
||||||
|
{attachmentsControls}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="flex items-center gap-x-1 min-w-0">
|
||||||
|
<ModelControls className={cn('flex items-center justify-end min-w-0')} />
|
||||||
|
{actionButton}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={cn("flex items-center flex-shrink-0", footerGapClass)}>
|
||||||
|
{attachmentsControls}
|
||||||
|
</div>
|
||||||
|
<div className={cn('flex items-center flex-1 justify-end', footerGapClass, 'md:gap-x-3')}>
|
||||||
|
<ModelControls className={cn('flex-1 min-w-0 justify-end')} />
|
||||||
|
{actionButton}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,750 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Message, Part } from '@opencode-ai/sdk';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
import { defaultCodeDark, defaultCodeLight } from '@/lib/codeTheme';
|
||||||
|
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
|
||||||
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
|
import { useMessageStore } from '@/stores/messageStore';
|
||||||
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
|
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||||
|
import MessageHeader from './message/MessageHeader';
|
||||||
|
import MessageBody from './message/MessageBody';
|
||||||
|
import type { AgentMentionInfo } from './message/types';
|
||||||
|
import type { StreamPhase, ToolPopupContent } from './message/types';
|
||||||
|
import { deriveMessageRole } from './message/messageRole';
|
||||||
|
import { filterVisibleParts } from './message/partUtils';
|
||||||
|
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
||||||
|
import type { TurnGroupingContext } from './hooks/useTurnGrouping';
|
||||||
|
|
||||||
|
const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog'));
|
||||||
|
|
||||||
|
function useStickyDisplayValue<T>(value: T | null | undefined): T | null | undefined {
|
||||||
|
const ref = React.useRef<{ hasValue: boolean; value: T | null | undefined }>({ hasValue: false, value: undefined as T | null | undefined });
|
||||||
|
|
||||||
|
if (!ref.current.hasValue && value !== undefined && value !== null) {
|
||||||
|
ref.current = { hasValue: true, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
return ref.current.hasValue ? ref.current.value : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMessageInfoProp = (info: unknown, key: string): unknown => {
|
||||||
|
if (typeof info === 'object' && info !== null) {
|
||||||
|
return (info as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ChatMessageProps {
|
||||||
|
message: {
|
||||||
|
info: Message;
|
||||||
|
parts: Part[];
|
||||||
|
};
|
||||||
|
previousMessage?: {
|
||||||
|
info: Message;
|
||||||
|
parts: Part[];
|
||||||
|
};
|
||||||
|
nextMessage?: {
|
||||||
|
info: Message;
|
||||||
|
parts: Part[];
|
||||||
|
};
|
||||||
|
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||||
|
animationHandlers?: AnimationHandlers;
|
||||||
|
scrollToBottom?: (options?: { instant?: boolean }) => void;
|
||||||
|
isPendingAnchor?: boolean;
|
||||||
|
turnGroupingContext?: TurnGroupingContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||||
|
message,
|
||||||
|
previousMessage,
|
||||||
|
nextMessage,
|
||||||
|
onContentChange,
|
||||||
|
animationHandlers,
|
||||||
|
isPendingAnchor = false,
|
||||||
|
turnGroupingContext,
|
||||||
|
}) => {
|
||||||
|
const { isMobile, hasTouchInput } = useDeviceInfo();
|
||||||
|
const { currentTheme } = useThemeSystem();
|
||||||
|
const messageContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const sessionState = useSessionStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
lifecyclePhase: state.messageStreamStates.get(message.info.id)?.phase ?? null,
|
||||||
|
isStreamingMessage: (() => {
|
||||||
|
const sessionId =
|
||||||
|
(message.info as { sessionID?: string }).sessionID ??
|
||||||
|
state.currentSessionId ??
|
||||||
|
null;
|
||||||
|
if (!sessionId) return false;
|
||||||
|
return (state.streamingMessageIds.get(sessionId) ?? null) === message.info.id;
|
||||||
|
})(),
|
||||||
|
currentSessionId: state.currentSessionId,
|
||||||
|
getCurrentAgent: state.getCurrentAgent,
|
||||||
|
getSessionAgentSelection: state.getSessionAgentSelection,
|
||||||
|
getAgentModelForSession: state.getAgentModelForSession,
|
||||||
|
getSessionModelSelection: state.getSessionModelSelection,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
lifecyclePhase,
|
||||||
|
isStreamingMessage,
|
||||||
|
currentSessionId,
|
||||||
|
getCurrentAgent,
|
||||||
|
getSessionAgentSelection,
|
||||||
|
getAgentModelForSession,
|
||||||
|
getSessionModelSelection,
|
||||||
|
} = sessionState;
|
||||||
|
|
||||||
|
const providers = useConfigStore((state) => state.providers);
|
||||||
|
const showReasoningTraces = useUIStore((state) => state.showReasoningTraces);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (currentSessionId) {
|
||||||
|
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
|
||||||
|
}
|
||||||
|
}, [currentSessionId]);
|
||||||
|
|
||||||
|
const [copiedCode, setCopiedCode] = React.useState<string | null>(null);
|
||||||
|
const [copiedMessage, setCopiedMessage] = React.useState(false);
|
||||||
|
const [expandedTools, setExpandedTools] = React.useState<Set<string>>(new Set());
|
||||||
|
const [popupContent, setPopupContent] = React.useState<ToolPopupContent>({
|
||||||
|
open: false,
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const messageRole = React.useMemo(() => deriveMessageRole(message.info), [message.info]);
|
||||||
|
const isUser = messageRole.isUser;
|
||||||
|
|
||||||
|
const normalizedParts = React.useMemo(() => {
|
||||||
|
if (!isUser) {
|
||||||
|
return message.parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
return message.parts.map((part) => {
|
||||||
|
const rawPart = part as Record<string, unknown>;
|
||||||
|
if (rawPart.type === 'compaction') {
|
||||||
|
return { type: 'text', text: '/summarize' } as Part;
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
}, [isUser, message.parts]);
|
||||||
|
|
||||||
|
const previousUserMetadata = React.useMemo(() => {
|
||||||
|
if (isUser || !previousMessage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientRole = getMessageInfoProp(previousMessage.info, 'clientRole');
|
||||||
|
const role = getMessageInfoProp(previousMessage.info, 'role');
|
||||||
|
const previousRole = typeof clientRole === 'string' ? clientRole : (typeof role === 'string' ? role : undefined);
|
||||||
|
if (previousRole !== 'user') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = getMessageInfoProp(previousMessage.info, 'mode');
|
||||||
|
const providerID = getMessageInfoProp(previousMessage.info, 'providerID');
|
||||||
|
const modelID = getMessageInfoProp(previousMessage.info, 'modelID');
|
||||||
|
const resolvedAgent = typeof mode === 'string' && mode.trim().length > 0 ? mode : undefined;
|
||||||
|
const resolvedProvider = typeof providerID === 'string' && providerID.trim().length > 0 ? providerID : undefined;
|
||||||
|
const resolvedModel = typeof modelID === 'string' && modelID.trim().length > 0 ? modelID : undefined;
|
||||||
|
|
||||||
|
if (!resolvedAgent && !resolvedProvider && !resolvedModel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
agentName: resolvedAgent,
|
||||||
|
providerId: resolvedProvider,
|
||||||
|
modelId: resolvedModel,
|
||||||
|
};
|
||||||
|
}, [isUser, previousMessage]);
|
||||||
|
|
||||||
|
const agentName = React.useMemo(() => {
|
||||||
|
if (isUser) return undefined;
|
||||||
|
|
||||||
|
const messageMode = getMessageInfoProp(message.info, 'mode');
|
||||||
|
if (typeof messageMode === 'string' && messageMode.trim().length > 0) {
|
||||||
|
return messageMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousUserMetadata?.agentName) {
|
||||||
|
return previousUserMetadata.agentName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = message.info.sessionID;
|
||||||
|
if (!sessionId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentContextAgent = getCurrentAgent(sessionId);
|
||||||
|
if (currentContextAgent) {
|
||||||
|
return currentContextAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedSelection = getSessionAgentSelection(sessionId);
|
||||||
|
return savedSelection ?? undefined;
|
||||||
|
}, [isUser, message.info, previousUserMetadata, getCurrentAgent, getSessionAgentSelection]);
|
||||||
|
|
||||||
|
const sessionId = message.info.sessionID;
|
||||||
|
const messageProviderID = !isUser ? getMessageInfoProp(message.info, 'providerID') : null;
|
||||||
|
const messageModelID = !isUser ? getMessageInfoProp(message.info, 'modelID') : null;
|
||||||
|
|
||||||
|
const contextModelSelection = React.useMemo(() => {
|
||||||
|
if (isUser || !sessionId) return null;
|
||||||
|
|
||||||
|
if (previousUserMetadata?.providerId && previousUserMetadata?.modelId) {
|
||||||
|
return {
|
||||||
|
providerId: previousUserMetadata.providerId,
|
||||||
|
modelId: previousUserMetadata.modelId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agentName) {
|
||||||
|
const agentSelection = getAgentModelForSession(sessionId, agentName);
|
||||||
|
if (agentSelection?.providerId && agentSelection?.modelId) {
|
||||||
|
return agentSelection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionSelection = getSessionModelSelection(sessionId);
|
||||||
|
if (sessionSelection?.providerId && sessionSelection?.modelId) {
|
||||||
|
return sessionSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [isUser, sessionId, agentName, previousUserMetadata, getAgentModelForSession, getSessionModelSelection]);
|
||||||
|
|
||||||
|
const providerID = React.useMemo(() => {
|
||||||
|
if (isUser) return null;
|
||||||
|
if (typeof messageProviderID === 'string' && messageProviderID.trim().length > 0) {
|
||||||
|
return messageProviderID;
|
||||||
|
}
|
||||||
|
return contextModelSelection?.providerId ?? null;
|
||||||
|
}, [isUser, messageProviderID, contextModelSelection]);
|
||||||
|
|
||||||
|
const modelID = React.useMemo(() => {
|
||||||
|
if (isUser) return null;
|
||||||
|
if (typeof messageModelID === 'string' && messageModelID.trim().length > 0) {
|
||||||
|
return messageModelID;
|
||||||
|
}
|
||||||
|
return contextModelSelection?.modelId ?? null;
|
||||||
|
}, [isUser, messageModelID, contextModelSelection]);
|
||||||
|
|
||||||
|
const modelName = React.useMemo(() => {
|
||||||
|
if (isUser) return undefined;
|
||||||
|
|
||||||
|
if (providerID && modelID && providers.length > 0) {
|
||||||
|
const provider = providers.find((p) => p.id === providerID);
|
||||||
|
if (provider?.models && Array.isArray(provider.models)) {
|
||||||
|
const model = provider.models.find((m: Record<string, unknown>) => (m as Record<string, unknown>).id === modelID);
|
||||||
|
const modelObj = model as Record<string, unknown> | undefined;
|
||||||
|
const name = modelObj?.name;
|
||||||
|
return typeof name === 'string' ? name : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}, [isUser, providerID, modelID, providers]);
|
||||||
|
|
||||||
|
const displayAgentName = useStickyDisplayValue<string>(agentName);
|
||||||
|
const displayProviderIDValue = useStickyDisplayValue<string>(providerID ?? undefined);
|
||||||
|
const displayModelName = useStickyDisplayValue<string>(modelName);
|
||||||
|
|
||||||
|
const headerAgentName = displayAgentName ?? undefined;
|
||||||
|
const headerProviderID = displayProviderIDValue ?? null;
|
||||||
|
const headerModelName = displayModelName ?? undefined;
|
||||||
|
|
||||||
|
const messageCompletedAt = React.useMemo(() => {
|
||||||
|
const timeInfo = message.info.time as { completed?: number } | undefined;
|
||||||
|
return typeof timeInfo?.completed === 'number' ? timeInfo.completed : null;
|
||||||
|
}, [message.info.time]);
|
||||||
|
|
||||||
|
const isMessageCompleted = React.useMemo(() => {
|
||||||
|
if (isUser) return true;
|
||||||
|
return Boolean(messageCompletedAt && messageCompletedAt > 0);
|
||||||
|
}, [isUser, messageCompletedAt]);
|
||||||
|
|
||||||
|
const visibleParts = React.useMemo(
|
||||||
|
() =>
|
||||||
|
filterVisibleParts(normalizedParts, {
|
||||||
|
includeReasoning: showReasoningTraces,
|
||||||
|
}),
|
||||||
|
[normalizedParts, showReasoningTraces]
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayParts = React.useMemo(() => {
|
||||||
|
if (isUser) {
|
||||||
|
return visibleParts;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isMessageCompleted ? visibleParts : [];
|
||||||
|
}, [isUser, isMessageCompleted, visibleParts]);
|
||||||
|
|
||||||
|
const assistantTextParts = React.useMemo(() => {
|
||||||
|
if (isUser) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return visibleParts.filter((part) => part.type === 'text');
|
||||||
|
}, [isUser, visibleParts]);
|
||||||
|
|
||||||
|
const toolParts = React.useMemo(() => {
|
||||||
|
if (isUser) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return visibleParts.filter((part) => part.type === 'tool');
|
||||||
|
}, [isUser, visibleParts]);
|
||||||
|
|
||||||
|
const agentMention = React.useMemo(() => {
|
||||||
|
if (!isUser) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const mentionPart = message.parts.find((part) => part.type === 'agent');
|
||||||
|
if (!mentionPart) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const partWithName = mentionPart as { name?: string; source?: { value?: string } };
|
||||||
|
const name = typeof partWithName.name === 'string' ? partWithName.name : undefined;
|
||||||
|
if (!name) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const rawValue = partWithName.source && typeof partWithName.source.value === 'string' && partWithName.source.value.trim().length > 0
|
||||||
|
? partWithName.source.value
|
||||||
|
: `#${name}`;
|
||||||
|
return { name, token: rawValue } satisfies AgentMentionInfo;
|
||||||
|
}, [isUser, message.parts]);
|
||||||
|
|
||||||
|
const stepState = React.useMemo(() => {
|
||||||
|
|
||||||
|
let stepStarts = 0;
|
||||||
|
let stepFinishes = 0;
|
||||||
|
visibleParts.forEach((part) => {
|
||||||
|
if (part.type === 'step-start') {
|
||||||
|
stepStarts += 1;
|
||||||
|
} else if (part.type === 'step-finish') {
|
||||||
|
stepFinishes += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
hasOpenStep: stepStarts > stepFinishes,
|
||||||
|
};
|
||||||
|
}, [visibleParts]);
|
||||||
|
|
||||||
|
const hasOpenStep = stepState.hasOpenStep;
|
||||||
|
|
||||||
|
const shouldCoordinateRendering = React.useMemo(() => {
|
||||||
|
if (isUser) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (assistantTextParts.length === 0 || toolParts.length === 0) {
|
||||||
|
return hasOpenStep;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, [assistantTextParts.length, toolParts.length, hasOpenStep, isUser]);
|
||||||
|
|
||||||
|
const themeVariant = currentTheme?.metadata.variant;
|
||||||
|
const isDarkTheme = React.useMemo(() => {
|
||||||
|
if (themeVariant) {
|
||||||
|
return themeVariant === 'dark';
|
||||||
|
}
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
return document.documentElement.classList.contains('dark');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [themeVariant]);
|
||||||
|
|
||||||
|
const syntaxTheme = React.useMemo(() => {
|
||||||
|
if (currentTheme) {
|
||||||
|
return generateSyntaxTheme(currentTheme);
|
||||||
|
}
|
||||||
|
return isDarkTheme ? defaultCodeDark : defaultCodeLight;
|
||||||
|
}, [currentTheme, isDarkTheme]);
|
||||||
|
|
||||||
|
const shouldAnimateMessage = React.useMemo(() => {
|
||||||
|
if (isUser) return false;
|
||||||
|
const freshnessDetector = MessageFreshnessDetector.getInstance();
|
||||||
|
return freshnessDetector.shouldAnimateMessage(message.info, currentSessionId || message.info.sessionID);
|
||||||
|
}, [message.info, currentSessionId, isUser]);
|
||||||
|
|
||||||
|
const previousRole = React.useMemo(() => {
|
||||||
|
if (!previousMessage) return null;
|
||||||
|
return deriveMessageRole(previousMessage.info);
|
||||||
|
}, [previousMessage]);
|
||||||
|
|
||||||
|
const nextRole = React.useMemo(() => {
|
||||||
|
if (!nextMessage) return null;
|
||||||
|
return deriveMessageRole(nextMessage.info);
|
||||||
|
}, [nextMessage]);
|
||||||
|
|
||||||
|
const shouldShowHeader = React.useMemo(() => {
|
||||||
|
if (isUser) return true;
|
||||||
|
if (!previousRole) return true;
|
||||||
|
return previousRole.isUser;
|
||||||
|
}, [isUser, previousRole]);
|
||||||
|
|
||||||
|
const isFollowedByAssistant = React.useMemo(() => {
|
||||||
|
if (isUser) return false;
|
||||||
|
if (!nextRole) return false;
|
||||||
|
return !nextRole.isUser && nextRole.role === 'assistant';
|
||||||
|
}, [isUser, nextRole]);
|
||||||
|
|
||||||
|
const streamPhase: StreamPhase = React.useMemo(() => {
|
||||||
|
if (isMessageCompleted) {
|
||||||
|
return 'completed';
|
||||||
|
}
|
||||||
|
if (lifecyclePhase) {
|
||||||
|
return lifecyclePhase;
|
||||||
|
}
|
||||||
|
return isStreamingMessage ? 'streaming' : 'completed';
|
||||||
|
}, [isMessageCompleted, lifecyclePhase, isStreamingMessage]);
|
||||||
|
|
||||||
|
const handleCopyCode = React.useCallback((code: string) => {
|
||||||
|
navigator.clipboard.writeText(code);
|
||||||
|
setCopiedCode(code);
|
||||||
|
setTimeout(() => setCopiedCode(null), 2000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const userMessageIdForTurn = turnGroupingContext?.turnId;
|
||||||
|
const assistantSummaryFromStore = useMessageStore((state) => {
|
||||||
|
if (!userMessageIdForTurn) return undefined;
|
||||||
|
const sessionId = message.info.sessionID;
|
||||||
|
if (!sessionId) return undefined;
|
||||||
|
const sessionMessages = state.messages.get(sessionId);
|
||||||
|
if (!sessionMessages) return undefined;
|
||||||
|
const userMsg = sessionMessages.find((entry) => entry.info?.id === userMessageIdForTurn);
|
||||||
|
if (!userMsg) return undefined;
|
||||||
|
const summary = (userMsg.info as { summary?: { body?: string | null | undefined } | null | undefined }).summary;
|
||||||
|
const body = summary?.body;
|
||||||
|
return typeof body === 'string' && body.trim().length > 0 ? body : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistantSummaryCandidate =
|
||||||
|
typeof turnGroupingContext?.summaryBody === 'string' && turnGroupingContext.summaryBody.trim().length > 0
|
||||||
|
? turnGroupingContext.summaryBody
|
||||||
|
: assistantSummaryFromStore;
|
||||||
|
|
||||||
|
const assistantSummaryRef = React.useRef<string | undefined>(undefined);
|
||||||
|
if (assistantSummaryCandidate && assistantSummaryCandidate.trim().length > 0) {
|
||||||
|
assistantSummaryRef.current = assistantSummaryCandidate;
|
||||||
|
}
|
||||||
|
const prevUserMessageIdForCopy = React.useRef(userMessageIdForTurn);
|
||||||
|
if (prevUserMessageIdForCopy.current !== userMessageIdForTurn) {
|
||||||
|
prevUserMessageIdForCopy.current = userMessageIdForTurn;
|
||||||
|
assistantSummaryRef.current = undefined;
|
||||||
|
}
|
||||||
|
const assistantSummaryForCopy = assistantSummaryRef.current;
|
||||||
|
|
||||||
|
const messageTextContent = React.useMemo(() => {
|
||||||
|
if (isUser) {
|
||||||
|
const textParts = displayParts
|
||||||
|
.filter((part): part is Part & { type: 'text'; text?: string; content?: string } => part.type === 'text')
|
||||||
|
.map((part) => {
|
||||||
|
const text = part.text || part.content || '';
|
||||||
|
return text.trim();
|
||||||
|
})
|
||||||
|
.filter((text) => text.length > 0);
|
||||||
|
|
||||||
|
const combined = textParts.join('\n');
|
||||||
|
return combined.replace(/\n\s*\n+/g, '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assistantSummaryForCopy && assistantSummaryForCopy.trim().length > 0) {
|
||||||
|
return assistantSummaryForCopy;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textParts = displayParts
|
||||||
|
.filter((part): part is Part & { type: 'text'; text?: string; content?: string } => part.type === 'text')
|
||||||
|
.map((part) => {
|
||||||
|
const text = part.text || part.content || '';
|
||||||
|
return text.trim();
|
||||||
|
})
|
||||||
|
.filter((text) => text.length > 0);
|
||||||
|
|
||||||
|
const combined = textParts.join('\n');
|
||||||
|
return combined.replace(/\n\s*\n+/g, '\n');
|
||||||
|
}, [assistantSummaryForCopy, displayParts, isUser]);
|
||||||
|
|
||||||
|
const hasTextContent = messageTextContent.length > 0;
|
||||||
|
|
||||||
|
const handleCopyMessage = React.useCallback(() => {
|
||||||
|
navigator.clipboard.writeText(messageTextContent);
|
||||||
|
setCopiedMessage(true);
|
||||||
|
setTimeout(() => setCopiedMessage(false), 2000);
|
||||||
|
}, [messageTextContent]);
|
||||||
|
|
||||||
|
const handleToggleTool = React.useCallback((toolId: string) => {
|
||||||
|
setExpandedTools((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(toolId)) {
|
||||||
|
next.delete(toolId);
|
||||||
|
} else {
|
||||||
|
next.add(toolId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resolvedAnimationHandlers = animationHandlers ?? null;
|
||||||
|
const hasAnnouncedAuxiliaryScrollRef = React.useRef(false);
|
||||||
|
|
||||||
|
const animationCompletedRef = React.useRef(false);
|
||||||
|
const hasRequestedReservationRef = React.useRef(false);
|
||||||
|
const animationStartNotifiedRef = React.useRef(false);
|
||||||
|
const hasTriggeredReservationOnceRef = React.useRef(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
animationCompletedRef.current = false;
|
||||||
|
hasRequestedReservationRef.current = false;
|
||||||
|
animationStartNotifiedRef.current = false;
|
||||||
|
hasTriggeredReservationOnceRef.current = false;
|
||||||
|
hasAnnouncedAuxiliaryScrollRef.current = false;
|
||||||
|
}, [message.info.id]);
|
||||||
|
|
||||||
|
const handleAuxiliaryContentComplete = React.useCallback(() => {
|
||||||
|
if (isUser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasAnnouncedAuxiliaryScrollRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hasAnnouncedAuxiliaryScrollRef.current = true;
|
||||||
|
onContentChange?.('structural');
|
||||||
|
}, [isUser, onContentChange]);
|
||||||
|
|
||||||
|
const handleShowPopup = React.useCallback((content: ToolPopupContent) => {
|
||||||
|
|
||||||
|
if (content.image) {
|
||||||
|
setPopupContent(content);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePopupChange = React.useCallback((open: boolean) => {
|
||||||
|
setPopupContent((prev) => ({ ...prev, open }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isAnimationSettled = Boolean(getMessageInfoProp(message.info, 'animationSettled'));
|
||||||
|
const isStreamingPhase = streamPhase === 'streaming';
|
||||||
|
|
||||||
|
const hasReasoningParts = React.useMemo(() => {
|
||||||
|
if (isUser) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return visibleParts.some((part) => part.type === 'reasoning');
|
||||||
|
}, [isUser, visibleParts]);
|
||||||
|
|
||||||
|
const allowAnimation = shouldAnimateMessage && !isAnimationSettled && !isStreamingPhase;
|
||||||
|
const shouldReserveAnimationSpace = !isUser && shouldAnimateMessage && assistantTextParts.length > 0 && !shouldCoordinateRendering;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!resolvedAnimationHandlers?.onStreamingCandidate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldReserveAnimationSpace) {
|
||||||
|
if (hasRequestedReservationRef.current) {
|
||||||
|
if (hasReasoningParts && resolvedAnimationHandlers?.onReasoningBlock) {
|
||||||
|
resolvedAnimationHandlers.onReasoningBlock();
|
||||||
|
} else if (resolvedAnimationHandlers?.onReservationCancelled) {
|
||||||
|
resolvedAnimationHandlers.onReservationCancelled();
|
||||||
|
}
|
||||||
|
hasRequestedReservationRef.current = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasTriggeredReservationOnceRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasTriggeredReservationOnceRef.current = true;
|
||||||
|
resolvedAnimationHandlers.onStreamingCandidate();
|
||||||
|
hasRequestedReservationRef.current = true;
|
||||||
|
}, [resolvedAnimationHandlers, shouldReserveAnimationSpace, hasReasoningParts]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!resolvedAnimationHandlers?.onAnimationStart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!allowAnimation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (animationStartNotifiedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolvedAnimationHandlers.onAnimationStart();
|
||||||
|
animationStartNotifiedRef.current = true;
|
||||||
|
}, [resolvedAnimationHandlers, allowAnimation]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isUser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = resolvedAnimationHandlers?.onAnimatedHeightChange;
|
||||||
|
if (!handler) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldTrackHeight = allowAnimation || shouldReserveAnimationSpace;
|
||||||
|
if (!shouldTrackHeight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = messageContainerRef.current;
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window === 'undefined' || typeof ResizeObserver === 'undefined') {
|
||||||
|
handler(element.getBoundingClientRect().height);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rafId: number | null = null;
|
||||||
|
const notifyHeight = (height: number) => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
handler(height);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rafId !== null) {
|
||||||
|
window.cancelAnimationFrame(rafId);
|
||||||
|
}
|
||||||
|
rafId = window.requestAnimationFrame(() => {
|
||||||
|
handler(height);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
const entry = entries[0];
|
||||||
|
if (!entry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifyHeight(entry.contentRect.height);
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(element);
|
||||||
|
notifyHeight(element.getBoundingClientRect().height);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (rafId !== null) {
|
||||||
|
window.cancelAnimationFrame(rafId);
|
||||||
|
rafId = null;
|
||||||
|
}
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [allowAnimation, isUser, resolvedAnimationHandlers, shouldReserveAnimationSpace]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group w-full',
|
||||||
|
shouldShowHeader ? 'pt-2' : 'pt-0',
|
||||||
|
isUser ? 'pb-2' : isFollowedByAssistant ? 'pb-0' : 'pb-2'
|
||||||
|
)}
|
||||||
|
data-message-id={message.info.id}
|
||||||
|
ref={messageContainerRef}
|
||||||
|
style={isPendingAnchor ? { visibility: 'hidden' } : undefined}
|
||||||
|
>
|
||||||
|
<div className="chat-column">
|
||||||
|
{isUser ? (
|
||||||
|
<FadeInOnReveal>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border bg-input/10 dark:bg-input/30 pt-[0.7rem] pb-[0.45rem] relative'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
borderColor: 'color-mix(in srgb, var(--primary-muted, var(--primary)) 40%, var(--interactive-border, transparent))'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MessageBody
|
||||||
|
messageId={message.info.id}
|
||||||
|
parts={visibleParts}
|
||||||
|
isUser={isUser}
|
||||||
|
isMessageCompleted={isMessageCompleted}
|
||||||
|
syntaxTheme={syntaxTheme}
|
||||||
|
isMobile={isMobile}
|
||||||
|
hasTouchInput={hasTouchInput}
|
||||||
|
copiedCode={copiedCode}
|
||||||
|
onCopyCode={handleCopyCode}
|
||||||
|
expandedTools={expandedTools}
|
||||||
|
onToggleTool={handleToggleTool}
|
||||||
|
onShowPopup={handleShowPopup}
|
||||||
|
streamPhase={streamPhase}
|
||||||
|
allowAnimation={allowAnimation}
|
||||||
|
onContentChange={onContentChange}
|
||||||
|
shouldShowHeader={false}
|
||||||
|
hasTextContent={hasTextContent}
|
||||||
|
onCopyMessage={handleCopyMessage}
|
||||||
|
copiedMessage={copiedMessage}
|
||||||
|
showReasoningTraces={showReasoningTraces}
|
||||||
|
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
|
||||||
|
agentMention={agentMention}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</FadeInOnReveal>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{shouldShowHeader && (
|
||||||
|
<MessageHeader
|
||||||
|
isUser={isUser}
|
||||||
|
providerID={headerProviderID}
|
||||||
|
agentName={headerAgentName}
|
||||||
|
modelName={headerModelName}
|
||||||
|
isDarkTheme={isDarkTheme}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MessageBody
|
||||||
|
messageId={message.info.id}
|
||||||
|
parts={visibleParts}
|
||||||
|
isUser={isUser}
|
||||||
|
isMessageCompleted={isMessageCompleted}
|
||||||
|
syntaxTheme={syntaxTheme}
|
||||||
|
isMobile={isMobile}
|
||||||
|
hasTouchInput={hasTouchInput}
|
||||||
|
copiedCode={copiedCode}
|
||||||
|
onCopyCode={handleCopyCode}
|
||||||
|
expandedTools={expandedTools}
|
||||||
|
onToggleTool={handleToggleTool}
|
||||||
|
onShowPopup={handleShowPopup}
|
||||||
|
streamPhase={streamPhase}
|
||||||
|
allowAnimation={allowAnimation}
|
||||||
|
onContentChange={onContentChange}
|
||||||
|
shouldShowHeader={shouldShowHeader}
|
||||||
|
hasTextContent={hasTextContent}
|
||||||
|
onCopyMessage={handleCopyMessage}
|
||||||
|
copiedMessage={copiedMessage}
|
||||||
|
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
|
||||||
|
showReasoningTraces={showReasoningTraces}
|
||||||
|
agentMention={agentMention}
|
||||||
|
turnGroupingContext={turnGroupingContext}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<React.Suspense fallback={null}>
|
||||||
|
<ToolOutputDialog
|
||||||
|
popup={popupContent}
|
||||||
|
onOpenChange={handlePopupChange}
|
||||||
|
syntaxTheme={syntaxTheme}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
</React.Suspense>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(ChatMessage);
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { RiCommandLine, RiFileLine, RiFlashlightLine, RiRefreshLine, RiScissorsLine, RiTerminalBoxLine } from '@remixicon/react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
|
|
||||||
|
interface CommandInfo {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
agent?: string;
|
||||||
|
model?: string;
|
||||||
|
isBuiltIn?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandAutocompleteHandle {
|
||||||
|
handleKeyDown: (key: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandAutocompleteProps {
|
||||||
|
searchQuery: string;
|
||||||
|
onCommandSelect: (command: CommandInfo) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, CommandAutocompleteProps>(({
|
||||||
|
searchQuery,
|
||||||
|
onCommandSelect,
|
||||||
|
onClose
|
||||||
|
}, ref) => {
|
||||||
|
const { hasMessagesInCurrentSession } = useSessionStore(
|
||||||
|
useShallow((state) => {
|
||||||
|
const sessionId = state.currentSessionId;
|
||||||
|
const messageCount = sessionId ? (state.messages.get(sessionId)?.length ?? 0) : 0;
|
||||||
|
return {
|
||||||
|
hasMessagesInCurrentSession: messageCount > 0,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const [commands, setCommands] = React.useState<CommandInfo[]>([]);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||||
|
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
||||||
|
const target = event.target as Node | null;
|
||||||
|
if (!target || !containerRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (containerRef.current.contains(target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const loadCommands = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
|
||||||
|
const apiCommands = await opencodeClient.listCommands();
|
||||||
|
|
||||||
|
const customCommands: CommandInfo[] = apiCommands.map(cmd => ({
|
||||||
|
name: cmd.name,
|
||||||
|
description: cmd.description,
|
||||||
|
agent: cmd.agent,
|
||||||
|
model: cmd.model,
|
||||||
|
isBuiltIn: false
|
||||||
|
}));
|
||||||
|
|
||||||
|
const builtInCommands: CommandInfo[] = [
|
||||||
|
...(hasMessagesInCurrentSession
|
||||||
|
? []
|
||||||
|
: [{ name: 'init', description: 'Create/update AGENTS.md file', isBuiltIn: true }]),
|
||||||
|
{ name: 'summarize', description: 'Generate a summary of the current session', isBuiltIn: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const commandMap = new Map<string, CommandInfo>();
|
||||||
|
|
||||||
|
builtInCommands.forEach(cmd => commandMap.set(cmd.name, cmd));
|
||||||
|
|
||||||
|
customCommands.forEach(cmd => commandMap.set(cmd.name, cmd));
|
||||||
|
|
||||||
|
const allCommands = Array.from(commandMap.values());
|
||||||
|
|
||||||
|
const allowInitCommand = !hasMessagesInCurrentSession;
|
||||||
|
const filtered = (searchQuery
|
||||||
|
? allCommands.filter(cmd =>
|
||||||
|
cmd.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
(cmd.description && cmd.description.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||||
|
)
|
||||||
|
: allCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
|
||||||
|
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const aStartsWith = a.name.toLowerCase().startsWith(searchQuery.toLowerCase());
|
||||||
|
const bStartsWith = b.name.toLowerCase().startsWith(searchQuery.toLowerCase());
|
||||||
|
if (aStartsWith && !bStartsWith) return -1;
|
||||||
|
if (!aStartsWith && bStartsWith) return 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
setCommands(filtered);
|
||||||
|
} catch {
|
||||||
|
|
||||||
|
const allowInitCommand = !hasMessagesInCurrentSession;
|
||||||
|
const builtInCommands: CommandInfo[] = [
|
||||||
|
...(hasMessagesInCurrentSession
|
||||||
|
? []
|
||||||
|
: [{ name: 'init', description: 'Create/update AGENTS.md file', isBuiltIn: true }]),
|
||||||
|
{ name: 'summarize', description: 'Generate a summary of the current session', isBuiltIn: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const filtered = (searchQuery
|
||||||
|
? builtInCommands.filter(cmd =>
|
||||||
|
cmd.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
(cmd.description && cmd.description.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||||
|
)
|
||||||
|
: builtInCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
|
||||||
|
|
||||||
|
setCommands(filtered);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCommands();
|
||||||
|
}, [searchQuery, hasMessagesInCurrentSession]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}, [commands]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
itemRefs.current[selectedIndex]?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'nearest'
|
||||||
|
});
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
React.useImperativeHandle(ref, () => ({
|
||||||
|
handleKeyDown: (key: string) => {
|
||||||
|
const total = commands.length;
|
||||||
|
if (key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'ArrowDown') {
|
||||||
|
setSelectedIndex((prev) => (prev + 1) % total);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'ArrowUp') {
|
||||||
|
setSelectedIndex((prev) => (prev - 1 + total) % total);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'Enter' || key === 'Tab') {
|
||||||
|
const safeIndex = ((selectedIndex % total) + total) % total;
|
||||||
|
const command = commands[safeIndex];
|
||||||
|
if (command) {
|
||||||
|
onCommandSelect(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}), [commands, selectedIndex, onClose, onCommandSelect]);
|
||||||
|
|
||||||
|
const getCommandIcon = (command: CommandInfo) => {
|
||||||
|
|
||||||
|
switch (command.name) {
|
||||||
|
case 'init':
|
||||||
|
return <RiFileLine className="h-3.5 w-3.5 text-green-500" />;
|
||||||
|
case 'summarize':
|
||||||
|
return <RiScissorsLine className="h-3.5 w-3.5 text-purple-500" />;
|
||||||
|
case 'test':
|
||||||
|
case 'build':
|
||||||
|
case 'run':
|
||||||
|
return <RiTerminalBoxLine className="h-3.5 w-3.5 text-cyan-500" />;
|
||||||
|
default:
|
||||||
|
if (command.isBuiltIn) {
|
||||||
|
return <RiFlashlightLine className="h-3.5 w-3.5 text-yellow-500" />;
|
||||||
|
}
|
||||||
|
return <RiCommandLine className="h-3.5 w-3.5 text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="absolute z-[100] min-w-[250px] max-w-[450px] max-h-64 bg-popover border border-border rounded-xl shadow-none bottom-full mb-2 left-0 w-max flex flex-col"
|
||||||
|
>
|
||||||
|
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<RiRefreshLine className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{commands.map((command, index) => (
|
||||||
|
<div
|
||||||
|
key={command.name}
|
||||||
|
ref={(el) => { itemRefs.current[index] = el; }}
|
||||||
|
className={cn(
|
||||||
|
"flex items-start gap-2 px-3 py-2 cursor-pointer rounded-lg",
|
||||||
|
index === selectedIndex && "bg-accent"
|
||||||
|
)}
|
||||||
|
onClick={() => onCommandSelect(command)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
>
|
||||||
|
<div className="mt-0.5">
|
||||||
|
{getCommandIcon(command)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="typography-ui-label font-medium">/{command.name}</span>
|
||||||
|
{command.agent && (
|
||||||
|
<span className="typography-meta text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||||
|
{command.agent}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{command.description && (
|
||||||
|
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
|
||||||
|
{command.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{commands.length === 0 && (
|
||||||
|
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
||||||
|
No commands found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollableOverlay>
|
||||||
|
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
|
||||||
|
↑↓ navigate • Enter select • Esc close
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
CommandAutocomplete.displayName = 'CommandAutocomplete';
|
||||||
|
|
||||||
|
export type { CommandInfo };
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import React, { useRef, memo } from 'react';
|
||||||
|
import { RiAttachment2, RiCloseLine, RiComputerLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiHardDrive3Line } from '@remixicon/react';
|
||||||
|
import { useSessionStore, type AttachedFile } from '@/stores/useSessionStore';
|
||||||
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||||
|
import type { ToolPopupContent } from './message/types';
|
||||||
|
|
||||||
|
export const FileAttachmentButton = memo(() => {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const { addAttachedFile } = useSessionStore();
|
||||||
|
const { isMobile } = useUIStore();
|
||||||
|
const buttonSizeClass = isMobile ? 'h-9 w-9' : 'h-7 w-7';
|
||||||
|
const iconSizeClass = isMobile ? 'h-5 w-5' : 'h-[18px] w-[18px]';
|
||||||
|
|
||||||
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files) return;
|
||||||
|
|
||||||
|
let attachedCount = 0;
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const sizeBefore = useSessionStore.getState().attachedFiles.length;
|
||||||
|
try {
|
||||||
|
await addAttachedFile(files[i]);
|
||||||
|
const sizeAfter = useSessionStore.getState().attachedFiles.length;
|
||||||
|
if (sizeAfter > sizeBefore) {
|
||||||
|
attachedCount++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('File attach failed', error);
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to attach file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachedCount > 0) {
|
||||||
|
toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
accept="*/*"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className={cn(
|
||||||
|
buttonSizeClass,
|
||||||
|
'flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0'
|
||||||
|
)}
|
||||||
|
title='Attach files'
|
||||||
|
>
|
||||||
|
<RiAttachment2 className={cn(iconSizeClass, 'text-current')} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface FileChipProps {
|
||||||
|
file: AttachedFile;
|
||||||
|
onRemove: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileChip = memo(({ file, onRemove }: FileChipProps) => {
|
||||||
|
const getFileIcon = () => {
|
||||||
|
if (file.mimeType.startsWith('image/')) {
|
||||||
|
return <RiFileImageLine className="h-3.5 w-3.5" />;
|
||||||
|
}
|
||||||
|
if (file.mimeType.includes('text') || file.mimeType.includes('code')) {
|
||||||
|
return <RiFileLine className="h-3.5 w-3.5" />;
|
||||||
|
}
|
||||||
|
if (file.mimeType.includes('json') || file.mimeType.includes('xml')) {
|
||||||
|
return <RiFilePdfLine className="h-3.5 w-3.5" />;
|
||||||
|
}
|
||||||
|
return <RiFileLine className="h-3.5 w-3.5" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractFilename = (path: string): string => {
|
||||||
|
|
||||||
|
const normalized = path.replace(/\\/g, '/');
|
||||||
|
|
||||||
|
const parts = normalized.split('/');
|
||||||
|
const filename = parts[parts.length - 1];
|
||||||
|
|
||||||
|
return filename || path;
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayName = extractFilename(file.filename);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-muted/30 border border-border/30 rounded-xl typography-meta">
|
||||||
|
{}
|
||||||
|
<div title={file.source === 'server' ? "Server file" : "Local file"}>
|
||||||
|
{file.source === 'server' ? (
|
||||||
|
<RiHardDrive3Line className="h-3 w-3 text-primary" />
|
||||||
|
) : (
|
||||||
|
<RiComputerLine className="h-3 w-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{getFileIcon()}
|
||||||
|
<span title={file.serverPath || displayName}>
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground flex-shrink-0">
|
||||||
|
({formatFileSize(file.size)})
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={onRemove}
|
||||||
|
className="ml-1 hover:text-destructive p-0.5"
|
||||||
|
title="Remove file"
|
||||||
|
>
|
||||||
|
<RiCloseLine className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AttachedFilesList = memo(() => {
|
||||||
|
const { attachedFiles, removeAttachedFile } = useSessionStore();
|
||||||
|
|
||||||
|
if (attachedFiles.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-2">
|
||||||
|
<div className="flex items-center flex-wrap gap-2 px-3 py-2 bg-muted/30 rounded-xl border border-border/30">
|
||||||
|
<span className="typography-meta text-muted-foreground font-medium">Attached:</span>
|
||||||
|
{attachedFiles.map((file) => (
|
||||||
|
<FileChip
|
||||||
|
key={file.id}
|
||||||
|
file={file}
|
||||||
|
onRemove={() => removeAttachedFile(file.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface FilePart {
|
||||||
|
type: string;
|
||||||
|
mime?: string;
|
||||||
|
url?: string;
|
||||||
|
filename?: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageFilesDisplayProps {
|
||||||
|
files: FilePart[];
|
||||||
|
onShowPopup?: (content: ToolPopupContent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MessageFilesDisplay = memo(({ files, onShowPopup }: MessageFilesDisplayProps) => {
|
||||||
|
|
||||||
|
const fileItems = files.filter(f => f.type === 'file' && (f.mime || f.url));
|
||||||
|
|
||||||
|
const extractFilename = (path?: string): string => {
|
||||||
|
if (!path) return 'Unnamed file';
|
||||||
|
|
||||||
|
const normalized = path.replace(/\\/g, '/');
|
||||||
|
const parts = normalized.split('/');
|
||||||
|
return parts[parts.length - 1] || path;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileIcon = (mimeType?: string) => {
|
||||||
|
if (!mimeType) return <RiFileLine className="h-3.5 w-3.5" />;
|
||||||
|
|
||||||
|
if (mimeType.startsWith('image/')) {
|
||||||
|
return <RiFileImageLine className="h-3.5 w-3.5" />;
|
||||||
|
}
|
||||||
|
if (mimeType.includes('text') || mimeType.includes('code')) {
|
||||||
|
return <RiFileLine className="h-3.5 w-3.5" />;
|
||||||
|
}
|
||||||
|
if (mimeType.includes('json') || mimeType.includes('xml')) {
|
||||||
|
return <RiFilePdfLine className="h-3.5 w-3.5" />;
|
||||||
|
}
|
||||||
|
return <RiFileLine className="h-3.5 w-3.5" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageFiles = fileItems.filter(f => f.mime?.startsWith('image/') && f.url);
|
||||||
|
const otherFiles = fileItems.filter(f => !f.mime?.startsWith('image/'));
|
||||||
|
|
||||||
|
const handleImageClick = React.useCallback((file: { filename?: string; mime?: string; size?: number; url?: string }) => {
|
||||||
|
if (!onShowPopup || !file?.url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = extractFilename(file.filename) || 'Image';
|
||||||
|
|
||||||
|
const popupPayload: ToolPopupContent = {
|
||||||
|
open: true,
|
||||||
|
title: filename,
|
||||||
|
content: '',
|
||||||
|
metadata: {
|
||||||
|
tool: 'image-preview',
|
||||||
|
filename,
|
||||||
|
mime: file.mime,
|
||||||
|
size: file.size,
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
url: file.url,
|
||||||
|
mimeType: file.mime,
|
||||||
|
filename,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
onShowPopup(popupPayload);
|
||||||
|
}, [onShowPopup]);
|
||||||
|
|
||||||
|
if (fileItems.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
{}
|
||||||
|
{otherFiles.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{otherFiles.map((file, index) => (
|
||||||
|
<div
|
||||||
|
key={`file-${index}`}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-muted/30 border border-border/30 rounded-xl typography-meta"
|
||||||
|
>
|
||||||
|
{getFileIcon(file.mime)}
|
||||||
|
<span>
|
||||||
|
{extractFilename(file.filename)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{}
|
||||||
|
{imageFiles.length > 0 && (
|
||||||
|
<div className="overflow-x-auto -mx-1 px-1 py-1 scrollbar-thin">
|
||||||
|
<div className="flex gap-3 snap-x snap-mandatory">
|
||||||
|
{imageFiles.map((file, index) => {
|
||||||
|
const filename = extractFilename(file.filename) || 'Image';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip key={`img-${index}`} delayDuration={1000}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleImageClick(file)}
|
||||||
|
className="relative flex-none w-32 sm:w-36 md:w-40 aspect-square rounded-xl border border-border/40 bg-muted/10 overflow-hidden snap-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-primary"
|
||||||
|
aria-label={filename}
|
||||||
|
>
|
||||||
|
{file.url ? (
|
||||||
|
<img
|
||||||
|
src={file.url}
|
||||||
|
alt={filename}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
target.style.visibility = 'hidden';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full flex items-center justify-center bg-muted/30 text-muted-foreground">
|
||||||
|
<RiFileImageLine className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="sr-only">{filename}</span>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" sideOffset={6} className="typography-meta px-2 py-1">
|
||||||
|
{filename}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { RiCodeLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiRefreshLine } from '@remixicon/react';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { cn, truncatePathMiddle } from '@/lib/utils';
|
||||||
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
|
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||||
|
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||||
|
import type { ProjectFileSearchHit } from '@/lib/opencode/client';
|
||||||
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
|
|
||||||
|
type FileInfo = ProjectFileSearchHit;
|
||||||
|
|
||||||
|
export interface FileMentionHandle {
|
||||||
|
handleKeyDown: (key: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileMentionAutocompleteProps {
|
||||||
|
searchQuery: string;
|
||||||
|
onFileSelect: (file: FileInfo) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileMentionAutocompleteProps>(({
|
||||||
|
searchQuery,
|
||||||
|
onFileSelect,
|
||||||
|
onClose
|
||||||
|
}, ref) => {
|
||||||
|
const { currentDirectory } = useDirectoryStore();
|
||||||
|
const { addServerFile } = useSessionStore();
|
||||||
|
const searchFiles = useFileSearchStore((state) => state.searchFiles);
|
||||||
|
const debouncedQuery = useDebouncedValue(searchQuery, 180);
|
||||||
|
const [files, setFiles] = React.useState<FileInfo[]>([]);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||||
|
const [hoveredTooltipIndex, setHoveredTooltipIndex] = React.useState<number | null>(null);
|
||||||
|
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
||||||
|
const target = event.target as Node | null;
|
||||||
|
if (!target || !containerRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (containerRef.current.contains(target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!currentDirectory) {
|
||||||
|
setFiles([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
searchFiles(currentDirectory, debouncedQuery ?? '', 40)
|
||||||
|
.then((hits) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFiles(hits.slice(0, 15));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setFiles([]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [currentDirectory, debouncedQuery, searchFiles]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
setHoveredTooltipIndex(null);
|
||||||
|
}, [files]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
itemRefs.current[selectedIndex]?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'nearest'
|
||||||
|
});
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
const handleFileSelect = React.useCallback(async (file: FileInfo) => {
|
||||||
|
|
||||||
|
await addServerFile(file.path, file.name);
|
||||||
|
onFileSelect(file);
|
||||||
|
}, [addServerFile, onFileSelect]);
|
||||||
|
|
||||||
|
React.useImperativeHandle(ref, () => ({
|
||||||
|
handleKeyDown: (key: string) => {
|
||||||
|
if (key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = files.length;
|
||||||
|
if (total === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'ArrowDown') {
|
||||||
|
setSelectedIndex((prev) => (prev + 1) % total);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'ArrowUp') {
|
||||||
|
setSelectedIndex((prev) => (prev - 1 + total) % total);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'Enter' || key === 'Tab') {
|
||||||
|
const safeIndex = ((selectedIndex % total) + total) % total;
|
||||||
|
const selectedFile = files[safeIndex];
|
||||||
|
if (selectedFile) {
|
||||||
|
handleFileSelect(selectedFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}), [files, selectedIndex, onClose, handleFileSelect]);
|
||||||
|
|
||||||
|
const getFileIcon = (file: FileInfo) => {
|
||||||
|
const ext = file.extension?.toLowerCase();
|
||||||
|
switch (ext) {
|
||||||
|
case 'ts':
|
||||||
|
case 'tsx':
|
||||||
|
case 'js':
|
||||||
|
case 'jsx':
|
||||||
|
return <RiCodeLine className="h-3.5 w-3.5 text-blue-500" />;
|
||||||
|
case 'json':
|
||||||
|
return <RiCodeLine className="h-3.5 w-3.5 text-yellow-500" />;
|
||||||
|
case 'md':
|
||||||
|
case 'mdx':
|
||||||
|
return <RiFileLine className="h-3.5 w-3.5 text-gray-500" />;
|
||||||
|
case 'png':
|
||||||
|
case 'jpg':
|
||||||
|
case 'jpeg':
|
||||||
|
case 'gif':
|
||||||
|
case 'svg':
|
||||||
|
return <RiFileImageLine className="h-3.5 w-3.5 text-green-500" />;
|
||||||
|
default:
|
||||||
|
return <RiFilePdfLine className="h-3.5 w-3.5 text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="absolute z-[100] min-w-[240px] max-w-[520px] max-h-64 bg-popover border border-border rounded-xl shadow-none bottom-full mb-2 left-0 w-max flex flex-col"
|
||||||
|
>
|
||||||
|
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<RiRefreshLine className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="pb-2">
|
||||||
|
{files.map((file, index) => {
|
||||||
|
const relativePath = file.relativePath || file.name;
|
||||||
|
const displayPath = truncatePathMiddle(relativePath, { maxLength: 45 });
|
||||||
|
const isSelected = selectedIndex === index;
|
||||||
|
const isHovered = hoveredTooltipIndex === index;
|
||||||
|
const tooltipOpen = isSelected || isHovered;
|
||||||
|
|
||||||
|
const item = (
|
||||||
|
<div
|
||||||
|
ref={(el) => { itemRefs.current[index] = el; }}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg",
|
||||||
|
isSelected && "bg-accent"
|
||||||
|
)}
|
||||||
|
onClick={() => handleFileSelect(file)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
>
|
||||||
|
{getFileIcon(file)}
|
||||||
|
<span className="flex-1 truncate max-w-[360px]" aria-label={relativePath}>
|
||||||
|
{displayPath}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
key={file.path}
|
||||||
|
open={tooltipOpen}
|
||||||
|
delayDuration={tooltipOpen ? 0 : 120}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setHoveredTooltipIndex((previous) => {
|
||||||
|
if (!open && previous === index) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (open) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
return previous;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TooltipTrigger asChild>{item}</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" align="center" className="max-w-xs">
|
||||||
|
<span className="typography-meta text-foreground/80 whitespace-pre-wrap break-all">
|
||||||
|
{relativePath}
|
||||||
|
</span>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{}
|
||||||
|
{files.length > 0 && <div className="h-2" />}
|
||||||
|
{files.length === 0 && (
|
||||||
|
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
||||||
|
No files found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollableOverlay>
|
||||||
|
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
|
||||||
|
↑↓ navigate • Enter select • Esc close
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Streamdown } from 'streamdown';
|
||||||
|
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
||||||
|
import type { Part } from '@opencode-ai/sdk';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react';
|
||||||
|
|
||||||
|
const SHIKI_THEMES = ['vitesse-light', 'vitesse-dark'] as const;
|
||||||
|
|
||||||
|
// Table utility functions
|
||||||
|
const extractTableData = (tableEl: HTMLTableElement): { headers: string[]; rows: string[][] } => {
|
||||||
|
const headers: string[] = [];
|
||||||
|
const rows: string[][] = [];
|
||||||
|
|
||||||
|
const thead = tableEl.querySelector('thead');
|
||||||
|
if (thead) {
|
||||||
|
const headerCells = thead.querySelectorAll('th');
|
||||||
|
headerCells.forEach(cell => headers.push(cell.innerText.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
const tbody = tableEl.querySelector('tbody');
|
||||||
|
if (tbody) {
|
||||||
|
const rowEls = tbody.querySelectorAll('tr');
|
||||||
|
rowEls.forEach(row => {
|
||||||
|
const cells = row.querySelectorAll('td');
|
||||||
|
const rowData: string[] = [];
|
||||||
|
cells.forEach(cell => rowData.push(cell.innerText.trim()));
|
||||||
|
rows.push(rowData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { headers, rows };
|
||||||
|
};
|
||||||
|
|
||||||
|
const tableToCSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => {
|
||||||
|
const escapeCell = (cell: string): string => {
|
||||||
|
if (cell.includes(',') || cell.includes('"') || cell.includes('\n')) {
|
||||||
|
return `"${cell.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return cell;
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (headers.length > 0) {
|
||||||
|
lines.push(headers.map(escapeCell).join(','));
|
||||||
|
}
|
||||||
|
rows.forEach(row => lines.push(row.map(escapeCell).join(',')));
|
||||||
|
return lines.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const tableToTSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => {
|
||||||
|
const escapeCell = (cell: string): string => {
|
||||||
|
return cell.replace(/\t/g, '\\t').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (headers.length > 0) {
|
||||||
|
lines.push(headers.map(escapeCell).join('\t'));
|
||||||
|
}
|
||||||
|
rows.forEach(row => lines.push(row.map(escapeCell).join('\t')));
|
||||||
|
return lines.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const tableToMarkdown = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => {
|
||||||
|
if (headers.length === 0) return '';
|
||||||
|
|
||||||
|
const escapeCell = (cell: string): string => {
|
||||||
|
return cell.replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`| ${headers.map(escapeCell).join(' | ')} |`);
|
||||||
|
lines.push(`| ${headers.map(() => '---').join(' | ')} |`);
|
||||||
|
rows.forEach(row => {
|
||||||
|
const paddedRow = headers.map((_, i) => escapeCell(row[i] || ''));
|
||||||
|
lines.push(`| ${paddedRow.join(' | ')} |`);
|
||||||
|
});
|
||||||
|
return lines.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadFile = (filename: string, content: string, mimeType: string) => {
|
||||||
|
const blob = new Blob([content], { type: mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Table copy button with dropdown
|
||||||
|
const TableCopyButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | null> }> = ({ tableRef }) => {
|
||||||
|
const [copied, setCopied] = React.useState(false);
|
||||||
|
const [showMenu, setShowMenu] = React.useState(false);
|
||||||
|
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
setShowMenu(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCopy = async (format: 'csv' | 'tsv') => {
|
||||||
|
const tableEl = tableRef.current?.querySelector('table');
|
||||||
|
if (!tableEl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = extractTableData(tableEl);
|
||||||
|
const content = format === 'csv' ? tableToCSV(data) : tableToTSV(data);
|
||||||
|
await navigator.clipboard.write([
|
||||||
|
new ClipboardItem({
|
||||||
|
'text/plain': new Blob([content], { type: 'text/plain' }),
|
||||||
|
'text/html': new Blob([tableEl.outerHTML], { type: 'text/html' }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
setCopied(true);
|
||||||
|
setShowMenu(false);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy table:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMenu(!showMenu)}
|
||||||
|
className="p-1 rounded hover:bg-muted/60 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title="Copy table"
|
||||||
|
>
|
||||||
|
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
|
||||||
|
</button>
|
||||||
|
{showMenu && (
|
||||||
|
<div className="absolute top-full right-0 z-10 mt-1 min-w-[100px] overflow-hidden rounded-md border border-border bg-background shadow-lg">
|
||||||
|
<button
|
||||||
|
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/40"
|
||||||
|
onClick={() => handleCopy('csv')}
|
||||||
|
>
|
||||||
|
CSV
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/40"
|
||||||
|
onClick={() => handleCopy('tsv')}
|
||||||
|
>
|
||||||
|
TSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Table download button with dropdown
|
||||||
|
const TableDownloadButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | null> }> = ({ tableRef }) => {
|
||||||
|
const [showMenu, setShowMenu] = React.useState(false);
|
||||||
|
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
setShowMenu(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDownload = (format: 'csv' | 'markdown') => {
|
||||||
|
const tableEl = tableRef.current?.querySelector('table');
|
||||||
|
if (!tableEl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = extractTableData(tableEl);
|
||||||
|
const content = format === 'csv' ? tableToCSV(data) : tableToMarkdown(data);
|
||||||
|
const filename = format === 'csv' ? 'table.csv' : 'table.md';
|
||||||
|
const mimeType = format === 'csv' ? 'text/csv' : 'text/markdown';
|
||||||
|
downloadFile(filename, content, mimeType);
|
||||||
|
setShowMenu(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to download table:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMenu(!showMenu)}
|
||||||
|
className="p-1 rounded hover:bg-muted/60 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title="Download table"
|
||||||
|
>
|
||||||
|
<RiDownloadLine className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
{showMenu && (
|
||||||
|
<div className="absolute top-full right-0 z-10 mt-1 min-w-[100px] overflow-hidden rounded-md border border-border bg-background shadow-lg">
|
||||||
|
<button
|
||||||
|
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/40"
|
||||||
|
onClick={() => handleDownload('csv')}
|
||||||
|
>
|
||||||
|
CSV
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/40"
|
||||||
|
onClick={() => handleDownload('markdown')}
|
||||||
|
>
|
||||||
|
Markdown
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Table wrapper with custom controls
|
||||||
|
const TableWrapper: React.FC<{ children?: React.ReactNode; className?: string }> = ({ children, className }) => {
|
||||||
|
const tableRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group my-4 flex flex-col space-y-2" data-streamdown="table-wrapper" ref={tableRef}>
|
||||||
|
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<TableCopyButton tableRef={tableRef} />
|
||||||
|
<TableDownloadButton tableRef={tableRef} />
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className={cn('w-full border-collapse border border-border', className)} data-streamdown="table">
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CodeBlockWrapper: React.FC<{ children?: React.ReactNode; className?: string }> = ({ children, className }) => {
|
||||||
|
const [copied, setCopied] = React.useState(false);
|
||||||
|
const codeRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const getCodeContent = (): string => {
|
||||||
|
if (!codeRef.current) return '';
|
||||||
|
const codeEl = codeRef.current.querySelector('code');
|
||||||
|
|
||||||
|
return codeEl?.innerText || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
const code = getCodeContent();
|
||||||
|
if (!code) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(code);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('group relative', className)} ref={codeRef}>
|
||||||
|
{children}
|
||||||
|
<div className="absolute top-1 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="p-1 rounded hover:bg-muted/60 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title="Copy"
|
||||||
|
>
|
||||||
|
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const streamdownComponents = {
|
||||||
|
pre: CodeBlockWrapper,
|
||||||
|
table: TableWrapper,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MarkdownRendererProps {
|
||||||
|
content: string;
|
||||||
|
part?: Part;
|
||||||
|
messageId: string;
|
||||||
|
isAnimated?: boolean;
|
||||||
|
className?: string;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||||
|
content,
|
||||||
|
part,
|
||||||
|
messageId,
|
||||||
|
isAnimated = true,
|
||||||
|
className,
|
||||||
|
isStreaming = false,
|
||||||
|
}) => {
|
||||||
|
const componentKey = React.useMemo(() => {
|
||||||
|
const signature = part?.id ? `part-${part.id}` : `message-${messageId}`;
|
||||||
|
return `markdown-${signature}`;
|
||||||
|
}, [messageId, part?.id]);
|
||||||
|
|
||||||
|
const markdownContent = (
|
||||||
|
<div className={cn('break-words', className)}>
|
||||||
|
<Streamdown
|
||||||
|
mode={isStreaming ? 'streaming' : 'static'}
|
||||||
|
shikiTheme={SHIKI_THEMES}
|
||||||
|
className="streamdown-content"
|
||||||
|
controls={{ code: false, table: false }}
|
||||||
|
components={streamdownComponents}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Streamdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isAnimated) {
|
||||||
|
return (
|
||||||
|
<FadeInOnReveal key={componentKey}>
|
||||||
|
{markdownContent}
|
||||||
|
</FadeInOnReveal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return markdownContent;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SimpleMarkdownRenderer: React.FC<{
|
||||||
|
content: string;
|
||||||
|
className?: string;
|
||||||
|
}> = ({ content, className }) => {
|
||||||
|
return (
|
||||||
|
<div className={cn('break-words', className)}>
|
||||||
|
<Streamdown
|
||||||
|
mode="static"
|
||||||
|
shikiTheme={SHIKI_THEMES}
|
||||||
|
className="streamdown-content"
|
||||||
|
controls={{ code: false, table: false }}
|
||||||
|
components={streamdownComponents}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Streamdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Message, Part } from '@opencode-ai/sdk';
|
||||||
|
|
||||||
|
import ChatMessage from './ChatMessage';
|
||||||
|
import { PermissionCard } from './PermissionCard';
|
||||||
|
import type { Permission } from '@/types/permission';
|
||||||
|
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||||
|
import { isFullySyntheticMessage } from '@/lib/messages/synthetic';
|
||||||
|
import { useTurnGrouping } from './hooks/useTurnGrouping';
|
||||||
|
|
||||||
|
interface MessageListProps {
|
||||||
|
messages: { info: Message; parts: Part[] }[];
|
||||||
|
permissions: Permission[];
|
||||||
|
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||||
|
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||||
|
hasMoreAbove: boolean;
|
||||||
|
isLoadingOlder: boolean;
|
||||||
|
onLoadOlder: () => void;
|
||||||
|
scrollToBottom?: (options?: { instant?: boolean }) => void;
|
||||||
|
pendingAnchorId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MessageList: React.FC<MessageListProps> = ({
|
||||||
|
messages,
|
||||||
|
permissions,
|
||||||
|
onMessageContentChange,
|
||||||
|
getAnimationHandlers,
|
||||||
|
hasMoreAbove,
|
||||||
|
isLoadingOlder,
|
||||||
|
onLoadOlder,
|
||||||
|
scrollToBottom,
|
||||||
|
pendingAnchorId,
|
||||||
|
}) => {
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (permissions.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onMessageContentChange('permission');
|
||||||
|
}, [permissions, onMessageContentChange]);
|
||||||
|
|
||||||
|
const displayMessages = React.useMemo(() => {
|
||||||
|
return messages.filter((message) => !isFullySyntheticMessage(message.parts));
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const { getContextForMessage } = useTurnGrouping(displayMessages);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{hasMoreAbove && (
|
||||||
|
<div className="flex justify-center py-3">
|
||||||
|
{isLoadingOlder ? (
|
||||||
|
<span className="text-xs uppercase tracking-wide text-muted-foreground/80">
|
||||||
|
Loading…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onLoadOlder}
|
||||||
|
className="text-xs uppercase tracking-wide text-muted-foreground/80 hover:text-foreground"
|
||||||
|
>
|
||||||
|
Load older messages
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{displayMessages.map((message, index) => (
|
||||||
|
<ChatMessage
|
||||||
|
key={message.info.id}
|
||||||
|
message={message}
|
||||||
|
previousMessage={index > 0 ? displayMessages[index - 1] : undefined}
|
||||||
|
nextMessage={index < displayMessages.length - 1 ? displayMessages[index + 1] : undefined}
|
||||||
|
onContentChange={onMessageContentChange}
|
||||||
|
animationHandlers={getAnimationHandlers(message.info.id)}
|
||||||
|
scrollToBottom={scrollToBottom}
|
||||||
|
isPendingAnchor={pendingAnchorId === message.info.id}
|
||||||
|
turnGroupingContext={getContextForMessage(message.info.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{permissions.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{permissions.map((permission) => (
|
||||||
|
<PermissionCard key={permission.id} permission={permission} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(MessageList);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,525 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { RiCheckLine, RiCloseLine, RiGlobalLine, RiPencilAiLine, RiQuestionLine, RiTerminalBoxLine, RiTimeLine, RiToolsLine } from '@remixicon/react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { Permission, PermissionResponse } from '@/types/permission';
|
||||||
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
|
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
||||||
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
|
|
||||||
|
interface PermissionCardProps {
|
||||||
|
permission: Permission;
|
||||||
|
onResponse?: (response: 'once' | 'always' | 'reject') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getToolIcon = (toolName: string) => {
|
||||||
|
const iconClass = "h-3 w-3";
|
||||||
|
const tool = toolName.toLowerCase();
|
||||||
|
|
||||||
|
if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
|
||||||
|
return <RiPencilAiLine className={iconClass} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool === 'bash' || tool === 'shell' || tool === 'cmd' || tool === 'terminal' || tool === 'shell_command') {
|
||||||
|
return <RiTerminalBoxLine className={iconClass} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool === 'webfetch' || tool === 'fetch' || tool === 'curl' || tool === 'wget') {
|
||||||
|
return <RiGlobalLine className={iconClass} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <RiToolsLine className={iconClass} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getToolDisplayName = (toolName: string): string => {
|
||||||
|
const tool = toolName.toLowerCase();
|
||||||
|
|
||||||
|
if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
|
||||||
|
return 'edit';
|
||||||
|
}
|
||||||
|
if (tool === 'bash' || tool === 'shell' || tool === 'cmd' || tool === 'terminal' || tool === 'shell_command') {
|
||||||
|
return 'bash';
|
||||||
|
}
|
||||||
|
if (tool === 'webfetch' || tool === 'fetch' || tool === 'curl' || tool === 'wget') {
|
||||||
|
return 'webfetch';
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolName;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||||
|
permission,
|
||||||
|
onResponse
|
||||||
|
}) => {
|
||||||
|
const [isResponding, setIsResponding] = React.useState(false);
|
||||||
|
const [hasResponded, setHasResponded] = React.useState(false);
|
||||||
|
const { respondToPermission } = useSessionStore();
|
||||||
|
const { currentTheme } = useThemeSystem();
|
||||||
|
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
|
||||||
|
|
||||||
|
const handleResponse = async (response: PermissionResponse) => {
|
||||||
|
setIsResponding(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await respondToPermission(permission.sessionID, permission.id, response);
|
||||||
|
setHasResponded(true);
|
||||||
|
onResponse?.(response);
|
||||||
|
} catch { /* ignored */ } finally {
|
||||||
|
setIsResponding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasResponded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolName = permission.type || 'Unknown Tool';
|
||||||
|
const tool = toolName.toLowerCase();
|
||||||
|
|
||||||
|
const getMeta = (key: string, fallback: string = ''): string => {
|
||||||
|
const val = permission.metadata[key];
|
||||||
|
return typeof val === 'string' ? val : (typeof val === 'number' ? String(val) : fallback);
|
||||||
|
};
|
||||||
|
const getMetaNum = (key: string): number | undefined => {
|
||||||
|
const val = permission.metadata[key];
|
||||||
|
return typeof val === 'number' ? val : undefined;
|
||||||
|
};
|
||||||
|
const getMetaBool = (key: string): boolean => {
|
||||||
|
const val = permission.metadata[key];
|
||||||
|
return Boolean(val);
|
||||||
|
};
|
||||||
|
const displayToolName = getToolDisplayName(toolName);
|
||||||
|
|
||||||
|
const renderToolContent = () => {
|
||||||
|
|
||||||
|
if (tool === 'bash' || tool === 'shell' || tool === 'shell_command') {
|
||||||
|
const command = getMeta('command') || getMeta('cmd') || getMeta('script');
|
||||||
|
const description = getMeta('description');
|
||||||
|
const workingDir = getMeta('cwd') || getMeta('working_directory') || getMeta('directory') || getMeta('path');
|
||||||
|
const timeout = getMetaNum('timeout');
|
||||||
|
|
||||||
|
const commandInTitle = permission.title === command;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{description && (
|
||||||
|
<div className="typography-meta text-muted-foreground mb-2">{description}</div>
|
||||||
|
)}
|
||||||
|
{workingDir && (
|
||||||
|
<div className="typography-meta text-muted-foreground mb-2">
|
||||||
|
<span className="font-semibold">Working Directory:</span> <code className="px-1 py-0.5 bg-muted/30 rounded">{workingDir}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{timeout && (
|
||||||
|
<div className="typography-meta text-muted-foreground mb-2">
|
||||||
|
<span className="font-semibold">Timeout:</span> {timeout}ms
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{}
|
||||||
|
{command && !commandInTitle && (
|
||||||
|
<div>
|
||||||
|
<SyntaxHighlighter
|
||||||
|
language="bash"
|
||||||
|
style={syntaxTheme}
|
||||||
|
PreTag="div"
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
padding: '0.5rem',
|
||||||
|
fontSize: 'var(--text-meta)',
|
||||||
|
lineHeight: '1.25rem',
|
||||||
|
background: 'rgb(var(--muted) / 0.3)',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
overflowWrap: 'break-word',
|
||||||
|
overflow: 'visible'
|
||||||
|
}}
|
||||||
|
codeTagProps={{
|
||||||
|
style: {
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
overflowWrap: 'break-word'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
wrapLongLines={true}
|
||||||
|
>
|
||||||
|
{command}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
|
||||||
|
const filePath = getMeta('path') || getMeta('file_path') || getMeta('filename') || getMeta('filePath');
|
||||||
|
const oldContent = getMeta('old_str') || getMeta('oldString') || getMeta('old_content') || getMeta('before');
|
||||||
|
const newContent = getMeta('new_str') || getMeta('newString') || getMeta('new_content') || getMeta('after');
|
||||||
|
const changes = getMeta('changes') || getMeta('diff');
|
||||||
|
const replaceAll = getMetaBool('replace_all') || getMetaBool('replaceAll');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{filePath && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="typography-meta text-muted-foreground mb-1">File Path:</div>
|
||||||
|
<code className="typography-meta px-2 py-1 bg-muted/30 rounded block break-all">
|
||||||
|
{filePath}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{replaceAll && (
|
||||||
|
<div className="typography-meta text-muted-foreground mb-2">
|
||||||
|
<span className="font-semibold">⚠️ Replace All Occurrences</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{changes ? (
|
||||||
|
<div>
|
||||||
|
<div className="typography-meta text-muted-foreground mb-1">Changes:</div>
|
||||||
|
<ScrollableOverlay outerClassName="max-h-64" className="overflow-x-auto p-0">
|
||||||
|
<SyntaxHighlighter
|
||||||
|
language="diff"
|
||||||
|
style={syntaxTheme}
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
padding: '0.5rem',
|
||||||
|
fontSize: 'var(--text-meta)',
|
||||||
|
lineHeight: '1.25rem',
|
||||||
|
background: 'rgb(var(--muted) / 0.3)',
|
||||||
|
borderRadius: '0.25rem'
|
||||||
|
}}
|
||||||
|
wrapLongLines={false}
|
||||||
|
>
|
||||||
|
{changes}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</ScrollableOverlay>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{oldContent && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="typography-meta text-muted-foreground mb-1">Remove:</div>
|
||||||
|
<ScrollableOverlay outerClassName="max-h-32 border border-red-500/20 rounded bg-red-500/5 p-2" className="p-0">
|
||||||
|
<pre className="typography-meta font-mono text-red-600 dark:text-red-400 whitespace-pre-wrap break-all">
|
||||||
|
{oldContent.length > 500 ? oldContent.substring(0, 500) + '...' : oldContent}
|
||||||
|
</pre>
|
||||||
|
</ScrollableOverlay>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{newContent && (
|
||||||
|
<div>
|
||||||
|
<div className="typography-meta text-muted-foreground mb-1">Replace with:</div>
|
||||||
|
<ScrollableOverlay outerClassName="max-h-32 border border-green-500/20 rounded bg-green-500/5 p-2" className="p-0">
|
||||||
|
<pre className="typography-meta font-mono text-green-600 dark:text-green-400 whitespace-pre-wrap break-all">
|
||||||
|
{newContent.length > 500 ? newContent.substring(0, 500) + '...' : newContent}
|
||||||
|
</pre>
|
||||||
|
</ScrollableOverlay>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool === 'webfetch' || tool === 'fetch' || tool === 'curl' || tool === 'wget') {
|
||||||
|
const url = getMeta('url') || getMeta('uri') || getMeta('endpoint');
|
||||||
|
const method = getMeta('method') || 'GET';
|
||||||
|
const headers = permission.metadata.headers && typeof permission.metadata.headers === 'object' ? (permission.metadata.headers as Record<string, unknown>) : undefined;
|
||||||
|
const body = getMeta('body') || getMeta('data') || getMeta('payload');
|
||||||
|
const timeout = getMetaNum('timeout');
|
||||||
|
const format = getMeta('format') || getMeta('responseType');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{url && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="typography-meta text-muted-foreground mb-1">Request:</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="typography-meta font-semibold px-1.5 py-0.5 bg-primary/20 text-primary rounded">
|
||||||
|
{method}
|
||||||
|
</span>
|
||||||
|
<code className="typography-meta px-2 py-1 bg-muted/30 rounded flex-1 break-all">
|
||||||
|
{url}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{headers && Object.keys(headers).length > 0 && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="typography-meta text-muted-foreground mb-1">Headers:</div>
|
||||||
|
<ScrollableOverlay outerClassName="max-h-24" className="p-0">
|
||||||
|
<SyntaxHighlighter
|
||||||
|
language="json"
|
||||||
|
style={syntaxTheme}
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
padding: '0.5rem',
|
||||||
|
fontSize: 'var(--text-meta)',
|
||||||
|
lineHeight: '1.25rem',
|
||||||
|
background: 'rgb(var(--muted) / 0.3)',
|
||||||
|
borderRadius: '0.25rem'
|
||||||
|
}}
|
||||||
|
wrapLongLines={true}
|
||||||
|
>
|
||||||
|
{JSON.stringify(headers, null, 2)}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</ScrollableOverlay>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{body && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="typography-meta text-muted-foreground mb-1">Body:</div>
|
||||||
|
<ScrollableOverlay outerClassName="max-h-32" className="p-0">
|
||||||
|
<SyntaxHighlighter
|
||||||
|
language={typeof body === 'object' ? 'json' : 'text'}
|
||||||
|
style={syntaxTheme}
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
padding: '0.5rem',
|
||||||
|
fontSize: 'var(--text-meta)',
|
||||||
|
lineHeight: '1.25rem',
|
||||||
|
background: 'rgb(var(--muted) / 0.3)',
|
||||||
|
borderRadius: '0.25rem'
|
||||||
|
}}
|
||||||
|
wrapLongLines={true}
|
||||||
|
>
|
||||||
|
{typeof body === 'object' ? JSON.stringify(body, null, 2) : String(body)}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</ScrollableOverlay>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(timeout || format) && (
|
||||||
|
<div className="typography-meta text-muted-foreground">
|
||||||
|
{timeout && <span>Timeout: {timeout}ms</span>}
|
||||||
|
{timeout && format && <span> • </span>}
|
||||||
|
{format && <span>Response format: {format}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const genericContent = getMeta('command') || getMeta('content') || getMeta('action') || getMeta('operation');
|
||||||
|
const description = getMeta('description');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{description && (
|
||||||
|
<div className="typography-meta text-muted-foreground mb-2">{description}</div>
|
||||||
|
)}
|
||||||
|
{genericContent && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="typography-meta text-muted-foreground mb-1">Action:</div>
|
||||||
|
<ScrollableOverlay outerClassName="max-h-32" className="p-0">
|
||||||
|
<pre className="typography-meta font-mono px-2 py-1 bg-muted/30 rounded whitespace-pre-wrap break-all">
|
||||||
|
{String(genericContent)}
|
||||||
|
</pre>
|
||||||
|
</ScrollableOverlay>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{}
|
||||||
|
{Object.keys(permission.metadata).length > 0 && !genericContent && !description && (
|
||||||
|
<div>
|
||||||
|
<div className="typography-meta text-muted-foreground mb-1">Details:</div>
|
||||||
|
<ScrollableOverlay outerClassName="max-h-32" className="p-0">
|
||||||
|
<pre className="typography-meta font-mono px-2 py-1 bg-muted/30 rounded whitespace-pre-wrap break-all">
|
||||||
|
{JSON.stringify(permission.metadata, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</ScrollableOverlay>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group w-full pt-0 pb-2">
|
||||||
|
<div className="chat-column">
|
||||||
|
<div className="-mt-1 border border-border/30 rounded-xl bg-muted/10">
|
||||||
|
{}
|
||||||
|
<div className="px-2 py-1.5 border-b border-border/20 bg-muted/5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RiQuestionLine className="h-3.5 w-3.5 text-yellow-500" />
|
||||||
|
<span className="typography-meta font-medium text-muted-foreground">
|
||||||
|
Permission Required
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{getToolIcon(toolName)}
|
||||||
|
<span className="typography-meta text-muted-foreground font-medium">{displayToolName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{}
|
||||||
|
<div className="px-2 py-2">
|
||||||
|
{}
|
||||||
|
{(() => {
|
||||||
|
|
||||||
|
let primaryContent = '';
|
||||||
|
let primaryLanguage = 'text';
|
||||||
|
let shouldHighlight = false;
|
||||||
|
|
||||||
|
if (tool === 'bash' || tool === 'shell' || tool === 'shell_command') {
|
||||||
|
primaryContent = getMeta('command') || getMeta('cmd') || getMeta('script');
|
||||||
|
primaryLanguage = 'bash';
|
||||||
|
shouldHighlight = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
else if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
|
||||||
|
primaryContent = getMeta('path') || getMeta('file_path') || getMeta('filename') || getMeta('filePath');
|
||||||
|
shouldHighlight = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
else if (tool === 'webfetch' || tool === 'fetch') {
|
||||||
|
primaryContent = getMeta('url') || getMeta('uri') || getMeta('endpoint');
|
||||||
|
shouldHighlight = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleMatchesContent = permission.title === primaryContent;
|
||||||
|
|
||||||
|
if (titleMatchesContent && primaryContent && shouldHighlight) {
|
||||||
|
return (
|
||||||
|
<div className="mb-3">
|
||||||
|
<SyntaxHighlighter
|
||||||
|
language={primaryLanguage}
|
||||||
|
style={syntaxTheme}
|
||||||
|
PreTag="div"
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
padding: '0.5rem',
|
||||||
|
fontSize: 'var(--text-meta)',
|
||||||
|
lineHeight: '1.25rem',
|
||||||
|
background: 'rgb(var(--muted) / 0.3)',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
overflowWrap: 'break-word',
|
||||||
|
overflow: 'visible'
|
||||||
|
}}
|
||||||
|
codeTagProps={{
|
||||||
|
style: {
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
overflowWrap: 'break-word'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
wrapLongLines={true}
|
||||||
|
>
|
||||||
|
{primaryContent}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (titleMatchesContent && primaryContent && !shouldHighlight) {
|
||||||
|
return (
|
||||||
|
<div className="mb-3">
|
||||||
|
<code className="typography-ui-label px-2 py-1 bg-muted/30 rounded block break-all">
|
||||||
|
{primaryContent}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission.title) {
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
"typography-ui-label text-foreground mb-3",
|
||||||
|
|
||||||
|
(shouldHighlight || primaryContent) && "font-mono"
|
||||||
|
)}>
|
||||||
|
{permission.title}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{}
|
||||||
|
{renderToolContent()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{}
|
||||||
|
<div className="px-2 pb-1.5 pt-1 flex items-center gap-1.5 border-t border-border/20">
|
||||||
|
<button
|
||||||
|
onClick={() => handleResponse('once')}
|
||||||
|
disabled={isResponding}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded transition-all",
|
||||||
|
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgb(var(--status-success) / 0.1)',
|
||||||
|
color: 'var(--status-success)'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'rgb(var(--status-success) / 0.2)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'rgb(var(--status-success) / 0.1)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiCheckLine className="h-3 w-3" />
|
||||||
|
Allow Once
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleResponse('always')}
|
||||||
|
disabled={isResponding}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded transition-all",
|
||||||
|
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgb(var(--muted) / 0.5)',
|
||||||
|
color: 'var(--muted-foreground)'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'rgb(var(--muted) / 0.7)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'rgb(var(--muted) / 0.5)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiTimeLine className="h-3 w-3" />
|
||||||
|
Always Allow
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleResponse('reject')}
|
||||||
|
disabled={isResponding}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded transition-all",
|
||||||
|
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgb(var(--status-error) / 0.1)',
|
||||||
|
color: 'var(--status-error)'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'rgb(var(--status-error) / 0.2)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'rgb(var(--status-error) / 0.1)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiCloseLine className="h-3 w-3" />
|
||||||
|
Deny
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isResponding && (
|
||||||
|
<div className="ml-auto typography-meta text-muted-foreground">
|
||||||
|
<div className="animate-spin h-3 w-3 border border-primary border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { RiCheckLine, RiCloseLine, RiTimeLine } from '@remixicon/react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { Permission, PermissionResponse } from '@/types/permission';
|
||||||
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
|
|
||||||
|
interface PermissionRequestProps {
|
||||||
|
permission: Permission;
|
||||||
|
onResponse?: (response: 'once' | 'always' | 'reject') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PermissionRequest: React.FC<PermissionRequestProps> = ({
|
||||||
|
permission,
|
||||||
|
onResponse
|
||||||
|
}) => {
|
||||||
|
const [isResponding, setIsResponding] = React.useState(false);
|
||||||
|
const [hasResponded, setHasResponded] = React.useState(false);
|
||||||
|
const { respondToPermission } = useSessionStore();
|
||||||
|
|
||||||
|
const handleResponse = async (response: PermissionResponse) => {
|
||||||
|
setIsResponding(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await respondToPermission(permission.sessionID, permission.id, response);
|
||||||
|
setHasResponded(true);
|
||||||
|
onResponse?.(response);
|
||||||
|
} catch { /* ignored */ } finally {
|
||||||
|
setIsResponding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasResponded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = typeof permission.metadata.command === 'string'
|
||||||
|
? permission.metadata.command
|
||||||
|
: permission.title;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="typography-ui-label font-medium text-muted-foreground">
|
||||||
|
Permission required:
|
||||||
|
</span>
|
||||||
|
<code className="ml-2 typography-meta bg-amber-100/50 dark:bg-amber-800/30 px-1.5 py-0.5 rounded font-mono text-amber-800 dark:text-amber-200">
|
||||||
|
{command}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0 ml-4">
|
||||||
|
<button
|
||||||
|
onClick={() => handleResponse('once')}
|
||||||
|
disabled={isResponding}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded border h-6",
|
||||||
|
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--status-success)',
|
||||||
|
color: 'var(--status-success)'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--status-success-background)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiCheckLine className="h-3 w-3" />
|
||||||
|
Once
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleResponse('always')}
|
||||||
|
disabled={isResponding}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded border h-6",
|
||||||
|
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--status-info)',
|
||||||
|
color: 'var(--status-info)'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--status-info-background)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiTimeLine className="h-3 w-3" />
|
||||||
|
Always
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleResponse('reject')}
|
||||||
|
disabled={isResponding}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded border h-6",
|
||||||
|
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--status-error)',
|
||||||
|
color: 'var(--status-error)'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--status-error-background)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiCloseLine className="h-3 w-3" />
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isResponding && (
|
||||||
|
<div className="ml-2 flex items-center">
|
||||||
|
<div className="animate-spin h-3 w-3 border-2 border-t-transparent rounded-full" style={{ borderColor: 'var(--loading-spinner)' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,559 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { RiCloseLine, RiCodeLine, RiFileImageLine, RiFileTextLine, RiFolder6Line, RiSearchLine } from '@remixicon/react';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { cn, truncatePathMiddle } from '@/lib/utils';
|
||||||
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
|
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||||
|
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||||
|
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||||
|
|
||||||
|
interface FileInfo {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: 'file' | 'directory';
|
||||||
|
size?: number;
|
||||||
|
extension?: string;
|
||||||
|
relativePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerFilePickerProps {
|
||||||
|
onFilesSelected: (files: FileInfo[]) => void;
|
||||||
|
multiSelect?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
|
||||||
|
onFilesSelected,
|
||||||
|
multiSelect = false,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
const { isMobile } = useDeviceInfo();
|
||||||
|
const { currentDirectory } = useDirectoryStore();
|
||||||
|
const searchFiles = useFileSearchStore((state) => state.searchFiles);
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = React.useState('');
|
||||||
|
const debouncedSearchQuery = useDebouncedValue(searchQuery, 200);
|
||||||
|
const [selectedFiles, setSelectedFiles] = React.useState<Set<string>>(new Set());
|
||||||
|
const [expandedDirs, setExpandedDirs] = React.useState<Set<string>>(new Set());
|
||||||
|
const [fileTree, setFileTree] = React.useState<FileInfo[]>([]);
|
||||||
|
const [searchResults, setSearchResults] = React.useState<FileInfo[]>([]);
|
||||||
|
const [searching, setSearching] = React.useState(false);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [attaching, setAttaching] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadDirectory = React.useCallback(async (dirPath: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const tempClient = opencodeClient.getApiClient();
|
||||||
|
const response = await tempClient.file.list({
|
||||||
|
query: {
|
||||||
|
path: '.',
|
||||||
|
directory: dirPath
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.data) {
|
||||||
|
setFileTree([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = response.data
|
||||||
|
.filter((item: { name: string; type: string; size?: number; absolute?: string }) => !item.name.startsWith('.'))
|
||||||
|
.map((item: { name: string; type: string; size?: number; absolute?: string }) => {
|
||||||
|
const extension = item.type === 'file'
|
||||||
|
? item.name.split('.').pop()?.toLowerCase()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
path: item.absolute || `${dirPath}/${item.name}`,
|
||||||
|
type: item.type as 'file' | 'directory',
|
||||||
|
size: item.size || 0,
|
||||||
|
extension
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a: FileInfo, b: FileInfo) => {
|
||||||
|
if (a.type !== b.type) {
|
||||||
|
return a.type === 'directory' ? -1 : 1;
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
setFileTree(items);
|
||||||
|
} catch {
|
||||||
|
setError('Failed to load directory contents');
|
||||||
|
setFileTree([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if ((open || mobileOpen) && currentDirectory) {
|
||||||
|
void loadDirectory(currentDirectory);
|
||||||
|
}
|
||||||
|
}, [open, mobileOpen, currentDirectory, loadDirectory]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!(open || mobileOpen) || !currentDirectory) {
|
||||||
|
setSearchResults([]);
|
||||||
|
setSearching(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedQuery = debouncedSearchQuery.trim();
|
||||||
|
if (!trimmedQuery) {
|
||||||
|
setSearchResults([]);
|
||||||
|
setSearching(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setSearching(true);
|
||||||
|
|
||||||
|
searchFiles(currentDirectory, trimmedQuery, 150)
|
||||||
|
.then((hits) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mappedHits: FileInfo[] = hits.map((hit) => ({
|
||||||
|
name: hit.name,
|
||||||
|
path: hit.path,
|
||||||
|
type: 'file',
|
||||||
|
extension: hit.extension,
|
||||||
|
relativePath: hit.relativePath,
|
||||||
|
size: 0,
|
||||||
|
}));
|
||||||
|
setSearchResults(mappedHits);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open, mobileOpen, currentDirectory, debouncedSearchQuery, searchFiles]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open && !mobileOpen) {
|
||||||
|
setSelectedFiles(new Set());
|
||||||
|
setSearchQuery('');
|
||||||
|
setSearchResults([]);
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
}, [open, mobileOpen]);
|
||||||
|
|
||||||
|
const getFileIcon = (file: FileInfo) => {
|
||||||
|
if (file.type === 'directory') {
|
||||||
|
return expandedDirs.has(file.path) ? (
|
||||||
|
<RiFolder6Line className="h-3.5 w-3.5 text-primary/60" />
|
||||||
|
) : (
|
||||||
|
<RiFolder6Line className="h-3.5 w-3.5 text-primary/60" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = file.extension?.toLowerCase();
|
||||||
|
switch (ext) {
|
||||||
|
case 'ts':
|
||||||
|
case 'tsx':
|
||||||
|
case 'js':
|
||||||
|
case 'jsx':
|
||||||
|
case 'html':
|
||||||
|
case 'css':
|
||||||
|
case 'scss':
|
||||||
|
case 'less':
|
||||||
|
return <RiCodeLine className="h-3.5 w-3.5 text-blue-500" />;
|
||||||
|
case 'json':
|
||||||
|
return <RiCodeLine className="h-3.5 w-3.5 text-yellow-500" />;
|
||||||
|
case 'md':
|
||||||
|
case 'mdx':
|
||||||
|
return <RiFileTextLine className="h-3.5 w-3.5 text-gray-500" />;
|
||||||
|
case 'png':
|
||||||
|
case 'jpg':
|
||||||
|
case 'jpeg':
|
||||||
|
case 'gif':
|
||||||
|
case 'svg':
|
||||||
|
return <RiFileImageLine className="h-3.5 w-3.5 text-green-500" />;
|
||||||
|
default:
|
||||||
|
return <RiFileTextLine className="h-3.5 w-3.5 text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDirectory = async (dirPath: string) => {
|
||||||
|
const isExpanded = expandedDirs.has(dirPath);
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
setExpandedDirs(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(dirPath);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setExpandedDirs(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(dirPath);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tempClient = opencodeClient.getApiClient();
|
||||||
|
const response = await tempClient.file.list({
|
||||||
|
query: {
|
||||||
|
path: '.',
|
||||||
|
directory: dirPath
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data) {
|
||||||
|
const subItems = response.data
|
||||||
|
.filter((item: { name: string; type: string; size?: number; absolute?: string }) => !item.name.startsWith('.'))
|
||||||
|
.map((item: { name: string; type: string; size?: number; absolute?: string }) => {
|
||||||
|
const extension = item.type === 'file'
|
||||||
|
? item.name.split('.').pop()?.toLowerCase()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
path: item.absolute || `${dirPath}/${item.name}`,
|
||||||
|
type: item.type as 'file' | 'directory',
|
||||||
|
size: 0,
|
||||||
|
extension
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setFileTree(prev => {
|
||||||
|
const filtered = prev.filter(item => !item.path.startsWith(dirPath + '/'));
|
||||||
|
return [...filtered, ...subItems].sort((a, b) => {
|
||||||
|
const aDepth = a.path.split('/').length;
|
||||||
|
const bDepth = b.path.split('/').length;
|
||||||
|
if (aDepth !== bDepth) return aDepth - bDepth;
|
||||||
|
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch { /* ignored */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFileSelection = (filePath: string) => {
|
||||||
|
if (multiSelect) {
|
||||||
|
setSelectedFiles(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(filePath)) {
|
||||||
|
next.delete(filePath);
|
||||||
|
} else {
|
||||||
|
next.add(filePath);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setSelectedFiles(new Set([filePath]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
const treeFileMap = new Map(
|
||||||
|
fileTree
|
||||||
|
.filter((file) => file.type === 'file')
|
||||||
|
.map((file) => [file.path, file])
|
||||||
|
);
|
||||||
|
const searchFileMap = new Map(searchResults.map((file) => [file.path, file]));
|
||||||
|
|
||||||
|
const selected = Array.from(selectedFiles)
|
||||||
|
.map((filePath) => treeFileMap.get(filePath) ?? searchFileMap.get(filePath))
|
||||||
|
.filter((file): file is FileInfo => Boolean(file));
|
||||||
|
|
||||||
|
setAttaching(true);
|
||||||
|
try {
|
||||||
|
await onFilesSelected(selected);
|
||||||
|
setSelectedFiles(new Set());
|
||||||
|
setOpen(false);
|
||||||
|
setMobileOpen(false);
|
||||||
|
} finally {
|
||||||
|
setAttaching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootItems = React.useMemo(() => {
|
||||||
|
if (!currentDirectory) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileTree.filter((item) => {
|
||||||
|
const itemDir = item.path.substring(0, item.path.lastIndexOf('/'));
|
||||||
|
return itemDir === currentDirectory;
|
||||||
|
});
|
||||||
|
}, [fileTree, currentDirectory]);
|
||||||
|
|
||||||
|
const isSearchActive = searchQuery.trim().length > 0;
|
||||||
|
|
||||||
|
const getChildItems = (parentPath: string) => {
|
||||||
|
return fileTree.filter(item => {
|
||||||
|
const itemDir = item.path.substring(0, item.path.lastIndexOf('/'));
|
||||||
|
return itemDir === parentPath;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRelativePath = (fullPath: string) => {
|
||||||
|
if (currentDirectory && fullPath.startsWith(currentDirectory)) {
|
||||||
|
const relativePath = fullPath.substring(currentDirectory.length);
|
||||||
|
return relativePath.startsWith('/') ? relativePath.substring(1) : relativePath;
|
||||||
|
}
|
||||||
|
return fullPath.split('/').pop() || fullPath;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFileItem = (file: FileInfo, level: number) => {
|
||||||
|
const rawLabel = isSearchActive
|
||||||
|
? file.relativePath || getRelativePath(file.path)
|
||||||
|
: file.name;
|
||||||
|
const shouldCompact = isSearchActive && rawLabel.includes('/') && rawLabel.length > 45;
|
||||||
|
const displayLabel = shouldCompact
|
||||||
|
? truncatePathMiddle(rawLabel, { maxLength: isMobile ? 42 : 48 })
|
||||||
|
: rawLabel;
|
||||||
|
|
||||||
|
const row = (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-start gap-1 px-2 py-1.5 rounded hover:bg-accent cursor-pointer typography-ui-label text-foreground text-left",
|
||||||
|
file.type === 'file' && selectedFiles.has(file.path) && "bg-primary/10"
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: `${level * 12}px` }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (file.type === 'file') {
|
||||||
|
toggleFileSelection(file.path);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 items-center justify-start gap-1">
|
||||||
|
<span className="text-muted-foreground">{getFileIcon(file)}</span>
|
||||||
|
<span className="flex-1 truncate text-foreground text-left max-w-[360px]" aria-label={rawLabel}>
|
||||||
|
{displayLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{file.type === 'file' && selectedFiles.has(file.path) && (
|
||||||
|
<div className="h-1.5 w-1.5 rounded-full bg-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!shouldCompact) {
|
||||||
|
return React.cloneElement(row, { key: file.path });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip key={file.path} delayDuration={120}>
|
||||||
|
<TooltipTrigger asChild>{row}</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" className="max-w-xs">
|
||||||
|
<span className="typography-meta text-foreground/80 whitespace-pre-wrap break-all">
|
||||||
|
{rawLabel}
|
||||||
|
</span>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFileTree = (file: FileInfo, level: number): React.ReactNode => {
|
||||||
|
const isDirectory = file.type === 'directory';
|
||||||
|
const children = isDirectory ? getChildItems(file.path) : [];
|
||||||
|
const isExpanded = expandedDirs.has(file.path);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={file.path}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center justify-start gap-1 px-2 py-1.5 rounded cursor-pointer typography-ui-label text-foreground text-left',
|
||||||
|
!isDirectory && selectedFiles.has(file.path) && 'bg-primary/10'
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: `${level * 12}px` }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isDirectory) {
|
||||||
|
toggleDirectory(file.path);
|
||||||
|
} else {
|
||||||
|
toggleFileSelection(file.path);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground">{getFileIcon(file)}</span>
|
||||||
|
<span className="flex-1 truncate text-foreground text-left">
|
||||||
|
{file.name}
|
||||||
|
</span>
|
||||||
|
{!isDirectory && selectedFiles.has(file.path) && (
|
||||||
|
<div className="h-1.5 w-1.5 rounded-full bg-primary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isDirectory && isExpanded && children.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{children.map((child) => renderFileTree(child, level + 1))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const summaryLabel = selectedFiles.size > 0
|
||||||
|
? `${selectedFiles.size} file${selectedFiles.size !== 1 ? 's' : ''} selected`
|
||||||
|
: 'No files selected';
|
||||||
|
|
||||||
|
const summarySection = (
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 shrink-0">
|
||||||
|
<div className="typography-meta text-muted-foreground">{summaryLabel}</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={selectedFiles.size === 0 || attaching}
|
||||||
|
className="h-6 typography-meta"
|
||||||
|
>
|
||||||
|
{attaching ? 'Attaching...' : 'Attach Files'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const scrollAreaClass = isMobile ? 'flex-1 min-h-[240px]' : 'h-[300px]';
|
||||||
|
|
||||||
|
const pickerBody = (
|
||||||
|
<>
|
||||||
|
<div className="px-3 py-2 border-b shrink-0">
|
||||||
|
<div className="font-medium typography-ui-label text-foreground">Select Project Files</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 border-b shrink-0">
|
||||||
|
<div className="relative">
|
||||||
|
<RiSearchLine className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search files..."
|
||||||
|
className="pl-7 h-6 typography-ui-label"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSearchQuery('');
|
||||||
|
}}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 hover:bg-accent rounded"
|
||||||
|
>
|
||||||
|
<RiCloseLine className="h-3 w-3"/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className={scrollAreaClass}>
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="typography-ui-label text-muted-foreground">Loading files...</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="typography-ui-label text-destructive">{error}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<div className="py-1 px-2">
|
||||||
|
{isSearchActive ? (
|
||||||
|
searching ? (
|
||||||
|
<div className="px-3 py-4 typography-ui-label text-muted-foreground text-center">
|
||||||
|
Searching files…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
searchResults.map((file) => renderFileItem(file, 0))
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
rootItems.map((file) => renderFileTree(file, 0))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isSearchActive && rootItems.length === 0 && (
|
||||||
|
<div className="px-3 py-4 typography-ui-label text-muted-foreground text-center">
|
||||||
|
No files in this directory
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSearchActive && !searching && searchResults.length === 0 && (
|
||||||
|
<div className="px-3 py-4 typography-ui-label text-muted-foreground text-center">
|
||||||
|
No files found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const mobileTrigger = (
|
||||||
|
<span
|
||||||
|
className="inline-flex cursor-pointer"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setMobileOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{mobileTrigger}
|
||||||
|
<MobileOverlayPanel
|
||||||
|
open={mobileOpen}
|
||||||
|
onClose={() => setMobileOpen(false)}
|
||||||
|
title="Select Project Files"
|
||||||
|
footer={summarySection}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-0">{pickerBody}</div>
|
||||||
|
</MobileOverlayPanel>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="w-[520px] p-0 overflow-hidden flex flex-col ml-16"
|
||||||
|
align="center"
|
||||||
|
sideOffset={5}
|
||||||
|
>
|
||||||
|
{pickerBody}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{summarySection}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Message, Part } from '@opencode-ai/sdk';
|
||||||
|
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
||||||
|
|
||||||
|
export interface ChatMessageEntry {
|
||||||
|
info: Message;
|
||||||
|
parts: Part[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Turn {
|
||||||
|
turnId: string;
|
||||||
|
userMessage: ChatMessageEntry;
|
||||||
|
assistantMessages: ChatMessageEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TurnActivityKind = 'tool' | 'reasoning' | 'justification';
|
||||||
|
|
||||||
|
export interface TurnActivityPart {
|
||||||
|
id: string;
|
||||||
|
turnId: string;
|
||||||
|
messageId: string;
|
||||||
|
kind: TurnActivityKind;
|
||||||
|
part: Part;
|
||||||
|
endedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TurnDiffStats {
|
||||||
|
additions: number;
|
||||||
|
deletions: number;
|
||||||
|
files: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnGroupingContext {
|
||||||
|
turnId: string;
|
||||||
|
isFirstAssistantInTurn: boolean;
|
||||||
|
|
||||||
|
summaryBody?: string;
|
||||||
|
|
||||||
|
activityParts: TurnActivityPart[];
|
||||||
|
hasTools: boolean;
|
||||||
|
hasReasoning: boolean;
|
||||||
|
diffStats?: TurnDiffStats;
|
||||||
|
|
||||||
|
isWorking: boolean;
|
||||||
|
isGroupExpanded: boolean;
|
||||||
|
|
||||||
|
previewedPartIds: Set<string>;
|
||||||
|
toggleGroup: () => void;
|
||||||
|
markPartsPreviewed: (partIds: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TurnUiState {
|
||||||
|
isExpanded: boolean;
|
||||||
|
previewedPartIds: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TurnActivityInfo {
|
||||||
|
activityParts: TurnActivityPart[];
|
||||||
|
hasTools: boolean;
|
||||||
|
hasReasoning: boolean;
|
||||||
|
summaryBody?: string;
|
||||||
|
diffStats?: TurnDiffStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENABLE_TEXT_JUSTIFICATION_ACTIVITY = false;
|
||||||
|
|
||||||
|
export const detectTurns = (messages: ChatMessageEntry[]): Turn[] => {
|
||||||
|
const result: Turn[] = [];
|
||||||
|
let currentTurn: Turn | null = null;
|
||||||
|
|
||||||
|
messages.forEach((msg) => {
|
||||||
|
const role = (msg.info as { clientRole?: string | null | undefined }).clientRole ?? msg.info.role;
|
||||||
|
|
||||||
|
if (role === 'user') {
|
||||||
|
currentTurn = {
|
||||||
|
turnId: msg.info.id,
|
||||||
|
userMessage: msg,
|
||||||
|
assistantMessages: [],
|
||||||
|
};
|
||||||
|
result.push(currentTurn);
|
||||||
|
} else if (role === 'assistant' && currentTurn) {
|
||||||
|
currentTurn.assistantMessages.push(msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractFinalAssistantText = (turn: Turn): string | undefined => {
|
||||||
|
|
||||||
|
for (const assistantMsg of turn.assistantMessages) {
|
||||||
|
|
||||||
|
for (const part of assistantMsg.parts) {
|
||||||
|
if (part.type === 'step-finish') {
|
||||||
|
const finishReason = (part as { reason?: string | null | undefined }).reason;
|
||||||
|
const infoFinish = (assistantMsg.info as { finish?: string | null | undefined }).finish;
|
||||||
|
|
||||||
|
if (finishReason === 'stop' || infoFinish === 'stop') {
|
||||||
|
|
||||||
|
const textPart = assistantMsg.parts.find(p => p.type === 'text');
|
||||||
|
if (textPart) {
|
||||||
|
const textContent = (textPart as { text?: string | null | undefined }).text ??
|
||||||
|
(textPart as { content?: string | null | undefined }).content;
|
||||||
|
if (typeof textContent === 'string' && textContent.trim().length > 0) {
|
||||||
|
return textContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTurnActivityInfo = (turn: Turn): TurnActivityInfo => {
|
||||||
|
interface SummaryDiff {
|
||||||
|
additions?: number | null | undefined;
|
||||||
|
deletions?: number | null | undefined;
|
||||||
|
file?: string | null | undefined;
|
||||||
|
}
|
||||||
|
interface UserSummaryPayload {
|
||||||
|
body?: string | null | undefined;
|
||||||
|
diffs?: SummaryDiff[] | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryBody = extractFinalAssistantText(turn);
|
||||||
|
|
||||||
|
let diffStats: TurnDiffStats | undefined;
|
||||||
|
|
||||||
|
const summary = (turn.userMessage.info as { summary?: UserSummaryPayload | null | undefined }).summary;
|
||||||
|
const diffs = summary?.diffs;
|
||||||
|
if (Array.isArray(diffs) && diffs.length > 0) {
|
||||||
|
let additions = 0;
|
||||||
|
let deletions = 0;
|
||||||
|
let files = 0;
|
||||||
|
|
||||||
|
diffs.forEach((diff) => {
|
||||||
|
if (!diff) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const diffAdditions = typeof diff.additions === 'number' ? diff.additions : 0;
|
||||||
|
const diffDeletions = typeof diff.deletions === 'number' ? diff.deletions : 0;
|
||||||
|
|
||||||
|
if (diffAdditions !== 0 || diffDeletions !== 0) {
|
||||||
|
files += 1;
|
||||||
|
}
|
||||||
|
additions += diffAdditions;
|
||||||
|
deletions += diffDeletions;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (files > 0) {
|
||||||
|
diffStats = {
|
||||||
|
additions,
|
||||||
|
deletions,
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasTools = false;
|
||||||
|
let hasReasoning = false;
|
||||||
|
|
||||||
|
turn.assistantMessages.forEach((msg) => {
|
||||||
|
msg.parts.forEach((part) => {
|
||||||
|
if (part.type === 'tool') {
|
||||||
|
hasTools = true;
|
||||||
|
} else if (part.type === 'reasoning') {
|
||||||
|
hasReasoning = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const activityParts: TurnActivityPart[] = [];
|
||||||
|
let syntheticIdCounter = 0;
|
||||||
|
|
||||||
|
turn.assistantMessages.forEach((msg) => {
|
||||||
|
const messageId = msg.info.id;
|
||||||
|
const hasStopFinishInMessage = ENABLE_TEXT_JUSTIFICATION_ACTIVITY
|
||||||
|
? msg.parts.some((part) => {
|
||||||
|
if (part.type !== 'step-finish') return false;
|
||||||
|
const reason = (part as { reason?: string | null | undefined }).reason;
|
||||||
|
return reason === 'stop';
|
||||||
|
})
|
||||||
|
: false;
|
||||||
|
|
||||||
|
msg.parts.forEach((part) => {
|
||||||
|
const baseId =
|
||||||
|
(typeof part.id === 'string' && part.id.trim().length > 0)
|
||||||
|
? part.id
|
||||||
|
: `${messageId}-activity-${syntheticIdCounter++}`;
|
||||||
|
|
||||||
|
if (part.type === 'tool') {
|
||||||
|
const state = (part as { state?: { time?: { end?: number | null | undefined } | null | undefined } | null | undefined }).state;
|
||||||
|
const time = state?.time;
|
||||||
|
const end = typeof time?.end === 'number' ? time.end : undefined;
|
||||||
|
|
||||||
|
activityParts.push({
|
||||||
|
id: baseId,
|
||||||
|
turnId: turn.turnId,
|
||||||
|
messageId,
|
||||||
|
kind: 'tool',
|
||||||
|
part,
|
||||||
|
endedAt: end,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type === 'reasoning') {
|
||||||
|
const text = (part as { text?: string | null | undefined; content?: string | null | undefined }).text
|
||||||
|
?? (part as { text?: string | null | undefined; content?: string | null | undefined }).content;
|
||||||
|
if (typeof text !== 'string' || text.trim().length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const time = (part as { time?: { end?: number | null | undefined } | null | undefined }).time;
|
||||||
|
const end = typeof time?.end === 'number' ? time.end : undefined;
|
||||||
|
|
||||||
|
activityParts.push({
|
||||||
|
id: baseId,
|
||||||
|
turnId: turn.turnId,
|
||||||
|
messageId,
|
||||||
|
kind: 'reasoning',
|
||||||
|
part,
|
||||||
|
endedAt: end,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
ENABLE_TEXT_JUSTIFICATION_ACTIVITY &&
|
||||||
|
part.type === 'text' &&
|
||||||
|
(hasTools || hasReasoning) &&
|
||||||
|
!hasStopFinishInMessage
|
||||||
|
) {
|
||||||
|
const text =
|
||||||
|
(part as { text?: string | null | undefined; content?: string | null | undefined }).text ??
|
||||||
|
(part as { text?: string | null | undefined; content?: string | null | undefined }).content;
|
||||||
|
if (typeof text !== 'string' || text.trim().length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const time = (part as { time?: { end?: number | null | undefined } | null | undefined }).time;
|
||||||
|
const end = typeof time?.end === 'number' ? time.end : undefined;
|
||||||
|
|
||||||
|
activityParts.push({
|
||||||
|
id: baseId,
|
||||||
|
turnId: turn.turnId,
|
||||||
|
messageId,
|
||||||
|
kind: 'justification',
|
||||||
|
part,
|
||||||
|
endedAt: end,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
activityParts,
|
||||||
|
hasTools,
|
||||||
|
hasReasoning,
|
||||||
|
summaryBody,
|
||||||
|
diffStats,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UseTurnGroupingResult {
|
||||||
|
turns: Turn[];
|
||||||
|
getTurnForMessage: (messageId: string) => Turn | undefined;
|
||||||
|
getContextForMessage: (messageId: string) => TurnGroupingContext | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingResult => {
|
||||||
|
const { isWorking: sessionIsWorking } = useCurrentSessionActivity();
|
||||||
|
|
||||||
|
const turns = React.useMemo(() => detectTurns(messages), [messages]);
|
||||||
|
|
||||||
|
const lastTurnId = React.useMemo(() => {
|
||||||
|
if (turns.length === 0) return null;
|
||||||
|
return turns[turns.length - 1]!.turnId;
|
||||||
|
}, [turns]);
|
||||||
|
|
||||||
|
const messageToTurn = React.useMemo(() => {
|
||||||
|
const map = new Map<string, Turn>();
|
||||||
|
turns.forEach((turn) => {
|
||||||
|
map.set(turn.userMessage.info.id, turn);
|
||||||
|
turn.assistantMessages.forEach((msg) => {
|
||||||
|
map.set(msg.info.id, turn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [turns]);
|
||||||
|
|
||||||
|
const turnActivityInfo = React.useMemo(() => {
|
||||||
|
const map = new Map<string, TurnActivityInfo>();
|
||||||
|
turns.forEach((turn) => {
|
||||||
|
map.set(turn.turnId, getTurnActivityInfo(turn));
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [turns]);
|
||||||
|
|
||||||
|
const [turnUiStates, setTurnUiStates] = React.useState<Map<string, TurnUiState>>(
|
||||||
|
() => new Map()
|
||||||
|
);
|
||||||
|
|
||||||
|
const getOrCreateTurnState = React.useCallback(
|
||||||
|
(turnId: string): TurnUiState => {
|
||||||
|
const existing = turnUiStates.get(turnId);
|
||||||
|
if (existing) return existing;
|
||||||
|
return { isExpanded: false, previewedPartIds: new Set<string>() };
|
||||||
|
},
|
||||||
|
[turnUiStates]
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleGroup = React.useCallback((turnId: string) => {
|
||||||
|
setTurnUiStates((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const current = next.get(turnId) ?? { isExpanded: false, previewedPartIds: new Set<string>() };
|
||||||
|
next.set(turnId, { ...current, isExpanded: !current.isExpanded });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const markPartsPreviewedInternal = React.useCallback((turnId: string, partIds: string[]) => {
|
||||||
|
if (partIds.length === 0) return;
|
||||||
|
|
||||||
|
setTurnUiStates((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const state = next.get(turnId) ?? { isExpanded: false, previewedPartIds: new Set<string>() };
|
||||||
|
const newPreviewed = new Set(state.previewedPartIds);
|
||||||
|
partIds.forEach((id) => {
|
||||||
|
if (id && id.trim().length > 0) {
|
||||||
|
newPreviewed.add(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
next.set(turnId, { ...state, previewedPartIds: newPreviewed });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getTurnForMessage = React.useCallback(
|
||||||
|
(messageId: string): Turn | undefined => {
|
||||||
|
return messageToTurn.get(messageId);
|
||||||
|
},
|
||||||
|
[messageToTurn]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getContextForMessage = React.useCallback(
|
||||||
|
(messageId: string): TurnGroupingContext | undefined => {
|
||||||
|
const turn = messageToTurn.get(messageId);
|
||||||
|
if (!turn) return undefined;
|
||||||
|
|
||||||
|
const isAssistantMessage = turn.assistantMessages.some(
|
||||||
|
(msg) => msg.info.id === messageId
|
||||||
|
);
|
||||||
|
if (!isAssistantMessage) return undefined;
|
||||||
|
|
||||||
|
const activityInfo = turnActivityInfo.get(turn.turnId);
|
||||||
|
const activityParts = activityInfo?.activityParts ?? [];
|
||||||
|
const hasTools = Boolean(activityInfo?.hasTools);
|
||||||
|
const hasReasoning = Boolean(activityInfo?.hasReasoning);
|
||||||
|
const summaryBody = activityInfo?.summaryBody;
|
||||||
|
const diffStats = activityInfo?.diffStats;
|
||||||
|
|
||||||
|
const firstAssistantId = turn.assistantMessages[0]?.info.id;
|
||||||
|
const isFirstAssistantInTurn = messageId === firstAssistantId;
|
||||||
|
|
||||||
|
const uiState = getOrCreateTurnState(turn.turnId);
|
||||||
|
const isTurnWorking = sessionIsWorking && lastTurnId === turn.turnId;
|
||||||
|
|
||||||
|
return {
|
||||||
|
turnId: turn.turnId,
|
||||||
|
isFirstAssistantInTurn,
|
||||||
|
summaryBody,
|
||||||
|
activityParts,
|
||||||
|
hasTools,
|
||||||
|
hasReasoning,
|
||||||
|
diffStats,
|
||||||
|
isWorking: isTurnWorking,
|
||||||
|
isGroupExpanded: uiState.isExpanded,
|
||||||
|
previewedPartIds: uiState.previewedPartIds,
|
||||||
|
toggleGroup: () => toggleGroup(turn.turnId),
|
||||||
|
markPartsPreviewed: (partIds: string[]) => markPartsPreviewedInternal(turn.turnId, partIds),
|
||||||
|
};
|
||||||
|
}, [getOrCreateTurnState, lastTurnId, sessionIsWorking, markPartsPreviewedInternal, messageToTurn, toggleGroup, turnActivityInfo]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
turns,
|
||||||
|
getTurnForMessage,
|
||||||
|
getContextForMessage,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { RiAlignJustify, RiLayoutColumnLine } from '@remixicon/react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export type DiffViewMode = 'side-by-side' | 'unified';
|
||||||
|
|
||||||
|
interface DiffViewToggleProps {
|
||||||
|
mode: DiffViewMode;
|
||||||
|
onModeChange: (mode: DiffViewMode) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DiffViewToggle: React.FC<DiffViewToggleProps> = ({ mode, onModeChange, className }) => {
|
||||||
|
const handleClick = React.useCallback(
|
||||||
|
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onModeChange(mode === 'side-by-side' ? 'unified' : 'side-by-side');
|
||||||
|
},
|
||||||
|
[mode, onModeChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className={cn('h-5 w-5 p-0 opacity-60 hover:opacity-100', className)}
|
||||||
|
onClick={handleClick}
|
||||||
|
title={mode === 'side-by-side' ? 'Switch to unified view' : 'Switch to side-by-side view'}
|
||||||
|
>
|
||||||
|
{mode === 'side-by-side' ? (
|
||||||
|
<RiAlignJustify className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<RiLayoutColumnLine className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface FadeInOnRevealProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FADE_ANIMATION_ENABLED = true;
|
||||||
|
|
||||||
|
export const FadeInOnReveal: React.FC<FadeInOnRevealProps> = ({ children, className }) => {
|
||||||
|
const [visible, setVisible] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!FADE_ANIMATION_ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let frame: number | null = null;
|
||||||
|
|
||||||
|
const enable = () => setVisible(true);
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||||
|
frame = window.requestAnimationFrame(enable);
|
||||||
|
} else {
|
||||||
|
enable();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (
|
||||||
|
frame !== null &&
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
typeof window.cancelAnimationFrame === 'function'
|
||||||
|
) {
|
||||||
|
window.cancelAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!FADE_ANIMATION_ENABLED) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-full transition-all duration-300 ease-out',
|
||||||
|
visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { RiBrainAi3Line, RiUser3Line } from '@remixicon/react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { getAgentColor } from '@/lib/agentColors';
|
||||||
|
import { FadeInOnReveal } from './FadeInOnReveal';
|
||||||
|
import { useProviderLogo } from '@/hooks/useProviderLogo';
|
||||||
|
|
||||||
|
interface MessageHeaderProps {
|
||||||
|
isUser: boolean;
|
||||||
|
providerID: string | null;
|
||||||
|
agentName: string | undefined;
|
||||||
|
modelName: string | undefined;
|
||||||
|
isDarkTheme: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MessageHeader: React.FC<MessageHeaderProps> = ({ isUser, providerID, agentName, modelName, isDarkTheme }) => {
|
||||||
|
const { src: logoSrc, onError: handleLogoError, hasLogo } = useProviderLogo(providerID);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FadeInOnReveal>
|
||||||
|
<div className={cn('pl-3', 'mb-2')}>
|
||||||
|
<div className={cn('flex items-center justify-between gap-2')}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{isUser ? (
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||||
|
<RiUser3Line className="h-4 w-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
{hasLogo && logoSrc ? (
|
||||||
|
<img
|
||||||
|
src={logoSrc}
|
||||||
|
alt={`${providerID} logo`}
|
||||||
|
className="h-4 w-4"
|
||||||
|
style={{
|
||||||
|
filter: isDarkTheme ? 'brightness(0.9) contrast(1.1) invert(1)' : 'brightness(0.9) contrast(1.1)',
|
||||||
|
}}
|
||||||
|
onError={handleLogoError}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RiBrainAi3Line
|
||||||
|
className="h-4 w-4"
|
||||||
|
style={{ color: `var(${getAgentColor(agentName).var})` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3
|
||||||
|
className={cn(
|
||||||
|
'font-bold typography-ui-header tracking-tight leading-none',
|
||||||
|
isUser ? 'text-primary' : 'text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isUser ? 'You' : (modelName || 'Assistant')}
|
||||||
|
</h3>
|
||||||
|
{!isUser && agentName && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1 px-1.5 py-0 rounded',
|
||||||
|
'agent-badge typography-meta',
|
||||||
|
getAgentColor(agentName).class
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{agentName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FadeInOnReveal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(MessageHeader);
|
||||||
@@ -0,0 +1,563 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||||
|
import { RiBrainAi3Line, RiFileImageLine, RiFilePdfLine, RiFileSearchLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiPencilAiLine, RiSearchLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
|
||||||
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
|
import { Streamdown } from 'streamdown';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { toolDisplayStyles } from '@/lib/typography';
|
||||||
|
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
||||||
|
import {
|
||||||
|
renderTodoOutput,
|
||||||
|
renderListOutput,
|
||||||
|
renderGrepOutput,
|
||||||
|
renderGlobOutput,
|
||||||
|
renderWebSearchOutput,
|
||||||
|
formatInputForDisplay,
|
||||||
|
parseDiffToUnified,
|
||||||
|
type UnifiedDiffHunk,
|
||||||
|
type SideBySideDiffHunk,
|
||||||
|
type SideBySideDiffLine,
|
||||||
|
} from './toolRenderers';
|
||||||
|
import type { ToolPopupContent, DiffViewMode } from './types';
|
||||||
|
import { DiffViewToggle } from './DiffViewToggle';
|
||||||
|
|
||||||
|
interface ToolOutputDialogProps {
|
||||||
|
popup: ToolPopupContent;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
syntaxTheme: { [key: string]: React.CSSProperties };
|
||||||
|
isMobile: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getToolIcon = (toolName: string) => {
|
||||||
|
const iconClass = 'h-3.5 w-3.5 flex-shrink-0';
|
||||||
|
const tool = toolName.toLowerCase();
|
||||||
|
|
||||||
|
if (tool === 'reasoning') {
|
||||||
|
return <RiBrainAi3Line className={iconClass} />;
|
||||||
|
}
|
||||||
|
if (tool === 'image-preview') {
|
||||||
|
return <RiFileImageLine className={iconClass} />;
|
||||||
|
}
|
||||||
|
if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
|
||||||
|
return <RiPencilAiLine className={iconClass} />;
|
||||||
|
}
|
||||||
|
if (tool === 'write' || tool === 'create' || tool === 'file_write') {
|
||||||
|
return <RiFilePdfLine className={iconClass} />;
|
||||||
|
}
|
||||||
|
if (tool === 'read' || tool === 'view' || tool === 'file_read' || tool === 'cat') {
|
||||||
|
return <RiFilePdfLine className={iconClass} />;
|
||||||
|
}
|
||||||
|
if (tool === 'bash' || tool === 'shell' || tool === 'cmd' || tool === 'terminal') {
|
||||||
|
return <RiTerminalBoxLine className={iconClass} />;
|
||||||
|
}
|
||||||
|
if (tool === 'list' || tool === 'ls' || tool === 'dir' || tool === 'list_files') {
|
||||||
|
return <RiFolder6Line className={iconClass} />;
|
||||||
|
}
|
||||||
|
if (tool === 'search' || tool === 'grep' || tool === 'find' || tool === 'ripgrep') {
|
||||||
|
return <RiSearchLine className={iconClass} />;
|
||||||
|
}
|
||||||
|
if (tool === 'glob') {
|
||||||
|
return <RiFileSearchLine className={iconClass} />;
|
||||||
|
}
|
||||||
|
if (tool === 'fetch' || tool === 'curl' || tool === 'wget' || tool === 'webfetch') {
|
||||||
|
return <RiGlobalLine className={iconClass} />;
|
||||||
|
}
|
||||||
|
if (tool === 'web-search' || tool === 'websearch' || tool === 'search_web' || tool === 'google' || tool === 'bing' || tool === 'duckduckgo') {
|
||||||
|
return <RiSearchLine className={iconClass} />;
|
||||||
|
}
|
||||||
|
if (tool === 'todowrite' || tool === 'todoread') {
|
||||||
|
return <RiListCheck3 className={iconClass} />;
|
||||||
|
}
|
||||||
|
if (tool.startsWith('git')) {
|
||||||
|
return <RiGitBranchLine className={iconClass} />;
|
||||||
|
}
|
||||||
|
return <RiToolsLine className={iconClass} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange, syntaxTheme, isMobile }) => {
|
||||||
|
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>(isMobile ? 'unified' : 'side-by-side');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={popup.open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
'overflow-hidden flex flex-col min-h-0 pt-3 pb-4 px-4 gap-1',
|
||||||
|
'[&>button]:top-1.5',
|
||||||
|
isMobile ? 'w-[95vw] max-w-[95vw]' : 'max-w-5xl',
|
||||||
|
isMobile ? '[&>button]:right-1' : '[&>button]:top-2.5 [&>button]:right-4'
|
||||||
|
)}
|
||||||
|
style={{ maxHeight: '90vh' }}
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 pb-1">
|
||||||
|
<div className="flex items-start gap-2 text-foreground typography-ui-header font-semibold">
|
||||||
|
{popup.metadata?.tool ? getToolIcon(popup.metadata.tool as string) : (
|
||||||
|
<RiToolsLine className="h-3.5 w-3.5 text-foreground flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="break-words flex-1 leading-tight">{popup.title}</span>
|
||||||
|
{popup.isDiff && (
|
||||||
|
<DiffViewToggle
|
||||||
|
mode={diffViewMode}
|
||||||
|
onModeChange={setDiffViewMode}
|
||||||
|
className="mr-8 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 rounded-xl border border-border/30 bg-muted/10 overflow-hidden">
|
||||||
|
<div className="h-full max-h-[75vh] overflow-y-auto px-3 pr-4">
|
||||||
|
{popup.metadata?.input && typeof popup.metadata.input === 'object' &&
|
||||||
|
Object.keys(popup.metadata.input).length > 0 &&
|
||||||
|
popup.metadata?.tool !== 'todowrite' &&
|
||||||
|
popup.metadata?.tool !== 'todoread' ? (() => {
|
||||||
|
const meta = popup.metadata!;
|
||||||
|
const input = meta.input as Record<string, unknown>;
|
||||||
|
|
||||||
|
const getInputValue = (key: string): string | null => {
|
||||||
|
const val = input[key];
|
||||||
|
return typeof val === 'string' ? val : (typeof val === 'number' ? String(val) : null);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border/20 p-4 -mx-3">
|
||||||
|
<div className="typography-markdown font-medium text-muted-foreground mb-2 px-3">
|
||||||
|
{meta.tool === 'bash'
|
||||||
|
? 'Command:'
|
||||||
|
: meta.tool === 'task'
|
||||||
|
? 'Task Details:'
|
||||||
|
: 'Input:'}
|
||||||
|
</div>
|
||||||
|
{meta.tool === 'bash' && getInputValue('command') ? (
|
||||||
|
<div className="bg-muted/30 rounded-xl border border-border/20 mx-3">
|
||||||
|
<SyntaxHighlighter
|
||||||
|
style={syntaxTheme}
|
||||||
|
language="bash"
|
||||||
|
PreTag="div"
|
||||||
|
customStyle={toolDisplayStyles.getPopupStyles()}
|
||||||
|
wrapLongLines
|
||||||
|
>
|
||||||
|
{getInputValue('command')!}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</div>
|
||||||
|
) : meta.tool === 'task' && getInputValue('prompt') ? (
|
||||||
|
<pre
|
||||||
|
className="bg-muted/30 p-3 rounded-xl border border-border/20 font-mono whitespace-pre-wrap text-foreground/90 mx-3"
|
||||||
|
style={toolDisplayStyles.getPopupStyles()}
|
||||||
|
>
|
||||||
|
{getInputValue('description') ? `Task: ${getInputValue('description')}\n` : ''}
|
||||||
|
{getInputValue('subagent_type') ? `Agent Type: ${getInputValue('subagent_type')}\n` : ''}
|
||||||
|
{`Instructions:\n${getInputValue('prompt')}`}
|
||||||
|
</pre>
|
||||||
|
) : meta.tool === 'write' && getInputValue('content') ? (
|
||||||
|
<div className="bg-muted/30 rounded-xl border border-border/20 mx-3">
|
||||||
|
<SyntaxHighlighter
|
||||||
|
style={syntaxTheme}
|
||||||
|
language={getLanguageFromExtension(getInputValue('filePath') || getInputValue('file_path') || '') || 'text'}
|
||||||
|
PreTag="div"
|
||||||
|
customStyle={toolDisplayStyles.getPopupStyles()}
|
||||||
|
wrapLongLines
|
||||||
|
>
|
||||||
|
{getInputValue('content')!}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<pre
|
||||||
|
className="bg-muted/30 p-3 rounded-xl border border-border/20 font-mono whitespace-pre-wrap text-foreground/90 mx-3"
|
||||||
|
style={toolDisplayStyles.getPopupStyles()}
|
||||||
|
>
|
||||||
|
{formatInputForDisplay(input, meta.tool as string)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})() : null}
|
||||||
|
|
||||||
|
{popup.isDiff ? (
|
||||||
|
diffViewMode === 'unified' ? (
|
||||||
|
<div className="typography-markdown">
|
||||||
|
{parseDiffToUnified(popup.content).map((hunk, hunkIdx) => (
|
||||||
|
<div key={hunkIdx} className="border-b border-border/20 last:border-b-0">
|
||||||
|
<div
|
||||||
|
className={cn('bg-muted/20 px-3 py-2 font-medium text-muted-foreground border-b border-border/10 sticky top-0 z-10 break-words -mx-3', isMobile ? 'typography-micro' : 'typography-markdown')}
|
||||||
|
>
|
||||||
|
{`${hunk.file} (line ${hunk.oldStart})`}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{hunk.lines.map((line, lineIdx) => (
|
||||||
|
<div
|
||||||
|
key={lineIdx}
|
||||||
|
className={cn(
|
||||||
|
'typography-markdown font-mono px-3 py-0.5 flex',
|
||||||
|
line.type === 'context' && 'bg-transparent',
|
||||||
|
line.type === 'removed' && 'bg-transparent',
|
||||||
|
line.type === 'added' && 'bg-transparent'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
lineHeight: '1.1',
|
||||||
|
...(line.type === 'removed'
|
||||||
|
? { backgroundColor: 'var(--tools-edit-removed-bg)' }
|
||||||
|
: line.type === 'added'
|
||||||
|
? { backgroundColor: 'var(--tools-edit-added-bg)' }
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground/60 w-10 flex-shrink-0 text-right pr-3 self-start select-none">
|
||||||
|
{line.lineNumber || ''}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{(() => {
|
||||||
|
const inputFile = (typeof popup.metadata?.input === 'object' && popup.metadata.input !== null)
|
||||||
|
? ((popup.metadata.input as Record<string, unknown>).file_path || (popup.metadata.input as Record<string, unknown>).filePath)
|
||||||
|
: null;
|
||||||
|
const fileStr = typeof inputFile === 'string' ? inputFile : '';
|
||||||
|
const hunkFile = (typeof hunk === 'object' && hunk !== null && 'file' in hunk) ? (hunk as UnifiedDiffHunk).file : null;
|
||||||
|
const hunkFileStr = typeof hunkFile === 'string' ? hunkFile : '';
|
||||||
|
const finalFile = fileStr || hunkFileStr || '';
|
||||||
|
return (
|
||||||
|
<SyntaxHighlighter
|
||||||
|
style={syntaxTheme}
|
||||||
|
language={getLanguageFromExtension(finalFile) || 'text'}
|
||||||
|
PreTag="div"
|
||||||
|
wrapLines
|
||||||
|
wrapLongLines
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
fontSize: 'inherit',
|
||||||
|
background: 'transparent !important',
|
||||||
|
borderRadius: 0,
|
||||||
|
overflow: 'visible',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
overflowWrap: 'anywhere',
|
||||||
|
}}
|
||||||
|
codeTagProps={{
|
||||||
|
style: {
|
||||||
|
background: 'transparent !important',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{line.content}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : popup.diffHunks ? (
|
||||||
|
<div className="typography-markdown">
|
||||||
|
{popup.diffHunks.map((hunk, hunkIdx) => (
|
||||||
|
<div key={hunkIdx} className="border-b border-border/20 last:border-b-0">
|
||||||
|
<div
|
||||||
|
className={cn('bg-muted/20 px-3 py-2 font-medium text-muted-foreground border-b border-border/10 sticky top-0 z-10 break-words -mx-3', isMobile ? 'typography-micro' : 'typography-markdown')}
|
||||||
|
>
|
||||||
|
{`${hunk.file} (line ${hunk.oldStart})`}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{(hunk as unknown as SideBySideDiffHunk).lines.map((line: SideBySideDiffLine, lineIdx: number) => (
|
||||||
|
<div key={lineIdx} className="grid grid-cols-2 divide-x divide-border/20">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'typography-markdown font-mono px-3 py-0.5 overflow-hidden',
|
||||||
|
line.leftLine.type === 'context' && 'bg-transparent',
|
||||||
|
line.leftLine.type === 'empty' && 'bg-transparent'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
lineHeight: '1.1',
|
||||||
|
...(line.leftLine.type === 'removed' ? { backgroundColor: 'var(--tools-edit-removed-bg)' } : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-muted-foreground/60 w-10 flex-shrink-0 text-right pr-3 self-start select-none">
|
||||||
|
{line.leftLine.lineNumber || ''}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{line.leftLine.content && (
|
||||||
|
<SyntaxHighlighter
|
||||||
|
style={syntaxTheme}
|
||||||
|
language={(() => {
|
||||||
|
const input = popup.metadata?.input;
|
||||||
|
const inputObj = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
|
||||||
|
const filePath = inputObj.file_path || inputObj.filePath;
|
||||||
|
const hunkFile = (hunk as unknown as UnifiedDiffHunk).file;
|
||||||
|
return getLanguageFromExtension(typeof filePath === 'string' ? filePath : hunkFile) || 'text';
|
||||||
|
})()}
|
||||||
|
PreTag="div"
|
||||||
|
wrapLines
|
||||||
|
wrapLongLines
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
fontSize: 'inherit',
|
||||||
|
background: 'transparent !important',
|
||||||
|
borderRadius: 0,
|
||||||
|
overflow: 'visible',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
overflowWrap: 'anywhere',
|
||||||
|
}}
|
||||||
|
codeTagProps={{
|
||||||
|
style: {
|
||||||
|
background: 'transparent !important',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{line.leftLine.content}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'typography-markdown font-mono px-3 py-0.5 overflow-hidden',
|
||||||
|
line.rightLine.type === 'context' && 'bg-transparent',
|
||||||
|
line.rightLine.type === 'empty' && 'bg-transparent'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
lineHeight: '1.1',
|
||||||
|
...(line.rightLine.type === 'added' ? { backgroundColor: 'var(--tools-edit-added-bg)' } : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-muted-foreground/60 w-10 flex-shrink-0 text-right pr-3 self-start select-none">
|
||||||
|
{line.rightLine.lineNumber || ''}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{line.rightLine.content && (
|
||||||
|
<SyntaxHighlighter
|
||||||
|
style={syntaxTheme}
|
||||||
|
language={(() => {
|
||||||
|
const input = popup.metadata?.input;
|
||||||
|
const inputObj = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
|
||||||
|
const filePath = inputObj.file_path || inputObj.filePath;
|
||||||
|
const hunkFile = (hunk as unknown as UnifiedDiffHunk).file;
|
||||||
|
return getLanguageFromExtension(typeof filePath === 'string' ? filePath : hunkFile) || 'text';
|
||||||
|
})()}
|
||||||
|
PreTag="div"
|
||||||
|
wrapLines
|
||||||
|
wrapLongLines
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
fontSize: 'inherit',
|
||||||
|
background: 'transparent !important',
|
||||||
|
borderRadius: 0,
|
||||||
|
overflow: 'visible',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
overflowWrap: 'anywhere',
|
||||||
|
}}
|
||||||
|
codeTagProps={{
|
||||||
|
style: {
|
||||||
|
background: 'transparent !important',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{line.rightLine.content}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
) : popup.image ? (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="max-h-[70vh] overflow-hidden rounded-2xl border border-border/40 bg-muted/10">
|
||||||
|
<img
|
||||||
|
src={popup.image.url}
|
||||||
|
alt={popup.image.filename || popup.title || 'Image preview'}
|
||||||
|
className="block h-full max-h-[70vh] w-auto max-w-full object-contain"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{popup.image.filename && (
|
||||||
|
<span className="typography-meta text-muted-foreground text-center">
|
||||||
|
{popup.image.filename}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : popup.content ? (
|
||||||
|
<div className="p-4">
|
||||||
|
{(() => {
|
||||||
|
const tool = popup.metadata?.tool;
|
||||||
|
|
||||||
|
if (tool === 'todowrite' || tool === 'todoread') {
|
||||||
|
return (
|
||||||
|
renderTodoOutput(popup.content) || (
|
||||||
|
<SyntaxHighlighter
|
||||||
|
style={syntaxTheme}
|
||||||
|
language="json"
|
||||||
|
PreTag="div"
|
||||||
|
wrapLongLines
|
||||||
|
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
||||||
|
codeTagProps={{ style: { background: 'transparent !important' } }}
|
||||||
|
>
|
||||||
|
{popup.content}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool === 'list') {
|
||||||
|
return (
|
||||||
|
renderListOutput(popup.content) || (
|
||||||
|
<pre className="typography-markdown bg-muted/30 p-2 rounded-xl border border-border/20 font-mono whitespace-pre-wrap">
|
||||||
|
{popup.content}
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool === 'grep') {
|
||||||
|
return (
|
||||||
|
renderGrepOutput(popup.content, isMobile) || (
|
||||||
|
<pre className="typography-markdown bg-muted/30 p-2 rounded-xl border border-border/20 font-mono whitespace-pre-wrap">
|
||||||
|
{popup.content}
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool === 'glob') {
|
||||||
|
return (
|
||||||
|
renderGlobOutput(popup.content, isMobile) || (
|
||||||
|
<pre className="typography-markdown bg-muted/30 p-2 rounded-xl border border-border/20 font-mono whitespace-pre-wrap">
|
||||||
|
{popup.content}
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool === 'task' || tool === 'reasoning') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={tool === 'reasoning' ? "text-muted-foreground/70" : ""}
|
||||||
|
style={{ fontSize: 'var(--text-meta)' }}
|
||||||
|
>
|
||||||
|
<Streamdown mode="static" className="streamdown-content streamdown-tool">
|
||||||
|
{popup.content}
|
||||||
|
</Streamdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool === 'web-search' || tool === 'websearch' || tool === 'search_web') {
|
||||||
|
return (
|
||||||
|
renderWebSearchOutput(popup.content, syntaxTheme) || (
|
||||||
|
<SyntaxHighlighter
|
||||||
|
style={syntaxTheme}
|
||||||
|
language="text"
|
||||||
|
PreTag="div"
|
||||||
|
wrapLongLines
|
||||||
|
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
||||||
|
codeTagProps={{ style: { background: 'transparent !important' } }}
|
||||||
|
>
|
||||||
|
{popup.content}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool === 'read') {
|
||||||
|
const lines = popup.content.split('\n');
|
||||||
|
|
||||||
|
const inputMeta = popup.metadata?.input;
|
||||||
|
const inputObj = typeof inputMeta === 'object' && inputMeta !== null ? (inputMeta as Record<string, unknown>) : {};
|
||||||
|
const offset = typeof inputObj.offset === 'number' ? inputObj.offset : 0;
|
||||||
|
const limit = typeof inputObj.limit === 'number' ? inputObj.limit : undefined;
|
||||||
|
|
||||||
|
const isInfoMessage = (line: string) => line.trim().startsWith('(');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{lines.map((line: string, idx: number) => {
|
||||||
|
|
||||||
|
const isInfo = isInfoMessage(line);
|
||||||
|
|
||||||
|
const lineNumber = offset + idx + 1;
|
||||||
|
|
||||||
|
const shouldShowLineNumber = !isInfo && (limit === undefined || idx < limit);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={idx} className={`typography-markdown font-mono flex ${isInfo ? 'text-muted-foreground/70 italic' : ''}`}>
|
||||||
|
<span className="text-muted-foreground/60 w-10 flex-shrink-0 text-right pr-4 self-start select-none">
|
||||||
|
{shouldShowLineNumber ? lineNumber : ''}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{isInfo ? (
|
||||||
|
<div className="whitespace-pre-wrap break-words">{line}</div>
|
||||||
|
) : (
|
||||||
|
<SyntaxHighlighter
|
||||||
|
style={syntaxTheme}
|
||||||
|
language={popup.language || 'text'}
|
||||||
|
PreTag="div"
|
||||||
|
wrapLines
|
||||||
|
wrapLongLines
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
fontSize: 'inherit',
|
||||||
|
background: 'transparent !important',
|
||||||
|
borderRadius: 0,
|
||||||
|
overflow: 'visible',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
overflowWrap: 'anywhere',
|
||||||
|
}}
|
||||||
|
codeTagProps={{
|
||||||
|
style: {
|
||||||
|
background: 'transparent !important',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SyntaxHighlighter
|
||||||
|
style={syntaxTheme}
|
||||||
|
language={popup.language || 'text'}
|
||||||
|
PreTag="div"
|
||||||
|
wrapLongLines
|
||||||
|
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
||||||
|
codeTagProps={{ style: { background: 'transparent !important' } }}
|
||||||
|
>
|
||||||
|
{popup.content}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-8 text-muted-foreground typography-ui-header">
|
||||||
|
<div className="mb-2">Command completed successfully</div>
|
||||||
|
<div className="typography-markdown">No output was produced</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ToolOutputDialog;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Message } from '@opencode-ai/sdk';
|
||||||
|
|
||||||
|
export interface MessageRoleInfo {
|
||||||
|
role: string;
|
||||||
|
isUser: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deriveMessageRole = (
|
||||||
|
messageInfo: Message | (Message & { clientRole?: string; userMessageMarker?: boolean })
|
||||||
|
): MessageRoleInfo => {
|
||||||
|
const info = messageInfo as Message & { clientRole?: string; userMessageMarker?: boolean; origin?: string; source?: string };
|
||||||
|
const clientRole = info?.clientRole;
|
||||||
|
const serverRole = info?.role;
|
||||||
|
const userMarker = info?.userMessageMarker === true;
|
||||||
|
|
||||||
|
const isUser =
|
||||||
|
userMarker ||
|
||||||
|
clientRole === 'user' ||
|
||||||
|
serverRole === 'user' ||
|
||||||
|
info?.origin === 'user' ||
|
||||||
|
info?.source === 'user';
|
||||||
|
|
||||||
|
if (isUser) {
|
||||||
|
return {
|
||||||
|
role: 'user',
|
||||||
|
isUser: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: clientRole || serverRole || 'assistant',
|
||||||
|
isUser: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { Part } from '@opencode-ai/sdk';
|
||||||
|
|
||||||
|
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
||||||
|
|
||||||
|
export const extractTextContent = (part: Part): string => {
|
||||||
|
const partWithText = part as PartWithText;
|
||||||
|
const rawText = partWithText.text;
|
||||||
|
if (typeof rawText === 'string') {
|
||||||
|
return rawText;
|
||||||
|
}
|
||||||
|
return partWithText.content || partWithText.value || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isEmptyTextPart = (part: Part): boolean => {
|
||||||
|
if (part.type !== 'text') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const text = extractTextContent(part);
|
||||||
|
return !text || text.trim().length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PartWithSynthetic = Part & { synthetic?: boolean };
|
||||||
|
|
||||||
|
interface VisibleFilterOptions {
|
||||||
|
includeReasoning?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const filterVisibleParts = (parts: Part[], options: VisibleFilterOptions = {}): Part[] => {
|
||||||
|
const { includeReasoning = true } = options;
|
||||||
|
|
||||||
|
return parts.filter((part) => {
|
||||||
|
const partWithSynthetic = part as PartWithSynthetic;
|
||||||
|
const isSynthetic = Boolean(partWithSynthetic.synthetic);
|
||||||
|
if (isSynthetic) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!includeReasoning && part.type === 'reasoning') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const isPatchPart = part.type === 'patch';
|
||||||
|
|
||||||
|
return !isPatchPart;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
type PartWithTime = Part & { time?: { start?: number; end?: number } };
|
||||||
|
|
||||||
|
export const isFinalizedTextPart = (part: Part): boolean => {
|
||||||
|
if (part.type !== 'text') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const time = (part as PartWithTime).time;
|
||||||
|
return Boolean(time && typeof time.end !== 'undefined');
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Part } from '@opencode-ai/sdk';
|
||||||
|
import { MarkdownRenderer } from '../../MarkdownRenderer';
|
||||||
|
import type { StreamPhase } from '../types';
|
||||||
|
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||||
|
import { ReasoningTimelineBlock, formatReasoningText } from './ReasoningPart';
|
||||||
|
|
||||||
|
type PartWithText = Part & { text?: string; content?: string; value?: string; time?: { start?: number; end?: number } };
|
||||||
|
|
||||||
|
interface AssistantTextPartProps {
|
||||||
|
part: Part;
|
||||||
|
messageId: string;
|
||||||
|
streamPhase: StreamPhase;
|
||||||
|
allowAnimation: boolean;
|
||||||
|
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
|
||||||
|
renderAsReasoning?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
|
||||||
|
part,
|
||||||
|
messageId,
|
||||||
|
streamPhase,
|
||||||
|
allowAnimation,
|
||||||
|
onContentChange,
|
||||||
|
renderAsReasoning = false,
|
||||||
|
}) => {
|
||||||
|
const partWithText = part as PartWithText;
|
||||||
|
const rawText = partWithText.text;
|
||||||
|
const baseTextContent = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
|
||||||
|
const textContent = React.useMemo(() => {
|
||||||
|
if (renderAsReasoning) {
|
||||||
|
return formatReasoningText(baseTextContent);
|
||||||
|
}
|
||||||
|
return baseTextContent;
|
||||||
|
}, [baseTextContent, renderAsReasoning]);
|
||||||
|
const isStreamingPhase = streamPhase === 'streaming';
|
||||||
|
const isCooldownPhase = streamPhase === 'cooldown';
|
||||||
|
const wasStreamingRef = React.useRef(isStreamingPhase);
|
||||||
|
|
||||||
|
if (isStreamingPhase || isCooldownPhase) {
|
||||||
|
wasStreamingRef.current = true;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = partWithText.time;
|
||||||
|
const isFinalized = time && typeof time.end !== 'undefined';
|
||||||
|
|
||||||
|
if (!isFinalized && (!textContent || textContent.trim().length === 0)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!textContent || textContent.trim().length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renderAsReasoning) {
|
||||||
|
return (
|
||||||
|
<ReasoningTimelineBlock
|
||||||
|
key={part.id || `${messageId}-text`}
|
||||||
|
text={textContent}
|
||||||
|
variant="justification"
|
||||||
|
onContentChange={onContentChange}
|
||||||
|
blockId={part.id || `${messageId}-reasoning-text`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group/assistant-text relative break-words" key={part.id || `${messageId}-text`}>
|
||||||
|
<MarkdownRenderer
|
||||||
|
content={textContent}
|
||||||
|
part={part}
|
||||||
|
messageId={messageId}
|
||||||
|
isAnimated={allowAnimation}
|
||||||
|
isStreaming={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AssistantTextPart;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Part } from '@opencode-ai/sdk';
|
||||||
|
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||||
|
import { ReasoningTimelineBlock } from './ReasoningPart';
|
||||||
|
|
||||||
|
type PartWithText = Part & { text?: string; content?: string };
|
||||||
|
|
||||||
|
const cleanJustificationText = (text: string): string => {
|
||||||
|
if (typeof text !== 'string' || text.trim().length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.map((line: string) => line.replace(/^>\s?/, '').trimEnd())
|
||||||
|
.filter((line: string) => line.trim().length > 0)
|
||||||
|
.join('\n')
|
||||||
|
.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
interface JustificationBlockProps {
|
||||||
|
part: Part;
|
||||||
|
messageId: string;
|
||||||
|
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JustificationBlock: React.FC<JustificationBlockProps> = ({
|
||||||
|
part,
|
||||||
|
messageId,
|
||||||
|
onContentChange,
|
||||||
|
}) => {
|
||||||
|
const partWithText = part as PartWithText;
|
||||||
|
const rawText = partWithText.text || partWithText.content || '';
|
||||||
|
const textContent = React.useMemo(() => cleanJustificationText(rawText), [rawText]);
|
||||||
|
|
||||||
|
const timeInfo = 'time' in part ? (part.time as { start: number; end?: number }) : null;
|
||||||
|
if (!timeInfo?.end) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReasoningTimelineBlock
|
||||||
|
text={textContent}
|
||||||
|
variant="justification"
|
||||||
|
onContentChange={onContentChange}
|
||||||
|
blockId={part.id || `${messageId}-justification`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(JustificationBlock);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface MigratingPartProps {
|
||||||
|
|
||||||
|
isMigrating: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MigratingPart: React.FC<MigratingPartProps> = ({
|
||||||
|
isMigrating,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
|
||||||
|
'w-full overflow-hidden',
|
||||||
|
isMigrating && 'pointer-events-none',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={isMigrating ? { animation: 'oc-migrate-up 220ms ease-out forwards' } : undefined}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(MigratingPart);
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { RiArrowDownSLine, RiArrowRightSLine, RiStackLine } from '@remixicon/react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { TurnActivityPart } from '../../hooks/useTurnGrouping';
|
||||||
|
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk';
|
||||||
|
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||||
|
import type { ToolPopupContent } from '../types';
|
||||||
|
import ToolPart from './ToolPart';
|
||||||
|
import ReasoningPart from './ReasoningPart';
|
||||||
|
import JustificationBlock from './JustificationBlock';
|
||||||
|
import { FadeInOnReveal } from '../FadeInOnReveal';
|
||||||
|
|
||||||
|
interface DiffStats {
|
||||||
|
additions: number;
|
||||||
|
deletions: number;
|
||||||
|
files: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProgressiveGroupProps {
|
||||||
|
parts: TurnActivityPart[];
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
syntaxTheme: Record<string, React.CSSProperties>;
|
||||||
|
isMobile: boolean;
|
||||||
|
expandedTools: Set<string>;
|
||||||
|
onToggleTool: (toolId: string) => void;
|
||||||
|
onShowPopup: (content: ToolPopupContent) => void;
|
||||||
|
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||||
|
isWorking: boolean;
|
||||||
|
previewedPartIds: Set<string>;
|
||||||
|
diffStats?: DiffStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getGroupSummary = (parts: TurnActivityPart[]): string => {
|
||||||
|
const counts = {
|
||||||
|
tools: parts.filter((p) => p.kind === 'tool').length,
|
||||||
|
reasoning: parts.filter((p) => p.kind === 'reasoning').length,
|
||||||
|
justifications: parts.filter((p) => p.kind === 'justification').length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const segments: string[] = [];
|
||||||
|
if (counts.tools > 0) {
|
||||||
|
segments.push(`${counts.tools} tool${counts.tools > 1 ? 's' : ''}`);
|
||||||
|
}
|
||||||
|
if (counts.reasoning > 0) {
|
||||||
|
segments.push(`${counts.reasoning} reasoning`);
|
||||||
|
}
|
||||||
|
if (counts.justifications > 0) {
|
||||||
|
segments.push(`${counts.justifications} justification${counts.justifications > 1 ? 's' : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments.join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortPartsByTime = (parts: TurnActivityPart[]): TurnActivityPart[] => {
|
||||||
|
return [...parts].sort((a, b) => {
|
||||||
|
const aTime = typeof a.endedAt === 'number' ? a.endedAt : undefined;
|
||||||
|
const bTime = typeof b.endedAt === 'number' ? b.endedAt : undefined;
|
||||||
|
|
||||||
|
if (aTime === undefined && bTime === undefined) return 0;
|
||||||
|
if (aTime === undefined) return 1;
|
||||||
|
if (bTime === undefined) return -1;
|
||||||
|
|
||||||
|
return aTime - bTime;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getToolConnections = (
|
||||||
|
parts: TurnActivityPart[]
|
||||||
|
): Record<string, { hasPrev: boolean; hasNext: boolean }> => {
|
||||||
|
const connections: Record<string, { hasPrev: boolean; hasNext: boolean }> = {};
|
||||||
|
const toolParts = parts.filter((p) => p.kind === 'tool');
|
||||||
|
|
||||||
|
toolParts.forEach((activity, index) => {
|
||||||
|
const partId = activity.part.id;
|
||||||
|
if (partId) {
|
||||||
|
connections[partId] = {
|
||||||
|
hasPrev: index > 0,
|
||||||
|
hasNext: index < toolParts.length - 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return connections;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
||||||
|
parts,
|
||||||
|
isExpanded,
|
||||||
|
onToggle,
|
||||||
|
syntaxTheme,
|
||||||
|
isMobile,
|
||||||
|
expandedTools,
|
||||||
|
onToggleTool,
|
||||||
|
onContentChange,
|
||||||
|
isWorking,
|
||||||
|
previewedPartIds,
|
||||||
|
diffStats,
|
||||||
|
}) => {
|
||||||
|
const previousExpandedRef = React.useRef<boolean | undefined>(isExpanded);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (previousExpandedRef.current === isExpanded) return;
|
||||||
|
previousExpandedRef.current = isExpanded;
|
||||||
|
onContentChange?.('structural');
|
||||||
|
}, [isExpanded, onContentChange]);
|
||||||
|
|
||||||
|
const displayParts = React.useMemo(() => {
|
||||||
|
if (!isWorking) {
|
||||||
|
|
||||||
|
return sortPartsByTime(parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
|
||||||
|
return sortPartsByTime(parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortPartsByTime(
|
||||||
|
parts.filter((activity) => {
|
||||||
|
const partId = activity.part.id;
|
||||||
|
return partId && previewedPartIds.has(activity.id);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [parts, isWorking, isExpanded, previewedPartIds]);
|
||||||
|
|
||||||
|
const summary = getGroupSummary(displayParts);
|
||||||
|
const toolConnections = getToolConnections(displayParts);
|
||||||
|
|
||||||
|
if (displayParts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FadeInOnReveal>
|
||||||
|
<div className="my-1">
|
||||||
|
{}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group/tool flex items-center gap-2 pr-2 pl-px pt-0 pb-1.5 rounded-xl cursor-pointer'
|
||||||
|
)}
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{}
|
||||||
|
<div className="relative h-3.5 w-3.5 flex-shrink-0">
|
||||||
|
{}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute inset-0 transition-opacity',
|
||||||
|
isExpanded && 'opacity-0',
|
||||||
|
!isExpanded && !isMobile && 'group-hover/tool:opacity-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RiStackLine className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
{}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute inset-0 transition-opacity flex items-center justify-center',
|
||||||
|
isExpanded && 'opacity-100',
|
||||||
|
!isExpanded && isMobile && 'opacity-0',
|
||||||
|
!isExpanded && !isMobile && 'opacity-0 group-hover/tool:opacity-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<RiArrowDownSLine className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<RiArrowRightSLine className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="typography-meta font-medium">Activity</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(summary || diffStats) && (
|
||||||
|
<div className="flex-1 min-w-0 typography-meta text-muted-foreground/70 flex items-center gap-2">
|
||||||
|
{summary && (
|
||||||
|
<span className="truncate block">{summary}</span>
|
||||||
|
)}
|
||||||
|
{diffStats && (diffStats.additions > 0 || diffStats.deletions > 0) && (
|
||||||
|
<span className="flex-shrink-0 leading-none">
|
||||||
|
<span className="text-[color:var(--status-success)]">
|
||||||
|
+{Math.max(0, diffStats.additions)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground/50">/</span>
|
||||||
|
<span className="text-destructive">
|
||||||
|
-{Math.max(0, diffStats.deletions)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{}
|
||||||
|
{isExpanded && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative pr-2 pb-1 pt-1 pl-[1.4375rem]',
|
||||||
|
'before:absolute before:left-[0.4375rem] before:w-px before:bg-border/80 before:content-[""]',
|
||||||
|
'before:top-[-0.25rem] before:bottom-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{displayParts.map((activity, index) => {
|
||||||
|
const partId = activity.part.id || `group-part-${index}`;
|
||||||
|
const connection = toolConnections[partId];
|
||||||
|
|
||||||
|
switch (activity.kind) {
|
||||||
|
case 'tool':
|
||||||
|
return (
|
||||||
|
<FadeInOnReveal key={partId}>
|
||||||
|
<ToolPart
|
||||||
|
part={activity.part as ToolPartType}
|
||||||
|
isExpanded={expandedTools.has(partId)}
|
||||||
|
onToggle={onToggleTool}
|
||||||
|
syntaxTheme={syntaxTheme}
|
||||||
|
isMobile={isMobile}
|
||||||
|
onContentChange={onContentChange}
|
||||||
|
hasPrevTool={connection?.hasPrev ?? false}
|
||||||
|
hasNextTool={connection?.hasNext ?? false}
|
||||||
|
/>
|
||||||
|
</FadeInOnReveal>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'reasoning':
|
||||||
|
return (
|
||||||
|
<FadeInOnReveal key={partId}>
|
||||||
|
<ReasoningPart
|
||||||
|
part={activity.part}
|
||||||
|
messageId={activity.messageId}
|
||||||
|
onContentChange={onContentChange}
|
||||||
|
/>
|
||||||
|
</FadeInOnReveal>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'justification':
|
||||||
|
return (
|
||||||
|
<FadeInOnReveal key={partId}>
|
||||||
|
<JustificationBlock
|
||||||
|
part={activity.part}
|
||||||
|
messageId={activity.messageId}
|
||||||
|
onContentChange={onContentChange}
|
||||||
|
/>
|
||||||
|
</FadeInOnReveal>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FadeInOnReveal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(ProgressiveGroup);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user