Initial public release

This commit is contained in:
Bohdan Triapitsyn
2025-12-07 19:32:53 +02:00
commit 4b2edf7318
319 changed files with 81600 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
NODE_ENV=development
+29
View File
@@ -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
+269
View File
@@ -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
View File
@@ -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?
+1
View File
@@ -0,0 +1 @@
lts
+138
View File
@@ -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
+13
View File
@@ -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.
+41
View File
@@ -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.
+21
View File
@@ -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.
+86
View File
@@ -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
+21
View File
@@ -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"
}
View File
+23
View File
@@ -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,
},
},
])
+81
View File
@@ -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
View File
@@ -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"
}
}
+14
View File
@@ -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
+36
View File
@@ -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
+64
View File
@@ -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>
+38
View File
@@ -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"
}
}
+88
View File
@@ -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);
});
+237
View File
@@ -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);
});
}
File diff suppressed because it is too large Load Diff
+52
View File
@@ -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"
+10
View File
@@ -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>
+3
View File
@@ -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, &notified_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(&current, &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);
}
+20
View File
@@ -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, &current));
}
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"
}
}
}
+32
View File
@@ -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');
}
},
});
+114
View File
@@ -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');
}
},
});
+230
View File
@@ -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 });
},
});
+57
View File
@@ -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`);
},
};
};
+58
View File
@@ -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;
}
}
});
+49
View File
@@ -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) };
}
},
});
+58
View File
@@ -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)}`);
}
},
});
+123
View File
@@ -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,
});
},
});
+22
View File
@@ -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();
},
});
+105
View File
@@ -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';
}
}
+157
View File
@@ -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;
}
}
+254
View File
@@ -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;
}
+25
View File
@@ -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/**/*"]
}
+75
View File
@@ -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}`;
},
},
},
},
});
+77
View File
@@ -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"
}
}
+42
View File
@@ -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;
}
+188
View File
@@ -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