mirror of
https://github.com/langchain-ai/openwork.git
synced 2026-06-30 20:37:55 -04:00
working commit
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
@@ -0,0 +1,49 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint-and-typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Type check
|
||||
run: npm run typecheck
|
||||
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
@@ -0,0 +1,106 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
platform: mac
|
||||
- os: ubuntu-latest
|
||||
platform: linux
|
||||
- os: windows-latest
|
||||
platform: win
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Package (macOS)
|
||||
if: matrix.platform == 'mac'
|
||||
run: npm run package:mac
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Package (Linux)
|
||||
if: matrix.platform == 'linux'
|
||||
run: npm run package:linux
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Package (Windows)
|
||||
if: matrix.platform == 'win'
|
||||
run: npm run package:win
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: openwork-${{ matrix.platform }}
|
||||
path: |
|
||||
release/*.dmg
|
||||
release/*.zip
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.exe
|
||||
|
||||
publish:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: artifacts/**/*
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
publish-npm:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
@@ -0,0 +1,6 @@
|
||||
out
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
LICENSE.md
|
||||
tsconfig.json
|
||||
tsconfig.*.json
|
||||
@@ -0,0 +1,4 @@
|
||||
singleQuote: true
|
||||
semi: false
|
||||
printWidth: 100
|
||||
trailingComma: none
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
# Contributing to openwork
|
||||
|
||||
Thank you for your interest in contributing to openwork! This document provides guidelines for development and contribution.
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 20+
|
||||
- npm 10+
|
||||
- Git
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. Fork and clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/openwork.git
|
||||
cd openwork
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Start the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
openwork/
|
||||
├── electron/ # Electron main process
|
||||
│ ├── main.ts # App entry point
|
||||
│ ├── preload.ts # Context bridge
|
||||
│ ├── ipc/ # IPC handlers
|
||||
│ ├── db/ # SQLite/Drizzle schema
|
||||
│ └── agent/ # DeepAgentsJS runtime
|
||||
├── src/ # React renderer
|
||||
│ ├── components/ # UI components
|
||||
│ │ ├── ui/ # Base shadcn components
|
||||
│ │ ├── chat/ # Chat interface
|
||||
│ │ ├── sidebar/ # Thread sidebar
|
||||
│ │ ├── panels/ # Right panel tabs
|
||||
│ │ └── hitl/ # Approval dialogs
|
||||
│ ├── lib/ # Utilities and store
|
||||
│ └── types.ts # TypeScript types
|
||||
├── public/ # Static assets
|
||||
└── bin/ # CLI launcher
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
### TypeScript
|
||||
|
||||
- Use strict TypeScript with no `any` types
|
||||
- Prefer interfaces over types for object shapes
|
||||
- Export types alongside implementations
|
||||
|
||||
### React
|
||||
|
||||
- Use functional components with hooks
|
||||
- Prefer named exports
|
||||
- Keep components focused and composable
|
||||
|
||||
### CSS
|
||||
|
||||
- Use Tailwind CSS with the tactical design system
|
||||
- Follow the color system defined in `src/index.css`
|
||||
- Use `cn()` utility for conditional classes
|
||||
|
||||
## Design System
|
||||
|
||||
openwork uses a tactical/SCADA-inspired design system:
|
||||
|
||||
### Colors
|
||||
|
||||
| Role | Variable | Hex |
|
||||
|------|----------|-----|
|
||||
| Background | `--background` | `#0D0D0F` |
|
||||
| Elevated | `--background-elevated` | `#141418` |
|
||||
| Border | `--border` | `#2A2A32` |
|
||||
| Critical | `--status-critical` | `#E53E3E` |
|
||||
| Warning | `--status-warning` | `#F59E0B` |
|
||||
| Nominal | `--status-nominal` | `#22C55E` |
|
||||
| Info | `--status-info` | `#3B82F6` |
|
||||
|
||||
### Typography
|
||||
|
||||
- Primary font: JetBrains Mono
|
||||
- Section headers: 11px, uppercase, tracked
|
||||
- Data values: Tabular nums for alignment
|
||||
|
||||
### Spacing
|
||||
|
||||
- Use the Tailwind spacing scale
|
||||
- Prefer 4px increments (p-1, p-2, p-3, p-4)
|
||||
- Consistent 3px border radius
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run linting
|
||||
npm run lint
|
||||
|
||||
# Run type checking
|
||||
npm run typecheck
|
||||
|
||||
# Build for all platforms
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. Create a feature branch from `main`
|
||||
2. Make your changes with clear commit messages
|
||||
3. Ensure all checks pass (`npm run lint && npm run typecheck`)
|
||||
4. Submit a PR with a description of changes
|
||||
5. Address any review feedback
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Use conventional commits:
|
||||
|
||||
- `feat:` New features
|
||||
- `fix:` Bug fixes
|
||||
- `docs:` Documentation changes
|
||||
- `style:` Code style changes (formatting)
|
||||
- `refactor:` Code refactoring
|
||||
- `test:` Test additions/changes
|
||||
- `chore:` Build/tooling changes
|
||||
|
||||
## Questions?
|
||||
|
||||
Open an issue or start a discussion on GitHub.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 LangChain, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,135 @@
|
||||
# openwork
|
||||
|
||||
A tactical agent interface for [deepagentsjs](https://github.com/langchain-ai/deepagentsjs) - an opinionated harness for building deep agents with filesystem capabilities, planning, and subagent delegation.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- **Chat Interface** - Stream conversations with your AI agent in real-time
|
||||
- **TODO Tracking** - Visual task list showing agent's planning progress
|
||||
- **Filesystem Browser** - See files the agent reads, writes, and edits
|
||||
- **Subagent Monitoring** - Track spawned subagents and their status
|
||||
- **Human-in-the-Loop** - Approve, edit, or reject sensitive tool calls
|
||||
- **Multi-Model Support** - Use Claude, GPT-4, Gemini, or local models
|
||||
- **Thread Persistence** - SQLite-backed conversation history
|
||||
|
||||
## Installation
|
||||
|
||||
### Using npx (recommended)
|
||||
|
||||
```bash
|
||||
npx openwork
|
||||
```
|
||||
|
||||
### Using Homebrew (macOS)
|
||||
|
||||
```bash
|
||||
brew tap langchain-ai/tap
|
||||
brew install --cask openwork
|
||||
```
|
||||
|
||||
### Direct Download
|
||||
|
||||
Download the latest release for your platform from the [releases page](https://github.com/langchain-ai/openwork/releases).
|
||||
|
||||
### From Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/langchain-ai/openwork.git
|
||||
cd openwork
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### API Keys
|
||||
|
||||
openwork supports multiple LLM providers. Set your API keys via:
|
||||
|
||||
1. **Environment Variables** (recommended)
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY="sk-ant-..."
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
export GOOGLE_API_KEY="..."
|
||||
```
|
||||
|
||||
2. **In-App Settings** - Click the settings icon and enter your API keys securely.
|
||||
|
||||
### Supported Models
|
||||
|
||||
| Provider | Models |
|
||||
|----------|--------|
|
||||
| Anthropic | Claude Sonnet 4, Claude 3.5 Sonnet, Claude 3.5 Haiku |
|
||||
| OpenAI | GPT-4o, GPT-4o Mini |
|
||||
| Google | Gemini 2.0 Flash |
|
||||
|
||||
## Architecture
|
||||
|
||||
openwork is built with:
|
||||
|
||||
- **Electron** - Cross-platform desktop framework
|
||||
- **React** - UI components with tactical/SCADA-inspired design
|
||||
- **deepagentsjs** - Agent harness with planning, filesystem, and subagents
|
||||
- **LangGraph** - State machine for agent orchestration
|
||||
- **SQLite** - Local persistence for threads and checkpoints
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Electron Main Process │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||
│ │ IPC Handlers│ │ SQLite │ │ DeepAgentsJS │ │
|
||||
│ │ - agent │ │ - threads │ │ - createAgent │ │
|
||||
│ │ - threads │ │ - runs │ │ - checkpointer │ │
|
||||
│ │ - models │ │ - assists │ │ - middleware │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
IPC Bridge
|
||||
│
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Electron Renderer Process │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌──────────┐ ┌─────────────────────┐ ┌───────────────┐ │
|
||||
│ │ Sidebar │ │ Chat Interface │ │ Right Panel │ │
|
||||
│ │ - Threads│ │ - Messages │ │ - TODOs │ │
|
||||
│ │ - Model │ │ - Tool Renderers │ │ - Files │ │
|
||||
│ │ - Config │ │ - Streaming │ │ - Subagents │ │
|
||||
│ └──────────┘ └─────────────────────┘ └───────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Package for distribution
|
||||
npm run package
|
||||
```
|
||||
|
||||
## Design System
|
||||
|
||||
openwork uses a tactical/SCADA-inspired design system optimized for:
|
||||
|
||||
- **Information density** - Dense layouts for monitoring agent activity
|
||||
- **Status at a glance** - Color-coded status indicators (nominal, warning, critical)
|
||||
- **Dark mode only** - Reduced eye strain for extended sessions
|
||||
- **Monospace typography** - JetBrains Mono for data and code
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines.
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) for details.
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* openwork CLI launcher
|
||||
*
|
||||
* This script launches the openwork Electron app.
|
||||
* When installed via npm, it will start the packaged app.
|
||||
* During development, it runs electron-vite dev.
|
||||
*/
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development' ||
|
||||
fs.existsSync(path.join(__dirname, '..', 'electron.vite.config.ts'));
|
||||
|
||||
if (isDev) {
|
||||
// Development mode - run electron-vite dev
|
||||
const child = spawn('npx', ['electron-vite', 'dev'], {
|
||||
cwd: path.join(__dirname, '..'),
|
||||
stdio: 'inherit',
|
||||
shell: true
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
process.exit(code || 0);
|
||||
});
|
||||
} else {
|
||||
// Production mode - launch the packaged app
|
||||
const platform = process.platform;
|
||||
let appPath;
|
||||
|
||||
if (platform === 'darwin') {
|
||||
appPath = path.join(__dirname, '..', 'release', 'mac', 'openwork.app', 'Contents', 'MacOS', 'openwork');
|
||||
} else if (platform === 'win32') {
|
||||
appPath = path.join(__dirname, '..', 'release', 'win-unpacked', 'openwork.exe');
|
||||
} else {
|
||||
appPath = path.join(__dirname, '..', 'release', 'linux-unpacked', 'openwork');
|
||||
}
|
||||
|
||||
if (fs.existsSync(appPath)) {
|
||||
const child = spawn(appPath, process.argv.slice(2), {
|
||||
stdio: 'inherit',
|
||||
detached: true
|
||||
});
|
||||
child.unref();
|
||||
} else {
|
||||
console.error('openwork app not found. Please run: npm run package');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
appId: com.langchain.openwork
|
||||
productName: openwork
|
||||
directories:
|
||||
buildResources: build
|
||||
output: release
|
||||
files:
|
||||
- '!**/.vscode/*'
|
||||
- '!src/*'
|
||||
- '!electron.vite.config.{js,ts,mjs,cjs}'
|
||||
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
|
||||
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
win:
|
||||
executableName: openwork
|
||||
nsis:
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
shortcutName: ${productName}
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
mac:
|
||||
category: public.app-category.developer-tools
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
extendInfo:
|
||||
- NSCameraUsageDescription: Application requests access to the device's camera.
|
||||
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
|
||||
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
|
||||
notarize: false
|
||||
target:
|
||||
- dmg
|
||||
- zip
|
||||
dmg:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
maintainer: langchain.com
|
||||
category: Development
|
||||
appImage:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
npmRebuild: false
|
||||
@@ -0,0 +1,18 @@
|
||||
import { resolve } from 'path'
|
||||
import { defineConfig } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
main: {},
|
||||
preload: {},
|
||||
renderer: {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve('src/renderer/src'),
|
||||
'@renderer': resolve('src/renderer/src')
|
||||
}
|
||||
},
|
||||
plugins: [react(), tailwindcss()]
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "eslint/config";
|
||||
import tseslint from "@electron-toolkit/eslint-config-ts";
|
||||
import eslintConfigPrettier from "@electron-toolkit/eslint-config-prettier";
|
||||
import eslintPluginReact from "eslint-plugin-react";
|
||||
import eslintPluginReactHooks from "eslint-plugin-react-hooks";
|
||||
import eslintPluginReactRefresh from "eslint-plugin-react-refresh";
|
||||
|
||||
export default defineConfig(
|
||||
{ ignores: ["**/node_modules", "**/dist", "**/out"] },
|
||||
tseslint.configs.recommended,
|
||||
eslintPluginReact.configs.flat.recommended,
|
||||
eslintPluginReact.configs.flat["jsx-runtime"],
|
||||
{
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
plugins: {
|
||||
"react-hooks": eslintPluginReactHooks,
|
||||
"react-refresh": eslintPluginReactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...eslintPluginReactHooks.configs.recommended.rules,
|
||||
...eslintPluginReactRefresh.configs.vite.rules,
|
||||
},
|
||||
},
|
||||
eslintConfigPrettier
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
# Homebrew Cask formula for openwork
|
||||
# This file should be published to a tap repository (e.g., langchain-ai/homebrew-tap)
|
||||
|
||||
cask "openwork" do
|
||||
version "0.1.0"
|
||||
sha256 :no_check # Update with actual SHA256 after first release
|
||||
|
||||
url "https://github.com/langchain-ai/openwork/releases/download/v#{version}/openwork-#{version}-mac.dmg"
|
||||
name "openwork"
|
||||
desc "Tactical agent interface for deepagentsjs"
|
||||
homepage "https://github.com/langchain-ai/openwork"
|
||||
|
||||
livecheck do
|
||||
url :url
|
||||
strategy :github_latest
|
||||
end
|
||||
|
||||
app "openwork.app"
|
||||
|
||||
zap trash: [
|
||||
"~/Library/Application Support/openwork",
|
||||
"~/Library/Preferences/com.langchain.openwork.plist",
|
||||
"~/Library/Logs/openwork",
|
||||
]
|
||||
end
|
||||
Generated
+10800
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "openwork",
|
||||
"version": "0.1.0",
|
||||
"description": "A tactical agent interface for deepagentsjs",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "LangChain",
|
||||
"homepage": "https://github.com/langchain-ai/openwork",
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint --cache .",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "npm run build && electron-builder --win",
|
||||
"build:mac": "electron-vite build && electron-builder --mac",
|
||||
"build:linux": "electron-vite build && electron-builder --linux"
|
||||
},
|
||||
"bin": {
|
||||
"openwork": "./bin/cli.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@langchain/anthropic": "^1.3.7",
|
||||
"@langchain/core": "^1.1.12",
|
||||
"@langchain/langgraph": "^1.0.15",
|
||||
"@langchain/langgraph-checkpoint": "^1.0.0",
|
||||
"@langchain/openai": "^1.2.1",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"deepagents": "^1.4.1",
|
||||
"electron-store": "^8.2.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.4.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sql.js": "^1.12.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"uuid": "^11.0.5",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.1.0",
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/node": "^22.19.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"electron": "^39.2.6",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^5.0.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"prettier": "^3.7.4",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6"
|
||||
}
|
||||
}
|
||||
Generated
+8007
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
onlyBuiltDependencies:
|
||||
- better-sqlite3
|
||||
- electron
|
||||
- electron-winstaller
|
||||
- esbuild
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFBD4F"></stop><stop offset="100%" stop-color="#FF980E"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,85 @@
|
||||
import { createDeepAgent } from 'deepagents'
|
||||
import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { getDefaultModel, getApiKey } from '../ipc/models'
|
||||
import { ChatAnthropic } from '@langchain/anthropic'
|
||||
import { ChatOpenAI } from '@langchain/openai'
|
||||
import { SqlJsSaver } from '../checkpointer/sqljs-saver'
|
||||
|
||||
// Singleton checkpointer instance
|
||||
let checkpointer: SqlJsSaver | null = null
|
||||
|
||||
export async function getCheckpointer(): Promise<SqlJsSaver> {
|
||||
if (!checkpointer) {
|
||||
const dbPath = join(app.getPath('userData'), 'langgraph.sqlite')
|
||||
checkpointer = new SqlJsSaver(dbPath)
|
||||
await checkpointer.initialize()
|
||||
}
|
||||
return checkpointer
|
||||
}
|
||||
|
||||
// Get the appropriate model instance based on configuration
|
||||
function getModelInstance(modelId?: string) {
|
||||
const model = modelId || getDefaultModel()
|
||||
console.log('[Runtime] Using model:', model)
|
||||
|
||||
// Determine provider from model ID
|
||||
if (model.startsWith('claude')) {
|
||||
const apiKey = getApiKey('anthropic')
|
||||
console.log('[Runtime] Anthropic API key present:', !!apiKey)
|
||||
if (!apiKey) {
|
||||
throw new Error('Anthropic API key not configured')
|
||||
}
|
||||
return new ChatAnthropic({
|
||||
model,
|
||||
anthropicApiKey: apiKey
|
||||
})
|
||||
} else if (model.startsWith('gpt')) {
|
||||
const apiKey = getApiKey('openai')
|
||||
console.log('[Runtime] OpenAI API key present:', !!apiKey)
|
||||
if (!apiKey) {
|
||||
throw new Error('OpenAI API key not configured')
|
||||
}
|
||||
return new ChatOpenAI({
|
||||
model,
|
||||
openAIApiKey: apiKey
|
||||
})
|
||||
} else if (model.startsWith('gemini')) {
|
||||
// For Gemini, we'd need @langchain/google-genai
|
||||
throw new Error('Gemini support coming soon')
|
||||
}
|
||||
|
||||
// Default to model string (let deepagents handle it)
|
||||
return model
|
||||
}
|
||||
|
||||
// Create agent runtime with configured model and checkpointer
|
||||
export async function createAgentRuntime(modelId?: string) {
|
||||
console.log('[Runtime] Creating agent runtime...')
|
||||
|
||||
const model = getModelInstance(modelId)
|
||||
console.log('[Runtime] Model instance created:', typeof model)
|
||||
|
||||
const saver = await getCheckpointer()
|
||||
console.log('[Runtime] Checkpointer ready')
|
||||
|
||||
// Using type assertion to work around version compatibility issues
|
||||
// between @langchain packages and deepagentsjs types
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const agent = createDeepAgent({
|
||||
model: model as any,
|
||||
checkpointer: saver as any
|
||||
})
|
||||
|
||||
console.log('[Runtime] Deep agent created')
|
||||
|
||||
return agent
|
||||
}
|
||||
|
||||
// Clean up resources
|
||||
export async function closeRuntime() {
|
||||
if (checkpointer) {
|
||||
await checkpointer.close()
|
||||
checkpointer = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
import initSqlJs, { Database as SqlJsDatabase } from 'sql.js'
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
|
||||
import { dirname } from 'path'
|
||||
import type { RunnableConfig } from '@langchain/core/runnables'
|
||||
import {
|
||||
BaseCheckpointSaver,
|
||||
type Checkpoint,
|
||||
type CheckpointListOptions,
|
||||
type CheckpointTuple,
|
||||
type SerializerProtocol,
|
||||
type PendingWrite,
|
||||
type CheckpointMetadata,
|
||||
copyCheckpoint
|
||||
} from '@langchain/langgraph-checkpoint'
|
||||
|
||||
interface CheckpointRow {
|
||||
thread_id: string
|
||||
checkpoint_ns: string
|
||||
checkpoint_id: string
|
||||
parent_checkpoint_id: string | null
|
||||
type: string | null
|
||||
checkpoint: string
|
||||
metadata: string
|
||||
}
|
||||
|
||||
interface WriteRow {
|
||||
task_id: string
|
||||
channel: string
|
||||
type: string | null
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLite checkpointer using sql.js (pure JavaScript, no native modules)
|
||||
* Compatible with all Electron versions without native compilation.
|
||||
*/
|
||||
export class SqlJsSaver extends BaseCheckpointSaver {
|
||||
private db: SqlJsDatabase | null = null
|
||||
private dbPath: string
|
||||
private isSetup = false
|
||||
private saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private dirty = false
|
||||
|
||||
constructor(dbPath: string, serde?: SerializerProtocol) {
|
||||
super(serde)
|
||||
this.dbPath = dbPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the database asynchronously
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.db) return
|
||||
|
||||
const SQL = await initSqlJs()
|
||||
|
||||
// Load existing database if it exists
|
||||
if (existsSync(this.dbPath)) {
|
||||
const buffer = readFileSync(this.dbPath)
|
||||
this.db = new SQL.Database(buffer)
|
||||
} else {
|
||||
// Ensure directory exists
|
||||
const dir = dirname(this.dbPath)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
this.db = new SQL.Database()
|
||||
}
|
||||
|
||||
this.setup()
|
||||
}
|
||||
|
||||
private setup(): void {
|
||||
if (this.isSetup || !this.db) return
|
||||
|
||||
// Create tables
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS checkpoints (
|
||||
thread_id TEXT NOT NULL,
|
||||
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
||||
checkpoint_id TEXT NOT NULL,
|
||||
parent_checkpoint_id TEXT,
|
||||
type TEXT,
|
||||
checkpoint TEXT,
|
||||
metadata TEXT,
|
||||
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id)
|
||||
)
|
||||
`)
|
||||
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS writes (
|
||||
thread_id TEXT NOT NULL,
|
||||
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
||||
checkpoint_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
idx INTEGER NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
type TEXT,
|
||||
value TEXT,
|
||||
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx)
|
||||
)
|
||||
`)
|
||||
|
||||
this.isSetup = true
|
||||
this.saveToDisk()
|
||||
}
|
||||
|
||||
/**
|
||||
* Save database to disk (debounced)
|
||||
*/
|
||||
private saveToDisk(): void {
|
||||
if (!this.db) return
|
||||
|
||||
this.dirty = true
|
||||
|
||||
// Debounce saves to avoid excessive disk writes
|
||||
if (this.saveTimer) {
|
||||
clearTimeout(this.saveTimer)
|
||||
}
|
||||
|
||||
this.saveTimer = setTimeout(() => {
|
||||
if (this.db && this.dirty) {
|
||||
const data = this.db.export()
|
||||
writeFileSync(this.dbPath, Buffer.from(data))
|
||||
this.dirty = false
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Force immediate save to disk
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
if (this.saveTimer) {
|
||||
clearTimeout(this.saveTimer)
|
||||
this.saveTimer = null
|
||||
}
|
||||
if (this.db && this.dirty) {
|
||||
const data = this.db.export()
|
||||
writeFileSync(this.dbPath, Buffer.from(data))
|
||||
this.dirty = false
|
||||
}
|
||||
}
|
||||
|
||||
async getTuple(config: RunnableConfig): Promise<CheckpointTuple | undefined> {
|
||||
await this.initialize()
|
||||
if (!this.db) throw new Error('Database not initialized')
|
||||
|
||||
const { thread_id, checkpoint_ns = '', checkpoint_id } = config.configurable ?? {}
|
||||
|
||||
let sql: string
|
||||
let params: (string | undefined)[]
|
||||
|
||||
if (checkpoint_id) {
|
||||
sql = `
|
||||
SELECT thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata
|
||||
FROM checkpoints
|
||||
WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ?
|
||||
`
|
||||
params = [thread_id, checkpoint_ns, checkpoint_id]
|
||||
} else {
|
||||
sql = `
|
||||
SELECT thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata
|
||||
FROM checkpoints
|
||||
WHERE thread_id = ? AND checkpoint_ns = ?
|
||||
ORDER BY checkpoint_id DESC
|
||||
LIMIT 1
|
||||
`
|
||||
params = [thread_id, checkpoint_ns]
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(sql)
|
||||
stmt.bind(params.filter((p) => p !== undefined))
|
||||
|
||||
if (!stmt.step()) {
|
||||
stmt.free()
|
||||
return undefined
|
||||
}
|
||||
|
||||
const row = stmt.getAsObject() as unknown as CheckpointRow
|
||||
stmt.free()
|
||||
|
||||
// Get pending writes
|
||||
const writesStmt = this.db.prepare(`
|
||||
SELECT task_id, channel, type, value
|
||||
FROM writes
|
||||
WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ?
|
||||
`)
|
||||
writesStmt.bind([row.thread_id, row.checkpoint_ns, row.checkpoint_id])
|
||||
|
||||
const pendingWrites: [string, string, unknown][] = []
|
||||
while (writesStmt.step()) {
|
||||
const write = writesStmt.getAsObject() as unknown as WriteRow
|
||||
const value = await this.serde.loadsTyped(write.type ?? 'json', write.value ?? '')
|
||||
pendingWrites.push([write.task_id, write.channel, value])
|
||||
}
|
||||
writesStmt.free()
|
||||
|
||||
const checkpoint = (await this.serde.loadsTyped(
|
||||
row.type ?? 'json',
|
||||
row.checkpoint
|
||||
)) as Checkpoint
|
||||
|
||||
const finalConfig = checkpoint_id
|
||||
? config
|
||||
: {
|
||||
configurable: {
|
||||
thread_id: row.thread_id,
|
||||
checkpoint_ns: row.checkpoint_ns,
|
||||
checkpoint_id: row.checkpoint_id
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
checkpoint,
|
||||
config: finalConfig,
|
||||
metadata: (await this.serde.loadsTyped(
|
||||
row.type ?? 'json',
|
||||
row.metadata
|
||||
)) as CheckpointMetadata,
|
||||
parentConfig: row.parent_checkpoint_id
|
||||
? {
|
||||
configurable: {
|
||||
thread_id: row.thread_id,
|
||||
checkpoint_ns: row.checkpoint_ns,
|
||||
checkpoint_id: row.parent_checkpoint_id
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
pendingWrites
|
||||
}
|
||||
}
|
||||
|
||||
async *list(
|
||||
config: RunnableConfig,
|
||||
options?: CheckpointListOptions
|
||||
): AsyncGenerator<CheckpointTuple> {
|
||||
await this.initialize()
|
||||
if (!this.db) throw new Error('Database not initialized')
|
||||
|
||||
const { limit, before } = options ?? {}
|
||||
const thread_id = config.configurable?.thread_id
|
||||
const checkpoint_ns = config.configurable?.checkpoint_ns ?? ''
|
||||
|
||||
let sql = `
|
||||
SELECT thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata
|
||||
FROM checkpoints
|
||||
WHERE thread_id = ? AND checkpoint_ns = ?
|
||||
`
|
||||
const params: string[] = [thread_id, checkpoint_ns]
|
||||
|
||||
if (before?.configurable?.checkpoint_id) {
|
||||
sql += ` AND checkpoint_id < ?`
|
||||
params.push(before.configurable.checkpoint_id)
|
||||
}
|
||||
|
||||
sql += ` ORDER BY checkpoint_id DESC`
|
||||
|
||||
if (limit) {
|
||||
sql += ` LIMIT ${parseInt(String(limit), 10)}`
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(sql)
|
||||
stmt.bind(params)
|
||||
|
||||
while (stmt.step()) {
|
||||
const row = stmt.getAsObject() as unknown as CheckpointRow
|
||||
|
||||
// Get pending writes for this checkpoint
|
||||
const writesStmt = this.db.prepare(`
|
||||
SELECT task_id, channel, type, value
|
||||
FROM writes
|
||||
WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ?
|
||||
`)
|
||||
writesStmt.bind([row.thread_id, row.checkpoint_ns, row.checkpoint_id])
|
||||
|
||||
const pendingWrites: [string, string, unknown][] = []
|
||||
while (writesStmt.step()) {
|
||||
const write = writesStmt.getAsObject() as unknown as WriteRow
|
||||
const value = await this.serde.loadsTyped(write.type ?? 'json', write.value ?? '')
|
||||
pendingWrites.push([write.task_id, write.channel, value])
|
||||
}
|
||||
writesStmt.free()
|
||||
|
||||
const checkpoint = (await this.serde.loadsTyped(
|
||||
row.type ?? 'json',
|
||||
row.checkpoint
|
||||
)) as Checkpoint
|
||||
|
||||
yield {
|
||||
config: {
|
||||
configurable: {
|
||||
thread_id: row.thread_id,
|
||||
checkpoint_ns: row.checkpoint_ns,
|
||||
checkpoint_id: row.checkpoint_id
|
||||
}
|
||||
},
|
||||
checkpoint,
|
||||
metadata: (await this.serde.loadsTyped(
|
||||
row.type ?? 'json',
|
||||
row.metadata
|
||||
)) as CheckpointMetadata,
|
||||
parentConfig: row.parent_checkpoint_id
|
||||
? {
|
||||
configurable: {
|
||||
thread_id: row.thread_id,
|
||||
checkpoint_ns: row.checkpoint_ns,
|
||||
checkpoint_id: row.parent_checkpoint_id
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
pendingWrites
|
||||
}
|
||||
}
|
||||
|
||||
stmt.free()
|
||||
}
|
||||
|
||||
async put(
|
||||
config: RunnableConfig,
|
||||
checkpoint: Checkpoint,
|
||||
metadata: CheckpointMetadata
|
||||
): Promise<RunnableConfig> {
|
||||
await this.initialize()
|
||||
if (!this.db) throw new Error('Database not initialized')
|
||||
|
||||
if (!config.configurable) {
|
||||
throw new Error('Empty configuration supplied.')
|
||||
}
|
||||
|
||||
const thread_id = config.configurable?.thread_id
|
||||
const checkpoint_ns = config.configurable?.checkpoint_ns ?? ''
|
||||
const parent_checkpoint_id = config.configurable?.checkpoint_id
|
||||
|
||||
if (!thread_id) {
|
||||
throw new Error('Missing "thread_id" field in passed "config.configurable".')
|
||||
}
|
||||
|
||||
const preparedCheckpoint = copyCheckpoint(checkpoint)
|
||||
|
||||
const [[type1, serializedCheckpoint], [type2, serializedMetadata]] = await Promise.all([
|
||||
this.serde.dumpsTyped(preparedCheckpoint),
|
||||
this.serde.dumpsTyped(metadata)
|
||||
])
|
||||
|
||||
if (type1 !== type2) {
|
||||
throw new Error('Failed to serialize checkpoint and metadata to the same type.')
|
||||
}
|
||||
|
||||
this.db.run(
|
||||
`INSERT OR REPLACE INTO checkpoints
|
||||
(thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
checkpoint.id,
|
||||
parent_checkpoint_id ?? null,
|
||||
type1,
|
||||
serializedCheckpoint,
|
||||
serializedMetadata
|
||||
]
|
||||
)
|
||||
|
||||
this.saveToDisk()
|
||||
|
||||
return {
|
||||
configurable: {
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
checkpoint_id: checkpoint.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async putWrites(config: RunnableConfig, writes: PendingWrite[], taskId: string): Promise<void> {
|
||||
await this.initialize()
|
||||
if (!this.db) throw new Error('Database not initialized')
|
||||
|
||||
if (!config.configurable) {
|
||||
throw new Error('Empty configuration supplied.')
|
||||
}
|
||||
|
||||
if (!config.configurable?.thread_id) {
|
||||
throw new Error('Missing thread_id field in config.configurable.')
|
||||
}
|
||||
|
||||
if (!config.configurable?.checkpoint_id) {
|
||||
throw new Error('Missing checkpoint_id field in config.configurable.')
|
||||
}
|
||||
|
||||
for (let idx = 0; idx < writes.length; idx++) {
|
||||
const write = writes[idx]
|
||||
const [type, serializedWrite] = await this.serde.dumpsTyped(write[1])
|
||||
|
||||
this.db.run(
|
||||
`INSERT OR REPLACE INTO writes
|
||||
(thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
config.configurable.thread_id,
|
||||
config.configurable.checkpoint_ns ?? '',
|
||||
config.configurable.checkpoint_id,
|
||||
taskId,
|
||||
idx,
|
||||
write[0],
|
||||
type,
|
||||
serializedWrite
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
this.saveToDisk()
|
||||
}
|
||||
|
||||
async deleteThread(threadId: string): Promise<void> {
|
||||
await this.initialize()
|
||||
if (!this.db) throw new Error('Database not initialized')
|
||||
|
||||
this.db.run(`DELETE FROM checkpoints WHERE thread_id = ?`, [threadId])
|
||||
this.db.run(`DELETE FROM writes WHERE thread_id = ?`, [threadId])
|
||||
|
||||
this.saveToDisk()
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database and save any pending changes
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
await this.flush()
|
||||
if (this.db) {
|
||||
this.db.close()
|
||||
this.db = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import initSqlJs, { Database as SqlJsDatabase } from 'sql.js'
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
import { app } from 'electron'
|
||||
|
||||
// Database path in user data directory
|
||||
const getDbPath = () => join(app.getPath('userData'), 'openwork.sqlite')
|
||||
|
||||
let db: SqlJsDatabase | null = null
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let dirty = false
|
||||
|
||||
/**
|
||||
* Save database to disk (debounced)
|
||||
*/
|
||||
function saveToDisk(): void {
|
||||
if (!db) return
|
||||
|
||||
dirty = true
|
||||
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer)
|
||||
}
|
||||
|
||||
saveTimer = setTimeout(() => {
|
||||
if (db && dirty) {
|
||||
const data = db.export()
|
||||
writeFileSync(getDbPath(), Buffer.from(data))
|
||||
dirty = false
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Force immediate save
|
||||
*/
|
||||
export async function flush(): Promise<void> {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = null
|
||||
}
|
||||
if (db && dirty) {
|
||||
const data = db.export()
|
||||
writeFileSync(getDbPath(), Buffer.from(data))
|
||||
dirty = false
|
||||
}
|
||||
}
|
||||
|
||||
export function getDb(): SqlJsDatabase {
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized. Call initializeDatabase() first.')
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
export async function initializeDatabase(): Promise<SqlJsDatabase> {
|
||||
const dbPath = getDbPath()
|
||||
console.log('Initializing database at:', dbPath)
|
||||
|
||||
const SQL = await initSqlJs()
|
||||
|
||||
// Load existing database if it exists
|
||||
if (existsSync(dbPath)) {
|
||||
const buffer = readFileSync(dbPath)
|
||||
db = new SQL.Database(buffer)
|
||||
} else {
|
||||
// Ensure directory exists
|
||||
const dir = dirname(dbPath)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
db = new SQL.Database()
|
||||
}
|
||||
|
||||
// Create tables if they don't exist
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS threads (
|
||||
thread_id TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
metadata TEXT,
|
||||
status TEXT DEFAULT 'idle',
|
||||
thread_values TEXT,
|
||||
title TEXT
|
||||
)
|
||||
`)
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS runs (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
thread_id TEXT REFERENCES threads(thread_id) ON DELETE CASCADE,
|
||||
assistant_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
status TEXT,
|
||||
metadata TEXT,
|
||||
kwargs TEXT
|
||||
)
|
||||
`)
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS assistants (
|
||||
assistant_id TEXT PRIMARY KEY,
|
||||
graph_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
model TEXT DEFAULT 'claude-sonnet-4-20250514',
|
||||
config TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`)
|
||||
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_threads_updated_at ON threads(updated_at)`)
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_runs_thread_id ON runs(thread_id)`)
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status)`)
|
||||
|
||||
saveToDisk()
|
||||
|
||||
console.log('Database initialized successfully')
|
||||
return db
|
||||
}
|
||||
|
||||
export function closeDatabase(): void {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = null
|
||||
}
|
||||
if (db) {
|
||||
// Save any pending changes
|
||||
if (dirty) {
|
||||
const data = db.export()
|
||||
writeFileSync(getDbPath(), Buffer.from(data))
|
||||
}
|
||||
db.close()
|
||||
db = null
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for common operations
|
||||
|
||||
export interface Thread {
|
||||
thread_id: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
metadata: string | null
|
||||
status: string
|
||||
thread_values: string | null
|
||||
title: string | null
|
||||
}
|
||||
|
||||
export function getAllThreads(): Thread[] {
|
||||
const database = getDb()
|
||||
const stmt = database.prepare('SELECT * FROM threads ORDER BY updated_at DESC')
|
||||
const threads: Thread[] = []
|
||||
|
||||
while (stmt.step()) {
|
||||
threads.push(stmt.getAsObject() as unknown as Thread)
|
||||
}
|
||||
stmt.free()
|
||||
|
||||
return threads
|
||||
}
|
||||
|
||||
export function getThread(threadId: string): Thread | null {
|
||||
const database = getDb()
|
||||
const stmt = database.prepare('SELECT * FROM threads WHERE thread_id = ?')
|
||||
stmt.bind([threadId])
|
||||
|
||||
if (!stmt.step()) {
|
||||
stmt.free()
|
||||
return null
|
||||
}
|
||||
|
||||
const thread = stmt.getAsObject() as unknown as Thread
|
||||
stmt.free()
|
||||
return thread
|
||||
}
|
||||
|
||||
export function createThread(
|
||||
threadId: string,
|
||||
metadata?: Record<string, unknown>
|
||||
): Thread {
|
||||
const database = getDb()
|
||||
const now = Date.now()
|
||||
|
||||
database.run(
|
||||
`INSERT INTO threads (thread_id, created_at, updated_at, metadata, status)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[threadId, now, now, metadata ? JSON.stringify(metadata) : null, 'idle']
|
||||
)
|
||||
|
||||
saveToDisk()
|
||||
|
||||
return {
|
||||
thread_id: threadId,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
metadata: metadata ? JSON.stringify(metadata) : null,
|
||||
status: 'idle',
|
||||
thread_values: null,
|
||||
title: null
|
||||
}
|
||||
}
|
||||
|
||||
export function updateThread(
|
||||
threadId: string,
|
||||
updates: Partial<Omit<Thread, 'thread_id' | 'created_at'>>
|
||||
): Thread | null {
|
||||
const database = getDb()
|
||||
const existing = getThread(threadId)
|
||||
|
||||
if (!existing) return null
|
||||
|
||||
const now = Date.now()
|
||||
const setClauses: string[] = ['updated_at = ?']
|
||||
const values: (string | number | null)[] = [now]
|
||||
|
||||
if (updates.metadata !== undefined) {
|
||||
setClauses.push('metadata = ?')
|
||||
values.push(typeof updates.metadata === 'string' ? updates.metadata : JSON.stringify(updates.metadata))
|
||||
}
|
||||
if (updates.status !== undefined) {
|
||||
setClauses.push('status = ?')
|
||||
values.push(updates.status)
|
||||
}
|
||||
if (updates.thread_values !== undefined) {
|
||||
setClauses.push('thread_values = ?')
|
||||
values.push(updates.thread_values)
|
||||
}
|
||||
if (updates.title !== undefined) {
|
||||
setClauses.push('title = ?')
|
||||
values.push(updates.title)
|
||||
}
|
||||
|
||||
values.push(threadId)
|
||||
|
||||
database.run(
|
||||
`UPDATE threads SET ${setClauses.join(', ')} WHERE thread_id = ?`,
|
||||
values
|
||||
)
|
||||
|
||||
saveToDisk()
|
||||
|
||||
return getThread(threadId)
|
||||
}
|
||||
|
||||
export function deleteThread(threadId: string): void {
|
||||
const database = getDb()
|
||||
database.run('DELETE FROM threads WHERE thread_id = ?', [threadId])
|
||||
saveToDisk()
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { app, shell, BrowserWindow, ipcMain } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { registerAgentHandlers } from './ipc/agent'
|
||||
import { registerThreadHandlers } from './ipc/threads'
|
||||
import { registerModelHandlers } from './ipc/models'
|
||||
import { initializeDatabase } from './db'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
|
||||
// Simple dev check - replaces @electron-toolkit/utils is.dev
|
||||
const isDev = !app.isPackaged
|
||||
|
||||
function createWindow(): void {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1440,
|
||||
height: 900,
|
||||
minWidth: 1200,
|
||||
minHeight: 700,
|
||||
show: false,
|
||||
backgroundColor: '#0D0D0F',
|
||||
titleBarStyle: 'hiddenInset',
|
||||
trafficLightPosition: { x: 16, y: 16 },
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow?.show()
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// HMR for renderer based on electron-vite cli
|
||||
if (isDev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||
mainWindow.webContents.openDevTools()
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// Set app user model id for windows
|
||||
if (process.platform === 'win32') {
|
||||
app.setAppUserModelId(isDev ? process.execPath : 'com.langchain.openwork')
|
||||
}
|
||||
|
||||
// Default open or close DevTools by F12 in development
|
||||
if (isDev) {
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
window.webContents.on('before-input-event', (event, input) => {
|
||||
if (input.key === 'F12') {
|
||||
window.webContents.toggleDevTools()
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize database
|
||||
await initializeDatabase()
|
||||
|
||||
// Register IPC handlers
|
||||
registerAgentHandlers(ipcMain)
|
||||
registerThreadHandlers(ipcMain)
|
||||
registerModelHandlers(ipcMain)
|
||||
|
||||
createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,245 @@
|
||||
import { IpcMain, BrowserWindow } from 'electron'
|
||||
import { HumanMessage } from '@langchain/core/messages'
|
||||
import { createAgentRuntime } from '../agent/runtime'
|
||||
import type { HITLDecision, StreamEvent } from '../types'
|
||||
|
||||
// Track active runs for cancellation
|
||||
const activeRuns = new Map<string, AbortController>()
|
||||
|
||||
export function registerAgentHandlers(ipcMain: IpcMain) {
|
||||
console.log('[Agent] Registering agent handlers...')
|
||||
|
||||
// Handle agent invocation with streaming
|
||||
ipcMain.on('agent:invoke', async (event, { threadId, message }: { threadId: string; message: string }) => {
|
||||
const channel = `agent:stream:${threadId}`
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
|
||||
console.log('[Agent] Received invoke request:', { threadId, message: message.substring(0, 50) })
|
||||
|
||||
if (!window) {
|
||||
console.error('[Agent] No window found')
|
||||
return
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
activeRuns.set(threadId, abortController)
|
||||
|
||||
try {
|
||||
console.log('[Agent] Creating runtime...')
|
||||
const agent = await createAgentRuntime()
|
||||
console.log('[Agent] Runtime created, starting stream...')
|
||||
|
||||
// Create proper HumanMessage
|
||||
const humanMessage = new HumanMessage(message)
|
||||
|
||||
// Track seen message IDs to avoid duplicates
|
||||
const seenMessageIds = new Set<string>()
|
||||
|
||||
// Stream with values mode to get full state after each step
|
||||
// Note: 'messages' mode was causing tool call corruption, so we stick with 'values'
|
||||
const stream = await agent.stream(
|
||||
{ messages: [humanMessage] },
|
||||
{
|
||||
configurable: { thread_id: threadId },
|
||||
signal: abortController.signal,
|
||||
streamMode: 'values'
|
||||
}
|
||||
)
|
||||
console.log('[Agent] Stream started with streamMode: values')
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (abortController.signal.aborted) break
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const state = chunk as any
|
||||
console.log('[Agent] Chunk keys:', Object.keys(state || {}))
|
||||
|
||||
// Process messages from state
|
||||
if (state.messages && Array.isArray(state.messages)) {
|
||||
for (const msg of state.messages) {
|
||||
const msgId = msg.id || crypto.randomUUID()
|
||||
|
||||
// Skip if we've already sent this message
|
||||
if (seenMessageIds.has(msgId)) continue
|
||||
|
||||
// Determine the role from the message type
|
||||
let role: 'user' | 'assistant' | 'system' | 'tool' = 'assistant'
|
||||
if (typeof msg._getType === 'function') {
|
||||
const msgType = msg._getType()
|
||||
if (msgType === 'human') role = 'user'
|
||||
else if (msgType === 'ai') role = 'assistant'
|
||||
else if (msgType === 'system') role = 'system'
|
||||
else if (msgType === 'tool') role = 'tool'
|
||||
}
|
||||
|
||||
// Extract content
|
||||
let content: string = ''
|
||||
if (typeof msg.content === 'string') {
|
||||
content = msg.content
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
content = msg.content
|
||||
.filter((block: { type?: string }) => block.type === 'text')
|
||||
.map((block: { text?: string }) => block.text || '')
|
||||
.join('')
|
||||
}
|
||||
|
||||
// Only send assistant messages with content
|
||||
if (role === 'assistant' && content) {
|
||||
seenMessageIds.add(msgId)
|
||||
|
||||
const streamEvent: StreamEvent = {
|
||||
type: 'message',
|
||||
message: {
|
||||
id: msgId,
|
||||
role,
|
||||
content,
|
||||
tool_calls: msg.tool_calls,
|
||||
created_at: new Date()
|
||||
}
|
||||
}
|
||||
window.webContents.send(channel, streamEvent)
|
||||
console.log('[Agent] Sent message:', msgId.substring(0, 20))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for todos in agent state
|
||||
if (state.todos && Array.isArray(state.todos)) {
|
||||
const todosEvent: StreamEvent = {
|
||||
type: 'todos',
|
||||
todos: (state.todos as Array<{ id?: string; content?: string; status?: string }>).map((t) => ({
|
||||
id: t.id || crypto.randomUUID(),
|
||||
content: t.content || '',
|
||||
status: (t.status || 'pending') as 'pending' | 'in_progress' | 'completed' | 'cancelled'
|
||||
}))
|
||||
}
|
||||
window.webContents.send(channel, todosEvent)
|
||||
}
|
||||
|
||||
// Check for workspace/file state
|
||||
// deepagents stores files as Record<string, FileData> (object keyed by path)
|
||||
const filesObj = state.files as Record<string, { content?: string; lastModified?: number }> | undefined
|
||||
const workspacePath = (state.workspacePath as string) || process.cwd()
|
||||
|
||||
if (filesObj && typeof filesObj === 'object' && !Array.isArray(filesObj)) {
|
||||
// Convert object format to array format
|
||||
const files = Object.entries(filesObj).map(([filePath, data]) => ({
|
||||
path: filePath,
|
||||
is_dir: false,
|
||||
size: typeof data?.content === 'string' ? data.content.length : undefined
|
||||
}))
|
||||
|
||||
if (files.length > 0) {
|
||||
console.log('[Agent] Sending workspace event with', files.length, 'files')
|
||||
const workspaceEvent: StreamEvent = {
|
||||
type: 'workspace',
|
||||
files,
|
||||
path: workspacePath
|
||||
}
|
||||
window.webContents.send(channel, workspaceEvent)
|
||||
}
|
||||
} else if (Array.isArray(filesObj)) {
|
||||
// Handle legacy array format if present
|
||||
const files = (filesObj as Array<{ path: string; is_dir?: boolean; size?: number }>)
|
||||
if (files.length > 0) {
|
||||
const workspaceEvent: StreamEvent = {
|
||||
type: 'workspace',
|
||||
files: files.map((f) => ({
|
||||
path: f.path,
|
||||
is_dir: f.is_dir,
|
||||
size: f.size
|
||||
})),
|
||||
path: workspacePath
|
||||
}
|
||||
window.webContents.send(channel, workspaceEvent)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for subagents in agent state
|
||||
const subagentsRaw = state.subagents as Array<{
|
||||
id?: string
|
||||
name?: string
|
||||
type?: string
|
||||
description?: string
|
||||
status?: string
|
||||
startedAt?: Date | string
|
||||
completedAt?: Date | string
|
||||
}> | undefined
|
||||
|
||||
if (subagentsRaw && Array.isArray(subagentsRaw) && subagentsRaw.length > 0) {
|
||||
console.log('[Agent] Sending subagents event with', subagentsRaw.length, 'subagents')
|
||||
const subagentsEvent: StreamEvent = {
|
||||
type: 'subagents',
|
||||
subagents: subagentsRaw.map((s) => ({
|
||||
id: s.id || crypto.randomUUID(),
|
||||
name: s.name || s.type || 'Subagent',
|
||||
description: s.description || '',
|
||||
status: (s.status || 'pending') as 'pending' | 'running' | 'completed' | 'failed',
|
||||
startedAt: s.startedAt ? new Date(s.startedAt) : undefined,
|
||||
completedAt: s.completedAt ? new Date(s.completedAt) : undefined
|
||||
}))
|
||||
}
|
||||
window.webContents.send(channel, subagentsEvent)
|
||||
}
|
||||
|
||||
// Check for interrupts (HITL)
|
||||
const interrupt = state.__interrupt__ as { id?: string; tool_call?: unknown } | undefined
|
||||
if (interrupt) {
|
||||
const streamEvent: StreamEvent = {
|
||||
type: 'interrupt',
|
||||
request: {
|
||||
id: interrupt.id || crypto.randomUUID(),
|
||||
tool_call: interrupt.tool_call as { id: string; name: string; args: Record<string, unknown> },
|
||||
allowed_decisions: ['approve', 'reject', 'edit']
|
||||
}
|
||||
}
|
||||
window.webContents.send(channel, streamEvent)
|
||||
}
|
||||
}
|
||||
|
||||
// Send done event
|
||||
console.log('[Agent] Stream complete, sending done event')
|
||||
const doneEvent: StreamEvent = { type: 'done', result: null }
|
||||
window.webContents.send(channel, doneEvent)
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Agent] Error:', error)
|
||||
const errorEvent: StreamEvent = {
|
||||
type: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}
|
||||
window.webContents.send(channel, errorEvent)
|
||||
} finally {
|
||||
activeRuns.delete(threadId)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle HITL interrupt response
|
||||
ipcMain.handle('agent:interrupt', async (_event, { threadId, decision }: { threadId: string; decision: HITLDecision }) => {
|
||||
const agent = await createAgentRuntime()
|
||||
|
||||
// Get the current state
|
||||
const config = { configurable: { thread_id: threadId } }
|
||||
|
||||
// Resume with the decision
|
||||
if (decision.type === 'approve') {
|
||||
// Continue execution
|
||||
await agent.invoke(null, config)
|
||||
} else if (decision.type === 'reject') {
|
||||
// Cancel the tool call
|
||||
// The agent will handle this via Command
|
||||
} else if (decision.type === 'edit') {
|
||||
// Update the tool call args and continue
|
||||
// This requires updating state before resuming
|
||||
}
|
||||
})
|
||||
|
||||
// Handle cancellation
|
||||
ipcMain.handle('agent:cancel', async (_event, { threadId }: { threadId: string }) => {
|
||||
const controller = activeRuns.get(threadId)
|
||||
if (controller) {
|
||||
controller.abort()
|
||||
activeRuns.delete(threadId)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { IpcMain } from 'electron'
|
||||
import Store from 'electron-store'
|
||||
import type { ModelConfig } from '../types'
|
||||
|
||||
// Encrypted store for API keys
|
||||
const store = new Store({
|
||||
name: 'openwork-settings',
|
||||
encryptionKey: 'openwork-encryption-key-v1' // In production, derive from machine ID
|
||||
})
|
||||
|
||||
// Available models configuration
|
||||
const AVAILABLE_MODELS: ModelConfig[] = [
|
||||
{
|
||||
id: 'claude-sonnet-4-20250514',
|
||||
name: 'Claude Sonnet 4',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
description: 'Latest Claude model, best for complex tasks',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
id: 'claude-3-5-sonnet-20241022',
|
||||
name: 'Claude 3.5 Sonnet',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
description: 'Excellent balance of speed and capability',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
id: 'claude-3-5-haiku-20241022',
|
||||
name: 'Claude 3.5 Haiku',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-3-5-haiku-20241022',
|
||||
description: 'Fast and efficient for simpler tasks',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
id: 'gpt-4o',
|
||||
name: 'GPT-4o',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4o',
|
||||
description: 'OpenAI flagship model',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
id: 'gpt-4o-mini',
|
||||
name: 'GPT-4o Mini',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4o-mini',
|
||||
description: 'Smaller, faster GPT-4o variant',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
id: 'gemini-2.0-flash',
|
||||
name: 'Gemini 2.0 Flash',
|
||||
provider: 'google',
|
||||
model: 'gemini-2.0-flash',
|
||||
description: 'Google fast model',
|
||||
available: true
|
||||
}
|
||||
]
|
||||
|
||||
export function registerModelHandlers(ipcMain: IpcMain) {
|
||||
// List available models
|
||||
ipcMain.handle('models:list', async () => {
|
||||
// Check which models have API keys configured
|
||||
return AVAILABLE_MODELS.map(model => ({
|
||||
...model,
|
||||
available: hasApiKey(model.provider)
|
||||
}))
|
||||
})
|
||||
|
||||
// Get default model
|
||||
ipcMain.handle('models:getDefault', async () => {
|
||||
return store.get('defaultModel', 'claude-sonnet-4-20250514') as string
|
||||
})
|
||||
|
||||
// Set default model
|
||||
ipcMain.handle('models:setDefault', async (_event, modelId: string) => {
|
||||
store.set('defaultModel', modelId)
|
||||
})
|
||||
|
||||
// Set API key for a provider
|
||||
ipcMain.handle('models:setApiKey', async (_event, { provider, apiKey }: { provider: string; apiKey: string }) => {
|
||||
store.set(`apiKeys.${provider}`, apiKey)
|
||||
|
||||
// Also set as environment variable for the current session
|
||||
const envVarMap: Record<string, string> = {
|
||||
anthropic: 'ANTHROPIC_API_KEY',
|
||||
openai: 'OPENAI_API_KEY',
|
||||
google: 'GOOGLE_API_KEY'
|
||||
}
|
||||
|
||||
const envVar = envVarMap[provider]
|
||||
if (envVar) {
|
||||
process.env[envVar] = apiKey
|
||||
}
|
||||
})
|
||||
|
||||
// Get API key for a provider
|
||||
ipcMain.handle('models:getApiKey', async (_event, provider: string) => {
|
||||
return store.get(`apiKeys.${provider}`, null) as string | null
|
||||
})
|
||||
|
||||
// Sync version info
|
||||
ipcMain.on('app:version', (event) => {
|
||||
event.returnValue = require('../../package.json').version
|
||||
})
|
||||
}
|
||||
|
||||
function hasApiKey(provider: string): boolean {
|
||||
// Check store first
|
||||
const storedKey = store.get(`apiKeys.${provider}`) as string | undefined
|
||||
if (storedKey) return true
|
||||
|
||||
// Check environment variables
|
||||
const envVarMap: Record<string, string> = {
|
||||
anthropic: 'ANTHROPIC_API_KEY',
|
||||
openai: 'OPENAI_API_KEY',
|
||||
google: 'GOOGLE_API_KEY'
|
||||
}
|
||||
|
||||
const envVar = envVarMap[provider]
|
||||
return envVar ? !!process.env[envVar] : false
|
||||
}
|
||||
|
||||
// Export for use in agent runtime
|
||||
export function getApiKey(provider: string): string | undefined {
|
||||
const storedKey = store.get(`apiKeys.${provider}`) as string | undefined
|
||||
if (storedKey) return storedKey
|
||||
|
||||
const envVarMap: Record<string, string> = {
|
||||
anthropic: 'ANTHROPIC_API_KEY',
|
||||
openai: 'OPENAI_API_KEY',
|
||||
google: 'GOOGLE_API_KEY'
|
||||
}
|
||||
|
||||
const envVar = envVarMap[provider]
|
||||
return envVar ? process.env[envVar] : undefined
|
||||
}
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return store.get('defaultModel', 'claude-sonnet-4-20250514') as string
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { IpcMain } from 'electron'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import {
|
||||
getAllThreads,
|
||||
getThread,
|
||||
createThread as dbCreateThread,
|
||||
updateThread as dbUpdateThread,
|
||||
deleteThread as dbDeleteThread
|
||||
} from '../db'
|
||||
import { getCheckpointer } from '../agent/runtime'
|
||||
import { generateTitle } from '../services/title-generator'
|
||||
import type { Thread } from '../types'
|
||||
|
||||
export function registerThreadHandlers(ipcMain: IpcMain) {
|
||||
// List all threads
|
||||
ipcMain.handle('threads:list', async () => {
|
||||
const threads = getAllThreads()
|
||||
return threads.map((row) => ({
|
||||
thread_id: row.thread_id,
|
||||
created_at: new Date(row.created_at),
|
||||
updated_at: new Date(row.updated_at),
|
||||
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
|
||||
status: row.status as Thread['status'],
|
||||
thread_values: row.thread_values ? JSON.parse(row.thread_values) : undefined,
|
||||
title: row.title
|
||||
}))
|
||||
})
|
||||
|
||||
// Get a single thread
|
||||
ipcMain.handle('threads:get', async (_event, threadId: string) => {
|
||||
const row = getThread(threadId)
|
||||
if (!row) return null
|
||||
return {
|
||||
thread_id: row.thread_id,
|
||||
created_at: new Date(row.created_at),
|
||||
updated_at: new Date(row.updated_at),
|
||||
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
|
||||
status: row.status as Thread['status'],
|
||||
thread_values: row.thread_values ? JSON.parse(row.thread_values) : undefined,
|
||||
title: row.title
|
||||
}
|
||||
})
|
||||
|
||||
// Create a new thread
|
||||
ipcMain.handle('threads:create', async (_event, metadata?: Record<string, unknown>) => {
|
||||
const threadId = uuid()
|
||||
const title = (metadata?.title as string) || `Thread ${new Date().toLocaleDateString()}`
|
||||
|
||||
const thread = dbCreateThread(threadId, { ...metadata, title })
|
||||
|
||||
return {
|
||||
thread_id: thread.thread_id,
|
||||
created_at: new Date(thread.created_at),
|
||||
updated_at: new Date(thread.updated_at),
|
||||
metadata: thread.metadata ? JSON.parse(thread.metadata) : undefined,
|
||||
status: thread.status as Thread['status'],
|
||||
thread_values: thread.thread_values ? JSON.parse(thread.thread_values) : undefined,
|
||||
title
|
||||
} as Thread
|
||||
})
|
||||
|
||||
// Update a thread
|
||||
ipcMain.handle(
|
||||
'threads:update',
|
||||
async (_event, { threadId, updates }: { threadId: string; updates: Partial<Thread> }) => {
|
||||
const updateData: Parameters<typeof dbUpdateThread>[1] = {}
|
||||
|
||||
if (updates.title !== undefined) updateData.title = updates.title
|
||||
if (updates.status !== undefined) updateData.status = updates.status
|
||||
if (updates.metadata !== undefined)
|
||||
updateData.metadata = JSON.stringify(updates.metadata)
|
||||
if (updates.thread_values !== undefined) updateData.thread_values = JSON.stringify(updates.thread_values)
|
||||
|
||||
const row = dbUpdateThread(threadId, updateData)
|
||||
if (!row) throw new Error('Thread not found')
|
||||
|
||||
return {
|
||||
thread_id: row.thread_id,
|
||||
created_at: new Date(row.created_at),
|
||||
updated_at: new Date(row.updated_at),
|
||||
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
|
||||
status: row.status as Thread['status'],
|
||||
thread_values: row.thread_values ? JSON.parse(row.thread_values) : undefined,
|
||||
title: row.title
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Delete a thread
|
||||
ipcMain.handle('threads:delete', async (_event, threadId: string) => {
|
||||
console.log('[Threads] Deleting thread:', threadId)
|
||||
|
||||
// Delete from our metadata store
|
||||
dbDeleteThread(threadId)
|
||||
console.log('[Threads] Deleted from metadata store')
|
||||
|
||||
// Also delete from LangGraph checkpointer
|
||||
try {
|
||||
const checkpointer = await getCheckpointer()
|
||||
await checkpointer.deleteThread(threadId)
|
||||
console.log('[Threads] Deleted from checkpointer')
|
||||
} catch (e) {
|
||||
console.warn('[Threads] Failed to delete thread from checkpointer:', e)
|
||||
}
|
||||
})
|
||||
|
||||
// Get thread history (checkpoints)
|
||||
ipcMain.handle('threads:history', async (_event, threadId: string) => {
|
||||
try {
|
||||
const checkpointer = await getCheckpointer()
|
||||
|
||||
const history: unknown[] = []
|
||||
const config = { configurable: { thread_id: threadId } }
|
||||
|
||||
for await (const checkpoint of checkpointer.list(config, { limit: 50 })) {
|
||||
history.push(checkpoint)
|
||||
}
|
||||
|
||||
return history
|
||||
} catch (e) {
|
||||
console.warn('Failed to get thread history:', e)
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
// Generate a title from a message
|
||||
ipcMain.handle('threads:generateTitle', async (_event, message: string) => {
|
||||
return generateTitle(message)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Generate a short, descriptive title from a user's first message.
|
||||
*
|
||||
* Uses heuristics to extract a meaningful title:
|
||||
* - For short messages: use as-is
|
||||
* - For questions: use the first sentence/question
|
||||
* - For longer text: use first N words
|
||||
*
|
||||
* @param message - The user's first message
|
||||
* @returns A short title (max ~50 chars)
|
||||
*/
|
||||
export function generateTitle(message: string): string {
|
||||
// Clean up the message
|
||||
const cleaned = message.trim().replace(/\s+/g, ' ')
|
||||
|
||||
// If already short enough, use as-is
|
||||
if (cleaned.length <= 50) {
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// Try to extract first sentence/question
|
||||
const sentenceMatch = cleaned.match(/^[^.!?]+[.!?]/)
|
||||
if (sentenceMatch && sentenceMatch[0].length <= 60) {
|
||||
return sentenceMatch[0].trim()
|
||||
}
|
||||
|
||||
// Extract first N words
|
||||
const words = cleaned.split(/\s+/)
|
||||
let title = ''
|
||||
|
||||
for (const word of words) {
|
||||
if ((title + ' ' + word).length > 47) {
|
||||
break
|
||||
}
|
||||
title = title ? title + ' ' + word : word
|
||||
}
|
||||
|
||||
// Add ellipsis if we truncated
|
||||
if (words.join(' ').length > title.length) {
|
||||
title += '...'
|
||||
}
|
||||
|
||||
return title
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the title generator is ready (always true for heuristic approach)
|
||||
*/
|
||||
export function isModelReady(): boolean {
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Thread types matching langgraph-api
|
||||
export type ThreadStatus = 'idle' | 'busy' | 'interrupted' | 'error'
|
||||
|
||||
export interface Thread {
|
||||
thread_id: string
|
||||
created_at: Date
|
||||
updated_at: Date
|
||||
metadata?: Record<string, unknown>
|
||||
status: ThreadStatus
|
||||
thread_values?: Record<string, unknown>
|
||||
title?: string
|
||||
}
|
||||
|
||||
// Run types
|
||||
export type RunStatus = 'pending' | 'running' | 'error' | 'success' | 'interrupted'
|
||||
|
||||
export interface Run {
|
||||
run_id: string
|
||||
thread_id: string
|
||||
assistant_id?: string
|
||||
created_at: Date
|
||||
updated_at: Date
|
||||
status: RunStatus
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// Model configuration
|
||||
export interface ModelConfig {
|
||||
id: string
|
||||
name: string
|
||||
provider: 'anthropic' | 'openai' | 'google' | 'ollama'
|
||||
model: string
|
||||
description?: string
|
||||
available: boolean
|
||||
}
|
||||
|
||||
// Subagent types (from deepagentsjs)
|
||||
export interface Subagent {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
status: 'pending' | 'running' | 'completed' | 'failed'
|
||||
startedAt?: Date
|
||||
completedAt?: Date
|
||||
}
|
||||
|
||||
// Stream events from agent
|
||||
export type StreamEvent =
|
||||
| { type: 'message'; message: Message }
|
||||
| { type: 'tool_call'; toolCall: ToolCall }
|
||||
| { type: 'tool_result'; toolResult: ToolResult }
|
||||
| { type: 'interrupt'; request: HITLRequest }
|
||||
| { type: 'token'; token: string }
|
||||
| { type: 'todos'; todos: Todo[] }
|
||||
| { type: 'workspace'; files: FileInfo[]; path: string }
|
||||
| { type: 'subagents'; subagents: Subagent[] }
|
||||
| { type: 'done'; result: unknown }
|
||||
| { type: 'error'; error: string }
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system' | 'tool'
|
||||
content: string | ContentBlock[]
|
||||
tool_calls?: ToolCall[]
|
||||
created_at: Date
|
||||
}
|
||||
|
||||
export interface ContentBlock {
|
||||
type: 'text' | 'image' | 'tool_use' | 'tool_result'
|
||||
text?: string
|
||||
tool_use_id?: string
|
||||
name?: string
|
||||
input?: unknown
|
||||
content?: string
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string
|
||||
name: string
|
||||
args: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
tool_call_id: string
|
||||
content: string | unknown
|
||||
is_error?: boolean
|
||||
}
|
||||
|
||||
// Human-in-the-loop
|
||||
export interface HITLRequest {
|
||||
id: string
|
||||
tool_call: ToolCall
|
||||
allowed_decisions: HITLDecision['type'][]
|
||||
}
|
||||
|
||||
export interface HITLDecision {
|
||||
type: 'approve' | 'reject' | 'edit'
|
||||
tool_call_id: string
|
||||
edited_args?: Record<string, unknown>
|
||||
feedback?: string
|
||||
}
|
||||
|
||||
// Todo types (from deepagentsjs)
|
||||
export interface Todo {
|
||||
id: string
|
||||
content: string
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'cancelled'
|
||||
}
|
||||
|
||||
// File types (from deepagentsjs backends)
|
||||
export interface FileInfo {
|
||||
path: string
|
||||
is_dir?: boolean
|
||||
size?: number
|
||||
modified_at?: string
|
||||
}
|
||||
|
||||
export interface GrepMatch {
|
||||
path: string
|
||||
line: number
|
||||
text: string
|
||||
}
|
||||
Vendored
+45
@@ -0,0 +1,45 @@
|
||||
import type { Thread, ModelConfig, StreamEvent, HITLDecision } from '../main/types'
|
||||
|
||||
interface ElectronAPI {
|
||||
ipcRenderer: {
|
||||
send: (channel: string, ...args: unknown[]) => void
|
||||
on: (channel: string, listener: (...args: unknown[]) => void) => () => void
|
||||
once: (channel: string, listener: (...args: unknown[]) => void) => void
|
||||
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>
|
||||
}
|
||||
process: {
|
||||
platform: NodeJS.Platform
|
||||
versions: NodeJS.ProcessVersions
|
||||
}
|
||||
}
|
||||
|
||||
interface CustomAPI {
|
||||
agent: {
|
||||
invoke: (threadId: string, message: string, onEvent: (event: StreamEvent) => void) => () => void
|
||||
interrupt: (threadId: string, decision: HITLDecision) => Promise<void>
|
||||
cancel: (threadId: string) => Promise<void>
|
||||
}
|
||||
threads: {
|
||||
list: () => Promise<Thread[]>
|
||||
get: (threadId: string) => Promise<Thread | null>
|
||||
create: (metadata?: Record<string, unknown>) => Promise<Thread>
|
||||
update: (threadId: string, updates: Partial<Thread>) => Promise<Thread>
|
||||
delete: (threadId: string) => Promise<void>
|
||||
getHistory: (threadId: string) => Promise<unknown[]>
|
||||
generateTitle: (message: string) => Promise<string>
|
||||
}
|
||||
models: {
|
||||
list: () => Promise<ModelConfig[]>
|
||||
getDefault: () => Promise<string>
|
||||
setDefault: (modelId: string) => Promise<void>
|
||||
setApiKey: (provider: string, apiKey: string) => Promise<void>
|
||||
getApiKey: (provider: string) => Promise<string | null>
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI
|
||||
api: CustomAPI
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import type { Thread, ModelConfig, StreamEvent, HITLDecision } from '../main/types'
|
||||
|
||||
// Simple electron API - replaces @electron-toolkit/preload
|
||||
const electronAPI = {
|
||||
ipcRenderer: {
|
||||
send: (channel: string, ...args: unknown[]) => ipcRenderer.send(channel, ...args),
|
||||
on: (channel: string, listener: (...args: unknown[]) => void) => {
|
||||
ipcRenderer.on(channel, (_event, ...args) => listener(...args))
|
||||
return () => ipcRenderer.removeListener(channel, listener)
|
||||
},
|
||||
once: (channel: string, listener: (...args: unknown[]) => void) => {
|
||||
ipcRenderer.once(channel, (_event, ...args) => listener(...args))
|
||||
},
|
||||
invoke: (channel: string, ...args: unknown[]) => ipcRenderer.invoke(channel, ...args)
|
||||
},
|
||||
process: {
|
||||
platform: process.platform,
|
||||
versions: process.versions
|
||||
}
|
||||
}
|
||||
|
||||
// Custom APIs for renderer
|
||||
const api = {
|
||||
agent: {
|
||||
// Send message and receive events via callback
|
||||
invoke: (
|
||||
threadId: string,
|
||||
message: string,
|
||||
onEvent: (event: StreamEvent) => void
|
||||
): (() => void) => {
|
||||
console.log('[Preload] invoke() called', { threadId, message: message.substring(0, 50) })
|
||||
|
||||
const channel = `agent:stream:${threadId}`
|
||||
|
||||
const handler = (_: unknown, data: StreamEvent) => {
|
||||
console.log('[Preload] Received event:', data.type)
|
||||
onEvent(data)
|
||||
|
||||
// Clean up listener on terminal events
|
||||
if (data.type === 'done' || data.type === 'error') {
|
||||
ipcRenderer.removeListener(channel, handler)
|
||||
}
|
||||
}
|
||||
|
||||
ipcRenderer.on(channel, handler)
|
||||
console.log('[Preload] Sending agent:invoke IPC')
|
||||
ipcRenderer.send('agent:invoke', { threadId, message })
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
ipcRenderer.removeListener(channel, handler)
|
||||
}
|
||||
},
|
||||
interrupt: (threadId: string, decision: HITLDecision): Promise<void> => {
|
||||
return ipcRenderer.invoke('agent:interrupt', { threadId, decision })
|
||||
},
|
||||
cancel: (threadId: string): Promise<void> => {
|
||||
return ipcRenderer.invoke('agent:cancel', { threadId })
|
||||
}
|
||||
},
|
||||
threads: {
|
||||
list: (): Promise<Thread[]> => {
|
||||
return ipcRenderer.invoke('threads:list')
|
||||
},
|
||||
get: (threadId: string): Promise<Thread | null> => {
|
||||
return ipcRenderer.invoke('threads:get', threadId)
|
||||
},
|
||||
create: (metadata?: Record<string, unknown>): Promise<Thread> => {
|
||||
return ipcRenderer.invoke('threads:create', metadata)
|
||||
},
|
||||
update: (threadId: string, updates: Partial<Thread>): Promise<Thread> => {
|
||||
return ipcRenderer.invoke('threads:update', { threadId, updates })
|
||||
},
|
||||
delete: (threadId: string): Promise<void> => {
|
||||
return ipcRenderer.invoke('threads:delete', threadId)
|
||||
},
|
||||
getHistory: (threadId: string): Promise<unknown[]> => {
|
||||
return ipcRenderer.invoke('threads:history', threadId)
|
||||
},
|
||||
generateTitle: (message: string): Promise<string> => {
|
||||
return ipcRenderer.invoke('threads:generateTitle', message)
|
||||
}
|
||||
},
|
||||
models: {
|
||||
list: (): Promise<ModelConfig[]> => {
|
||||
return ipcRenderer.invoke('models:list')
|
||||
},
|
||||
getDefault: (): Promise<string> => {
|
||||
return ipcRenderer.invoke('models:getDefault')
|
||||
},
|
||||
setDefault: (modelId: string): Promise<void> => {
|
||||
return ipcRenderer.invoke('models:setDefault', modelId)
|
||||
},
|
||||
setApiKey: (provider: string, apiKey: string): Promise<void> => {
|
||||
return ipcRenderer.invoke('models:setApiKey', { provider, apiKey })
|
||||
},
|
||||
getApiKey: (provider: string): Promise<string | null> => {
|
||||
return ipcRenderer.invoke('models:getApiKey', provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to renderer
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||
contextBridge.exposeInMainWorld('api', api)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore (define in dts)
|
||||
window.electron = electronAPI
|
||||
// @ts-ignore (define in dts)
|
||||
window.api = api
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>openwork</title>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { ThreadSidebar } from '@/components/sidebar/ThreadSidebar'
|
||||
import { ChatContainer } from '@/components/chat/ChatContainer'
|
||||
import { RightPanel } from '@/components/panels/RightPanel'
|
||||
import { useAppStore } from '@/lib/store'
|
||||
|
||||
function App() {
|
||||
const { currentThreadId, loadThreads, createThread, setSettingsOpen } = useAppStore()
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Keyboard shortcuts
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
// Cmd+, for settings
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === ',') {
|
||||
e.preventDefault()
|
||||
setSettingsOpen(true)
|
||||
}
|
||||
}, [setSettingsOpen])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleKeyDown])
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
try {
|
||||
await loadThreads()
|
||||
// Create a default thread if none exist
|
||||
const threads = useAppStore.getState().threads
|
||||
if (threads.length === 0) {
|
||||
await createThread()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
init()
|
||||
}, [loadThreads, createThread])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="text-muted-foreground">Initializing...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen overflow-hidden bg-background">
|
||||
{/* Draggable titlebar region */}
|
||||
<div className="h-8 w-full shrink-0 app-drag-region bg-sidebar" />
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left Sidebar - Thread List */}
|
||||
<ThreadSidebar />
|
||||
|
||||
{/* Center - Chat Interface */}
|
||||
<main className="flex flex-1 flex-col min-w-0 overflow-hidden">
|
||||
{currentThreadId ? (
|
||||
<ChatContainer threadId={currentThreadId} />
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center text-muted-foreground">
|
||||
Select or create a thread to begin
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Right Panel - Status Panels */}
|
||||
<RightPanel />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Send, Square, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { useAppStore } from '@/lib/store'
|
||||
import { MessageBubble } from './MessageBubble'
|
||||
import { ApprovalDialog } from '@/components/hitl/ApprovalDialog'
|
||||
|
||||
interface ChatContainerProps {
|
||||
threadId: string
|
||||
}
|
||||
|
||||
export function ChatContainer({ threadId }: ChatContainerProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const {
|
||||
messages,
|
||||
isThreadStreaming,
|
||||
getStreamingContent,
|
||||
pendingApproval,
|
||||
sendMessage
|
||||
} = useAppStore()
|
||||
|
||||
// Get streaming state for this specific thread
|
||||
const isStreaming = isThreadStreaming(threadId)
|
||||
const streamingContent = getStreamingContent(threadId)
|
||||
|
||||
// Auto-scroll on new messages
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [messages, streamingContent, threadId])
|
||||
|
||||
// Focus input on mount
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [threadId])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!input.trim() || isStreaming) return
|
||||
|
||||
const message = input.trim()
|
||||
setInput('')
|
||||
await sendMessage(message)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit(e)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-resize textarea based on content
|
||||
const adjustTextareaHeight = () => {
|
||||
const textarea = inputRef.current
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
adjustTextareaHeight()
|
||||
}, [input])
|
||||
|
||||
const handleCancel = async () => {
|
||||
await window.api.agent.cancel(threadId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||
{/* Messages */}
|
||||
<ScrollArea className="flex-1 min-h-0" ref={scrollRef}>
|
||||
<div className="p-4">
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
{messages.length === 0 && !isStreaming && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<div className="text-section-header mb-2">NEW THREAD</div>
|
||||
<div className="text-sm">Start a conversation with the agent</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
|
||||
{/* Streaming indicator */}
|
||||
{isStreaming && streamingContent && (
|
||||
<MessageBubble
|
||||
message={{
|
||||
id: 'streaming',
|
||||
role: 'assistant',
|
||||
content: streamingContent,
|
||||
created_at: new Date()
|
||||
}}
|
||||
isStreaming
|
||||
/>
|
||||
)}
|
||||
|
||||
{isStreaming && !streamingContent && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Agent is thinking...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-border p-4">
|
||||
<form onSubmit={handleSubmit} className="max-w-3xl mx-auto">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Message..."
|
||||
disabled={isStreaming}
|
||||
className="flex-1 min-w-0 resize-none rounded-sm border border-border bg-background px-4 py-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
|
||||
rows={1}
|
||||
style={{ minHeight: '48px', maxHeight: '200px' }}
|
||||
/>
|
||||
<div className="flex items-center shrink-0 pb-2">
|
||||
{isStreaming ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<Square className="size-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
size="icon-sm"
|
||||
disabled={!input.trim()}
|
||||
>
|
||||
<Send className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* HITL Approval Dialog */}
|
||||
{pendingApproval && <ApprovalDialog request={pendingApproval} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { User, Bot } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Message } from '@/types'
|
||||
import { ToolCallRenderer } from './ToolCallRenderer'
|
||||
import { StreamingMarkdown } from './StreamingMarkdown'
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message
|
||||
isStreaming?: boolean
|
||||
}
|
||||
|
||||
export function MessageBubble({ message, isStreaming }: MessageBubbleProps) {
|
||||
const isUser = message.role === 'user'
|
||||
const isTool = message.role === 'tool'
|
||||
|
||||
// Hide all tool result messages - they're shown inline with tool calls
|
||||
if (isTool) {
|
||||
return null
|
||||
}
|
||||
|
||||
const getIcon = () => {
|
||||
if (isUser) return <User className="size-4" />
|
||||
return <Bot className="size-4" />
|
||||
}
|
||||
|
||||
const getLabel = () => {
|
||||
if (isUser) return 'YOU'
|
||||
return 'AGENT'
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (typeof message.content === 'string') {
|
||||
// Empty content
|
||||
if (!message.content.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Use streaming markdown for assistant messages, plain text for user messages
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className="whitespace-pre-wrap text-sm">
|
||||
{message.content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<StreamingMarkdown isStreaming={isStreaming}>
|
||||
{message.content}
|
||||
</StreamingMarkdown>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle content blocks
|
||||
const renderedBlocks = message.content.map((block, index) => {
|
||||
if (block.type === 'text' && block.text) {
|
||||
// Use streaming markdown for assistant text blocks
|
||||
if (isUser) {
|
||||
return (
|
||||
<div key={index} className="whitespace-pre-wrap text-sm">
|
||||
{block.text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<StreamingMarkdown key={index} isStreaming={isStreaming}>
|
||||
{block.text}
|
||||
</StreamingMarkdown>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}).filter(Boolean)
|
||||
|
||||
return renderedBlocks.length > 0 ? renderedBlocks : null
|
||||
}
|
||||
|
||||
const content = renderContent()
|
||||
const hasToolCalls = message.tool_calls && message.tool_calls.length > 0
|
||||
|
||||
// Don't render if there's no content and no tool calls
|
||||
if (!content && !hasToolCalls) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 overflow-hidden">
|
||||
{/* Left avatar column - shows for agent/tool */}
|
||||
<div className="w-8 shrink-0">
|
||||
{!isUser && (
|
||||
<div className="flex size-8 items-center justify-center rounded-sm bg-status-info/10 text-status-info">
|
||||
{getIcon()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content column - always same width */}
|
||||
<div className="flex-1 min-w-0 space-y-2 overflow-hidden">
|
||||
<div className={cn(
|
||||
"text-section-header",
|
||||
isUser && "text-right"
|
||||
)}>
|
||||
{getLabel()}
|
||||
</div>
|
||||
|
||||
{content && (
|
||||
<div className={cn(
|
||||
"rounded-sm p-3 overflow-hidden",
|
||||
isUser ? "bg-primary/10" : "bg-card"
|
||||
)}>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool calls */}
|
||||
{hasToolCalls && (
|
||||
<div className="space-y-2 overflow-hidden">
|
||||
{message.tool_calls!.map((toolCall) => (
|
||||
<ToolCallRenderer key={toolCall.id} toolCall={toolCall} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right avatar column - shows for user */}
|
||||
<div className="w-8 shrink-0">
|
||||
{isUser && (
|
||||
<div className="flex size-8 items-center justify-center rounded-sm bg-primary/10 text-primary">
|
||||
{getIcon()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { memo } from 'react'
|
||||
|
||||
interface StreamingMarkdownProps {
|
||||
children: string
|
||||
isStreaming?: boolean
|
||||
}
|
||||
|
||||
export const StreamingMarkdown = memo(function StreamingMarkdown({
|
||||
children,
|
||||
isStreaming = false
|
||||
}: StreamingMarkdownProps) {
|
||||
return (
|
||||
<div className="streaming-markdown">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-2 h-4 ml-0.5 bg-foreground/70 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,431 @@
|
||||
import {
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Search,
|
||||
Edit,
|
||||
Terminal,
|
||||
ListTodo,
|
||||
GitBranch,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Clock,
|
||||
XCircle,
|
||||
File,
|
||||
Folder
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ToolCall, Todo } from '@/types'
|
||||
|
||||
interface ToolCallRendererProps {
|
||||
toolCall: ToolCall
|
||||
result?: string | unknown
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
const TOOL_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
read_file: FileText,
|
||||
write_file: Edit,
|
||||
edit_file: Edit,
|
||||
ls: FolderOpen,
|
||||
glob: FolderOpen,
|
||||
grep: Search,
|
||||
execute: Terminal,
|
||||
write_todos: ListTodo,
|
||||
task: GitBranch,
|
||||
}
|
||||
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
read_file: 'Read File',
|
||||
write_file: 'Write File',
|
||||
edit_file: 'Edit File',
|
||||
ls: 'List Directory',
|
||||
glob: 'Find Files',
|
||||
grep: 'Search Content',
|
||||
execute: 'Execute Command',
|
||||
write_todos: 'Update Tasks',
|
||||
task: 'Subagent Task',
|
||||
}
|
||||
|
||||
// Tools whose results are shown in the UI panels and don't need verbose display
|
||||
const PANEL_SYNCED_TOOLS = new Set(['write_todos'])
|
||||
|
||||
// Helper to get a clean file name from path
|
||||
function getFileName(path: string): string {
|
||||
return path.split('/').pop() || path
|
||||
}
|
||||
|
||||
// Render todos nicely
|
||||
function TodosDisplay({ todos }: { todos: Todo[] }) {
|
||||
const statusConfig = {
|
||||
pending: { icon: Circle, color: 'text-muted-foreground' },
|
||||
in_progress: { icon: Clock, color: 'text-status-info' },
|
||||
completed: { icon: CheckCircle2, color: 'text-status-nominal' },
|
||||
cancelled: { icon: XCircle, color: 'text-muted-foreground' }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{todos.map((todo, i) => {
|
||||
const config = statusConfig[todo.status]
|
||||
const Icon = config.icon
|
||||
const isDone = todo.status === 'completed' || todo.status === 'cancelled'
|
||||
return (
|
||||
<div key={todo.id || i} className={cn(
|
||||
"flex items-start gap-2 text-xs",
|
||||
isDone && "opacity-50"
|
||||
)}>
|
||||
<Icon className={cn("size-3.5 mt-0.5 shrink-0", config.color)} />
|
||||
<span className={cn(isDone && "line-through")}>{todo.content}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render file list nicely
|
||||
function FileListDisplay({ files, isGlob }: { files: string[] | Array<{ path: string; is_dir?: boolean }>; isGlob?: boolean }) {
|
||||
const items = files.slice(0, 15) // Limit display
|
||||
const hasMore = files.length > 15
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{items.map((file, i) => {
|
||||
const path = typeof file === 'string' ? file : file.path
|
||||
const isDir = typeof file === 'object' && file.is_dir
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-2 text-xs font-mono">
|
||||
{isDir ? (
|
||||
<Folder className="size-3 text-status-warning shrink-0" />
|
||||
) : (
|
||||
<File className="size-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{isGlob ? path : getFileName(path)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{hasMore && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
... and {files.length - 15} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render grep results nicely
|
||||
function GrepResultsDisplay({ matches }: { matches: Array<{ path: string; line?: number; text?: string }> }) {
|
||||
const grouped = matches.reduce((acc, match) => {
|
||||
if (!acc[match.path]) acc[match.path] = []
|
||||
acc[match.path].push(match)
|
||||
return acc
|
||||
}, {} as Record<string, typeof matches>)
|
||||
|
||||
const files = Object.keys(grouped).slice(0, 5)
|
||||
const hasMore = Object.keys(grouped).length > 5
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{files.map(path => (
|
||||
<div key={path} className="text-xs">
|
||||
<div className="flex items-center gap-1.5 font-medium text-status-info mb-1">
|
||||
<FileText className="size-3" />
|
||||
{getFileName(path)}
|
||||
</div>
|
||||
<div className="space-y-0.5 pl-4 border-l border-border/50">
|
||||
{grouped[path].slice(0, 3).map((match, i) => (
|
||||
<div key={i} className="font-mono text-muted-foreground truncate">
|
||||
{match.line && <span className="text-status-warning mr-2">{match.line}:</span>}
|
||||
{match.text?.trim()}
|
||||
</div>
|
||||
))}
|
||||
{grouped[path].length > 3 && (
|
||||
<div className="text-muted-foreground">+{grouped[path].length - 3} more matches</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
... matches in {Object.keys(grouped).length - 5} more files
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render file content preview
|
||||
function FileContentPreview({ content }: { content: string; path?: string }) {
|
||||
const lines = content.split('\n')
|
||||
const preview = lines.slice(0, 10)
|
||||
const hasMore = lines.length > 10
|
||||
|
||||
return (
|
||||
<div className="text-xs font-mono bg-background rounded-sm overflow-hidden w-full">
|
||||
<pre className="p-2 overflow-auto max-h-40 w-full">
|
||||
{preview.map((line, i) => (
|
||||
<div key={i} className="flex min-w-0">
|
||||
<span className="w-8 shrink-0 text-muted-foreground select-none pr-2 text-right">{i + 1}</span>
|
||||
<span className="flex-1 min-w-0 truncate">{line || ' '}</span>
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
{hasMore && (
|
||||
<div className="px-2 py-1 text-muted-foreground bg-background-elevated border-t border-border">
|
||||
... {lines.length - 10} more lines
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render edit/write file summary
|
||||
function FileEditSummary({ args }: { args: Record<string, unknown> }) {
|
||||
const path = (args.path || args.file_path) as string
|
||||
const content = args.content as string | undefined
|
||||
const oldStr = args.old_str as string | undefined
|
||||
const newStr = args.new_str as string | undefined
|
||||
|
||||
if (oldStr !== undefined && newStr !== undefined) {
|
||||
// Edit operation
|
||||
return (
|
||||
<div className="text-xs space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-status-critical">
|
||||
<span className="font-mono bg-status-critical/10 px-1.5 py-0.5 rounded">- {oldStr.split('\n').length} lines</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-status-nominal">
|
||||
<span className="font-mono bg-status-nominal/10 px-1.5 py-0.5 rounded">+ {newStr.split('\n').length} lines</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (content) {
|
||||
const lines = content.split('\n').length
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Writing {lines} lines to {getFileName(path)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Command display
|
||||
function CommandDisplay({ command, output }: { command: string; output?: string }) {
|
||||
return (
|
||||
<div className="text-xs space-y-2 w-full overflow-hidden">
|
||||
<div className="font-mono bg-background rounded-sm p-2 flex items-center gap-2 min-w-0">
|
||||
<span className="text-status-info shrink-0">$</span>
|
||||
<span className="truncate">{command}</span>
|
||||
</div>
|
||||
{output && (
|
||||
<pre className="font-mono bg-background rounded-sm p-2 overflow-auto max-h-32 text-muted-foreground w-full whitespace-pre-wrap break-all">
|
||||
{output.slice(0, 500)}
|
||||
{output.length > 500 && '...'}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Subagent task display
|
||||
function TaskDisplay({ args }: { args: Record<string, unknown> }) {
|
||||
const name = args.name as string | undefined
|
||||
const description = args.description as string | undefined
|
||||
|
||||
return (
|
||||
<div className="text-xs space-y-1">
|
||||
{name && (
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="size-3 text-status-info" />
|
||||
<span className="font-medium">{name}</span>
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-muted-foreground pl-5">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToolCallRenderer({ toolCall, result, isError }: ToolCallRendererProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
const Icon = TOOL_ICONS[toolCall.name] || Terminal
|
||||
const label = TOOL_LABELS[toolCall.name] || toolCall.name
|
||||
const isPanelSynced = PANEL_SYNCED_TOOLS.has(toolCall.name)
|
||||
|
||||
// Format the main argument for display
|
||||
const getDisplayArg = () => {
|
||||
const args = toolCall.args
|
||||
if (args.path) return args.path as string
|
||||
if (args.file_path) return args.file_path as string
|
||||
if (args.command) return (args.command as string).slice(0, 50)
|
||||
if (args.pattern) return args.pattern as string
|
||||
if (args.query) return args.query as string
|
||||
if (args.glob) return args.glob as string
|
||||
return null
|
||||
}
|
||||
|
||||
const displayArg = getDisplayArg()
|
||||
|
||||
// Render formatted content based on tool type
|
||||
const renderFormattedContent = () => {
|
||||
const args = toolCall.args
|
||||
|
||||
switch (toolCall.name) {
|
||||
case 'write_todos': {
|
||||
const todos = args.todos as Todo[] | undefined
|
||||
if (todos && todos.length > 0) {
|
||||
return <TodosDisplay todos={todos} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
case 'task': {
|
||||
return <TaskDisplay args={args} />
|
||||
}
|
||||
|
||||
case 'edit_file':
|
||||
case 'write_file': {
|
||||
return <FileEditSummary args={args} />
|
||||
}
|
||||
|
||||
case 'execute': {
|
||||
const command = args.command as string
|
||||
const output = typeof result === 'string' ? result : undefined
|
||||
return <CommandDisplay command={command} output={isExpanded ? output : undefined} />
|
||||
}
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Render result based on tool type
|
||||
const renderFormattedResult = () => {
|
||||
if (result === undefined || isError) return null
|
||||
|
||||
switch (toolCall.name) {
|
||||
case 'read_file': {
|
||||
const content = typeof result === 'string' ? result : JSON.stringify(result)
|
||||
const path = (toolCall.args.path || toolCall.args.file_path) as string
|
||||
return <FileContentPreview content={content} path={path} />
|
||||
}
|
||||
|
||||
case 'ls': {
|
||||
if (Array.isArray(result)) {
|
||||
return <FileListDisplay files={result} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
case 'glob': {
|
||||
if (Array.isArray(result)) {
|
||||
return <FileListDisplay files={result} isGlob />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
case 'grep': {
|
||||
if (Array.isArray(result)) {
|
||||
return <GrepResultsDisplay matches={result} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
case 'write_todos':
|
||||
// Already shown in Tasks panel
|
||||
return null
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const formattedContent = renderFormattedContent()
|
||||
const formattedResult = renderFormattedResult()
|
||||
const hasFormattedDisplay = formattedContent || formattedResult
|
||||
|
||||
return (
|
||||
<div className="rounded-sm border border-border bg-background-elevated overflow-hidden">
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 hover:bg-background-interactive transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="size-4 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
|
||||
<Icon className="size-4 text-status-info shrink-0" />
|
||||
|
||||
<span className="text-xs font-medium shrink-0">{label}</span>
|
||||
|
||||
{displayArg && (
|
||||
<span className="flex-1 truncate text-left text-xs text-muted-foreground font-mono">
|
||||
{displayArg}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{result !== undefined && (
|
||||
<Badge variant={isError ? 'critical' : 'nominal'} className="ml-auto shrink-0">
|
||||
{isError ? 'ERROR' : 'OK'}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{isPanelSynced && (
|
||||
<Badge variant="outline" className="shrink-0 text-[9px]">
|
||||
SYNCED
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Formatted content (always visible if present) */}
|
||||
{hasFormattedDisplay && !isExpanded && (
|
||||
<div className="border-t border-border px-3 py-2 space-y-2 overflow-hidden">
|
||||
{formattedContent}
|
||||
{formattedResult}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded content - raw details */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border px-3 py-2 space-y-2 overflow-hidden">
|
||||
{/* Formatted display first */}
|
||||
{formattedContent}
|
||||
{formattedResult}
|
||||
|
||||
{/* Raw Arguments */}
|
||||
<div className="overflow-hidden w-full">
|
||||
<div className="text-section-header mb-1">RAW ARGUMENTS</div>
|
||||
<pre className="text-xs font-mono bg-background p-2 rounded-sm overflow-auto max-h-48 w-full whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(toolCall.args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Raw Result */}
|
||||
{result !== undefined && (
|
||||
<div className="overflow-hidden w-full">
|
||||
<div className="text-section-header mb-1">RAW RESULT</div>
|
||||
<pre className={cn(
|
||||
"text-xs font-mono p-2 rounded-sm overflow-auto max-h-48 w-full whitespace-pre-wrap break-all",
|
||||
isError ? "bg-status-critical/10 text-status-critical" : "bg-background"
|
||||
)}>
|
||||
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle, Check, X, Edit2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useAppStore } from '@/lib/store'
|
||||
import type { HITLRequest } from '@/types'
|
||||
|
||||
interface ApprovalDialogProps {
|
||||
request: HITLRequest
|
||||
}
|
||||
|
||||
export function ApprovalDialog({ request }: ApprovalDialogProps) {
|
||||
const { respondToApproval } = useAppStore()
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editedArgs, setEditedArgs] = useState(
|
||||
JSON.stringify(request.tool_call.args, null, 2)
|
||||
)
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (isEditing) {
|
||||
try {
|
||||
const parsed = JSON.parse(editedArgs)
|
||||
await respondToApproval('edit', parsed)
|
||||
} catch (e) {
|
||||
// Invalid JSON, show error
|
||||
return
|
||||
}
|
||||
} else {
|
||||
await respondToApproval('approve')
|
||||
}
|
||||
}
|
||||
|
||||
const handleReject = async () => {
|
||||
await respondToApproval('reject')
|
||||
}
|
||||
|
||||
const getToolWarning = () => {
|
||||
const name = request.tool_call.name
|
||||
if (name === 'execute') return 'This will execute a shell command'
|
||||
if (name === 'write_file') return 'This will create or overwrite a file'
|
||||
if (name === 'edit_file') return 'This will modify an existing file'
|
||||
return null
|
||||
}
|
||||
|
||||
const warning = getToolWarning()
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||
<div className="w-full max-w-lg rounded-sm border border-border bg-card p-6 shadow-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<AlertTriangle className="size-5 text-status-warning" />
|
||||
<h2 className="text-lg font-medium">Tool Approval Required</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The agent wants to execute the following action
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="warning">{request.tool_call.name}</Badge>
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
{warning && (
|
||||
<div className="mb-4 rounded-sm border border-status-warning/30 bg-status-warning/10 p-3 text-sm text-status-warning">
|
||||
{warning}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Arguments */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-section-header">ARGUMENTS</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsEditing(!isEditing)}
|
||||
>
|
||||
<Edit2 className="size-3 mr-1" />
|
||||
{isEditing ? 'Cancel Edit' : 'Edit'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editedArgs}
|
||||
onChange={(e) => setEditedArgs(e.target.value)}
|
||||
className="w-full h-48 rounded-sm border border-border bg-background p-3 font-mono text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
) : (
|
||||
<pre className="rounded-sm border border-border bg-background p-3 font-mono text-xs overflow-x-auto max-h-48">
|
||||
{JSON.stringify(request.tool_call.args, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" onClick={handleReject}>
|
||||
<X className="size-4 mr-1" />
|
||||
Reject
|
||||
</Button>
|
||||
<Button variant="nominal" onClick={handleApprove}>
|
||||
<Check className="size-4 mr-1" />
|
||||
{isEditing ? 'Apply & Approve' : 'Approve'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Folder, File, ChevronRight, ChevronDown, FolderOpen } from 'lucide-react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAppStore } from '@/lib/store'
|
||||
import type { FileInfo } from '@/types'
|
||||
|
||||
export function FilesystemPanel() {
|
||||
const { workspaceFiles, workspacePath } = useAppStore()
|
||||
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set())
|
||||
|
||||
// Auto-expand root when workspace path changes
|
||||
useEffect(() => {
|
||||
if (workspacePath) {
|
||||
setExpandedDirs(new Set([workspacePath]))
|
||||
}
|
||||
}, [workspacePath])
|
||||
|
||||
// Build tree structure
|
||||
const buildTree = (files: FileInfo[]) => {
|
||||
const tree: Map<string, FileInfo[]> = new Map()
|
||||
|
||||
files.forEach(file => {
|
||||
const parts = file.path.split('/')
|
||||
const parentPath = parts.slice(0, -1).join('/') || '/'
|
||||
|
||||
if (!tree.has(parentPath)) {
|
||||
tree.set(parentPath, [])
|
||||
}
|
||||
tree.get(parentPath)!.push(file)
|
||||
})
|
||||
|
||||
return tree
|
||||
}
|
||||
|
||||
const tree = buildTree(workspaceFiles)
|
||||
|
||||
const toggleDir = (path: string) => {
|
||||
setExpandedDirs(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(path)) {
|
||||
next.delete(path)
|
||||
} else {
|
||||
next.add(path)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const renderNode = (file: FileInfo, depth: number = 0) => {
|
||||
const name = file.path.split('/').pop() || file.path
|
||||
const isExpanded = expandedDirs.has(file.path)
|
||||
const children = tree.get(file.path) || []
|
||||
|
||||
return (
|
||||
<div key={file.path}>
|
||||
<button
|
||||
onClick={() => file.is_dir && toggleDir(file.path)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-1.5 text-sm hover:bg-background-interactive transition-colors",
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 16 + 12}px` }}
|
||||
>
|
||||
{file.is_dir ? (
|
||||
<>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
<Folder className="size-4 text-status-warning" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="w-3" />
|
||||
<File className="size-4 text-muted-foreground" />
|
||||
</>
|
||||
)}
|
||||
<span className="flex-1 text-left truncate">{name}</span>
|
||||
{!file.is_dir && file.size && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{formatSize(file.size)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{file.is_dir && isExpanded && children.map(child => renderNode(child, depth + 1))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Get root level items
|
||||
const rootItems = tree.get('/') || tree.get('') || []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-section-header">WORKSPACE</span>
|
||||
{workspacePath && (
|
||||
<span className="text-[10px] text-muted-foreground truncate max-w-[180px]" title={workspacePath}>
|
||||
{workspacePath.split('/').pop()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="py-2">
|
||||
{rootItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center text-center text-sm text-muted-foreground py-8 px-4">
|
||||
<FolderOpen className="size-8 mb-2 opacity-50" />
|
||||
<span>No workspace files</span>
|
||||
<span className="text-xs mt-1">
|
||||
Files will appear here when the agent accesses them
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
rootItems.map(file => renderNode(file))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { ListTodo, FolderTree, GitBranch, ChevronRight, CheckCircle2, Circle, Clock, XCircle, GripHorizontal } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAppStore } from '@/lib/store'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { Todo } from '@/types'
|
||||
|
||||
const HEADER_HEIGHT = 40 // px
|
||||
const HANDLE_HEIGHT = 6 // px
|
||||
const MIN_CONTENT_HEIGHT = 60 // px
|
||||
const COLLAPSE_THRESHOLD = 55 // px - auto-collapse when below this
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string
|
||||
icon: React.ElementType
|
||||
badge?: number
|
||||
isOpen: boolean
|
||||
onToggle: () => void
|
||||
}
|
||||
|
||||
function SectionHeader({ title, icon: Icon, badge, isOpen, onToggle }: SectionHeaderProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex items-center gap-2 px-3 py-2.5 text-section-header hover:bg-background-interactive transition-colors shrink-0 w-full"
|
||||
style={{ height: HEADER_HEIGHT }}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-3.5 text-muted-foreground transition-transform duration-200",
|
||||
isOpen && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
<Icon className="size-4" />
|
||||
<span className="flex-1 text-left">{title}</span>
|
||||
{badge !== undefined && badge > 0 && (
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">{badge}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
interface ResizeHandleProps {
|
||||
onDrag: (delta: number) => void
|
||||
}
|
||||
|
||||
function ResizeHandle({ onDrag }: ResizeHandleProps) {
|
||||
const startYRef = useRef<number>(0)
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
startYRef.current = e.clientY
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
// Calculate total delta from drag start
|
||||
const totalDelta = e.clientY - startYRef.current
|
||||
onDrag(totalDelta)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
document.body.style.cursor = 'row-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
}, [onDrag])
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className="group bg-border/50 hover:bg-primary/30 active:bg-primary/50 transition-colors cursor-row-resize flex items-center justify-center shrink-0 select-none"
|
||||
style={{ height: HANDLE_HEIGHT }}
|
||||
>
|
||||
<GripHorizontal className="size-4 text-muted-foreground/50 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RightPanel() {
|
||||
const { todos, workspaceFiles, subagents } = useAppStore()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [tasksOpen, setTasksOpen] = useState(true)
|
||||
const [filesOpen, setFilesOpen] = useState(true)
|
||||
const [agentsOpen, setAgentsOpen] = useState(true)
|
||||
|
||||
// Store content heights in pixels (null = auto/equal distribution)
|
||||
const [tasksHeight, setTasksHeight] = useState<number | null>(null)
|
||||
const [filesHeight, setFilesHeight] = useState<number | null>(null)
|
||||
const [agentsHeight, setAgentsHeight] = useState<number | null>(null)
|
||||
|
||||
// Track drag start heights
|
||||
const dragStartHeights = useRef<{ tasks: number; files: number; agents: number } | null>(null)
|
||||
|
||||
// Calculate available content height
|
||||
const getAvailableContentHeight = useCallback(() => {
|
||||
if (!containerRef.current) return 0
|
||||
const totalHeight = containerRef.current.clientHeight
|
||||
|
||||
// Subtract headers (always visible)
|
||||
let used = HEADER_HEIGHT * 3
|
||||
|
||||
// Subtract handles (only between open panels)
|
||||
if (tasksOpen && (filesOpen || agentsOpen)) used += HANDLE_HEIGHT
|
||||
if (filesOpen && agentsOpen) used += HANDLE_HEIGHT
|
||||
|
||||
return Math.max(0, totalHeight - used)
|
||||
}, [tasksOpen, filesOpen, agentsOpen])
|
||||
|
||||
// Get current heights for each panel's content area
|
||||
const getContentHeights = useCallback(() => {
|
||||
const available = getAvailableContentHeight()
|
||||
const openCount = [tasksOpen, filesOpen, agentsOpen].filter(Boolean).length
|
||||
|
||||
if (openCount === 0) {
|
||||
return { tasks: 0, files: 0, agents: 0 }
|
||||
}
|
||||
|
||||
const defaultHeight = available / openCount
|
||||
|
||||
return {
|
||||
tasks: tasksOpen ? (tasksHeight ?? defaultHeight) : 0,
|
||||
files: filesOpen ? (filesHeight ?? defaultHeight) : 0,
|
||||
agents: agentsOpen ? (agentsHeight ?? defaultHeight) : 0,
|
||||
}
|
||||
}, [getAvailableContentHeight, tasksOpen, filesOpen, agentsOpen, tasksHeight, filesHeight, agentsHeight])
|
||||
|
||||
// Handle resize between tasks and the next open section
|
||||
const handleTasksResize = useCallback((totalDelta: number) => {
|
||||
if (!dragStartHeights.current) {
|
||||
const heights = getContentHeights()
|
||||
dragStartHeights.current = { ...heights }
|
||||
}
|
||||
|
||||
const start = dragStartHeights.current
|
||||
const available = getAvailableContentHeight()
|
||||
|
||||
// Determine which panel is being resized against
|
||||
const otherStart = filesOpen ? start.files : start.agents
|
||||
|
||||
// Calculate new heights with proper clamping
|
||||
let newTasksHeight = start.tasks + totalDelta
|
||||
let newOtherHeight = otherStart - totalDelta
|
||||
|
||||
// Clamp both to min height
|
||||
if (newTasksHeight < MIN_CONTENT_HEIGHT) {
|
||||
newTasksHeight = MIN_CONTENT_HEIGHT
|
||||
newOtherHeight = otherStart + (start.tasks - MIN_CONTENT_HEIGHT)
|
||||
}
|
||||
if (newOtherHeight < MIN_CONTENT_HEIGHT) {
|
||||
newOtherHeight = MIN_CONTENT_HEIGHT
|
||||
newTasksHeight = start.tasks + (otherStart - MIN_CONTENT_HEIGHT)
|
||||
}
|
||||
|
||||
// Ensure total doesn't exceed available (accounting for third panel if open)
|
||||
const thirdPanelHeight = filesOpen && agentsOpen ? (agentsHeight ?? (available / 3)) : 0
|
||||
const maxForTwo = available - thirdPanelHeight
|
||||
if (newTasksHeight + newOtherHeight > maxForTwo) {
|
||||
const excess = (newTasksHeight + newOtherHeight) - maxForTwo
|
||||
if (totalDelta > 0) {
|
||||
newOtherHeight = Math.max(MIN_CONTENT_HEIGHT, newOtherHeight - excess)
|
||||
} else {
|
||||
newTasksHeight = Math.max(MIN_CONTENT_HEIGHT, newTasksHeight - excess)
|
||||
}
|
||||
}
|
||||
|
||||
setTasksHeight(newTasksHeight)
|
||||
if (filesOpen) {
|
||||
setFilesHeight(newOtherHeight)
|
||||
} else if (agentsOpen) {
|
||||
setAgentsHeight(newOtherHeight)
|
||||
}
|
||||
|
||||
// Auto-collapse if below threshold
|
||||
if (newTasksHeight < COLLAPSE_THRESHOLD) {
|
||||
setTasksOpen(false)
|
||||
}
|
||||
if (newOtherHeight < COLLAPSE_THRESHOLD) {
|
||||
if (filesOpen) setFilesOpen(false)
|
||||
else if (agentsOpen) setAgentsOpen(false)
|
||||
}
|
||||
}, [getContentHeights, getAvailableContentHeight, filesOpen, agentsOpen, agentsHeight])
|
||||
|
||||
// Handle resize between files and agents
|
||||
const handleFilesResize = useCallback((totalDelta: number) => {
|
||||
if (!dragStartHeights.current) {
|
||||
const heights = getContentHeights()
|
||||
dragStartHeights.current = { ...heights }
|
||||
}
|
||||
|
||||
const start = dragStartHeights.current
|
||||
const available = getAvailableContentHeight()
|
||||
const tasksH = tasksOpen ? (tasksHeight ?? (available / 3)) : 0
|
||||
const maxForFilesAndAgents = available - tasksH
|
||||
|
||||
// Calculate new heights with proper clamping
|
||||
let newFilesHeight = start.files + totalDelta
|
||||
let newAgentsHeight = start.agents - totalDelta
|
||||
|
||||
// Clamp both to min height
|
||||
if (newFilesHeight < MIN_CONTENT_HEIGHT) {
|
||||
newFilesHeight = MIN_CONTENT_HEIGHT
|
||||
newAgentsHeight = start.agents + (start.files - MIN_CONTENT_HEIGHT)
|
||||
}
|
||||
if (newAgentsHeight < MIN_CONTENT_HEIGHT) {
|
||||
newAgentsHeight = MIN_CONTENT_HEIGHT
|
||||
newFilesHeight = start.files + (start.agents - MIN_CONTENT_HEIGHT)
|
||||
}
|
||||
|
||||
// Ensure total doesn't exceed available
|
||||
if (newFilesHeight + newAgentsHeight > maxForFilesAndAgents) {
|
||||
const excess = (newFilesHeight + newAgentsHeight) - maxForFilesAndAgents
|
||||
if (totalDelta > 0) {
|
||||
newAgentsHeight = Math.max(MIN_CONTENT_HEIGHT, newAgentsHeight - excess)
|
||||
} else {
|
||||
newFilesHeight = Math.max(MIN_CONTENT_HEIGHT, newFilesHeight - excess)
|
||||
}
|
||||
}
|
||||
|
||||
setFilesHeight(newFilesHeight)
|
||||
setAgentsHeight(newAgentsHeight)
|
||||
|
||||
// Auto-collapse if below threshold
|
||||
if (newFilesHeight < COLLAPSE_THRESHOLD) {
|
||||
setFilesOpen(false)
|
||||
}
|
||||
if (newAgentsHeight < COLLAPSE_THRESHOLD) {
|
||||
setAgentsOpen(false)
|
||||
}
|
||||
}, [getContentHeights, getAvailableContentHeight, tasksOpen, tasksHeight])
|
||||
|
||||
// Reset drag start on mouse up
|
||||
useEffect(() => {
|
||||
const handleMouseUp = () => {
|
||||
dragStartHeights.current = null
|
||||
}
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
return () => document.removeEventListener('mouseup', handleMouseUp)
|
||||
}, [])
|
||||
|
||||
// Reset heights when panels open/close to redistribute
|
||||
useEffect(() => {
|
||||
setTasksHeight(null)
|
||||
setFilesHeight(null)
|
||||
setAgentsHeight(null)
|
||||
}, [tasksOpen, filesOpen, agentsOpen])
|
||||
|
||||
const heights = getContentHeights()
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={containerRef}
|
||||
className="flex h-full w-[320px] flex-col border-l border-border bg-sidebar overflow-hidden"
|
||||
>
|
||||
{/* TASKS */}
|
||||
<div className="flex flex-col shrink-0 border-b border-border">
|
||||
<SectionHeader
|
||||
title="TASKS"
|
||||
icon={ListTodo}
|
||||
badge={todos.length}
|
||||
isOpen={tasksOpen}
|
||||
onToggle={() => setTasksOpen(prev => !prev)}
|
||||
/>
|
||||
{tasksOpen && (
|
||||
<div className="overflow-auto" style={{ height: heights.tasks }}>
|
||||
<TasksContent />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resize handle after TASKS */}
|
||||
{tasksOpen && (filesOpen || agentsOpen) && (
|
||||
<ResizeHandle onDrag={handleTasksResize} />
|
||||
)}
|
||||
|
||||
{/* FILES */}
|
||||
<div className="flex flex-col shrink-0 border-b border-border">
|
||||
<SectionHeader
|
||||
title="FILES"
|
||||
icon={FolderTree}
|
||||
badge={workspaceFiles.length}
|
||||
isOpen={filesOpen}
|
||||
onToggle={() => setFilesOpen(prev => !prev)}
|
||||
/>
|
||||
{filesOpen && (
|
||||
<div className="overflow-auto" style={{ height: heights.files }}>
|
||||
<FilesContent />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resize handle after FILES */}
|
||||
{filesOpen && agentsOpen && (
|
||||
<ResizeHandle onDrag={handleFilesResize} />
|
||||
)}
|
||||
|
||||
{/* AGENTS */}
|
||||
<div className="flex flex-col shrink-0">
|
||||
<SectionHeader
|
||||
title="AGENTS"
|
||||
icon={GitBranch}
|
||||
badge={subagents.length}
|
||||
isOpen={agentsOpen}
|
||||
onToggle={() => setAgentsOpen(prev => !prev)}
|
||||
/>
|
||||
{agentsOpen && (
|
||||
<div className="overflow-auto" style={{ height: heights.agents }}>
|
||||
<AgentsContent />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ Content Components ============
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
pending: { icon: Circle, badge: 'outline' as const, label: 'PENDING', color: 'text-muted-foreground' },
|
||||
in_progress: { icon: Clock, badge: 'info' as const, label: 'IN PROGRESS', color: 'text-status-info' },
|
||||
completed: { icon: CheckCircle2, badge: 'nominal' as const, label: 'DONE', color: 'text-status-nominal' },
|
||||
cancelled: { icon: XCircle, badge: 'critical' as const, label: 'CANCELLED', color: 'text-muted-foreground' }
|
||||
}
|
||||
|
||||
function TasksContent() {
|
||||
const { todos } = useAppStore()
|
||||
|
||||
if (todos.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center text-sm text-muted-foreground py-8 px-4">
|
||||
<ListTodo className="size-8 mb-2 opacity-50" />
|
||||
<span>No tasks yet</span>
|
||||
<span className="text-xs mt-1">Tasks appear when the agent creates them</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const inProgress = todos.filter(t => t.status === 'in_progress')
|
||||
const pending = todos.filter(t => t.status === 'pending')
|
||||
const completed = todos.filter(t => t.status === 'completed')
|
||||
const cancelled = todos.filter(t => t.status === 'cancelled')
|
||||
const allTodos = [...inProgress, ...pending, ...completed, ...cancelled]
|
||||
|
||||
const done = completed.length
|
||||
const total = todos.length
|
||||
const progress = total > 0 ? Math.round((done / total) * 100) : 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Progress bar */}
|
||||
<div className="p-3 border-b border-border/50">
|
||||
<div className="flex items-center justify-between mb-1.5 text-xs">
|
||||
<span className="text-muted-foreground">PROGRESS</span>
|
||||
<span className="font-mono">{done}/{total}</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-background overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-status-nominal transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Todo list */}
|
||||
<div className="p-3 space-y-2">
|
||||
{allTodos.map(todo => (
|
||||
<TaskItem key={todo.id} todo={todo} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskItem({ todo }: { todo: Todo }) {
|
||||
const config = STATUS_CONFIG[todo.status]
|
||||
const Icon = config.icon
|
||||
const isDone = todo.status === 'completed' || todo.status === 'cancelled'
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex items-start gap-3 rounded-sm border border-border p-3",
|
||||
isDone && "opacity-50"
|
||||
)}>
|
||||
<Icon className={cn("size-4 shrink-0 mt-0.5", config.color)} />
|
||||
<span className={cn("flex-1 text-sm", isDone && "line-through")}>
|
||||
{todo.content}
|
||||
</span>
|
||||
<Badge variant={config.badge} className="shrink-0 text-[10px]">
|
||||
{config.label}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilesContent() {
|
||||
const { workspaceFiles, workspacePath } = useAppStore()
|
||||
|
||||
if (workspaceFiles.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center text-sm text-muted-foreground py-8 px-4">
|
||||
<FolderTree className="size-8 mb-2 opacity-50" />
|
||||
<span>No workspace files</span>
|
||||
<span className="text-xs mt-1">Files appear when the agent accesses them</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{workspacePath && (
|
||||
<div className="px-3 py-2 text-[10px] text-muted-foreground truncate border-b border-border/50 bg-background/30">
|
||||
{workspacePath}
|
||||
</div>
|
||||
)}
|
||||
<div className="py-1">
|
||||
{workspaceFiles.map(file => (
|
||||
<div
|
||||
key={file.path}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-background-interactive"
|
||||
>
|
||||
<FolderTree className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="truncate flex-1">{file.path.split('/').pop()}</span>
|
||||
{file.size && (
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{formatSize(file.size)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentsContent() {
|
||||
const { subagents } = useAppStore()
|
||||
|
||||
if (subagents.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center text-sm text-muted-foreground py-8 px-4">
|
||||
<GitBranch className="size-8 mb-2 opacity-50" />
|
||||
<span>No subagent tasks</span>
|
||||
<span className="text-xs mt-1">Subagents appear when spawned</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3 space-y-2">
|
||||
{subagents.map(agent => (
|
||||
<div key={agent.id} className="p-3 rounded-sm border border-border">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<GitBranch className="size-3.5 text-status-info" />
|
||||
<span className="flex-1">{agent.name}</span>
|
||||
<span className={cn(
|
||||
"text-[10px] px-1.5 py-0.5 rounded",
|
||||
agent.status === 'pending' && "bg-muted text-muted-foreground",
|
||||
agent.status === 'running' && "bg-status-info/20 text-status-info",
|
||||
agent.status === 'completed' && "bg-status-nominal/20 text-status-nominal",
|
||||
agent.status === 'failed' && "bg-status-critical/20 text-status-critical"
|
||||
)}>
|
||||
{agent.status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
{agent.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{agent.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Bot, Clock, CheckCircle2, XCircle, Loader2 } from 'lucide-react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAppStore } from '@/lib/store'
|
||||
import type { Subagent } from '@/types'
|
||||
|
||||
export function SubagentPanel() {
|
||||
const { subagents } = useAppStore()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-section-header">SUBAGENTS</span>
|
||||
<Badge variant="outline">{subagents.length} TASKS</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="p-4 space-y-3">
|
||||
{subagents.length === 0 ? (
|
||||
<div className="text-center text-sm text-muted-foreground py-8">
|
||||
<Bot className="size-8 mx-auto mb-2 opacity-50" />
|
||||
No subagent tasks
|
||||
<div className="text-xs mt-1">
|
||||
Subagents will appear here when spawned
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
subagents.map((subagent) => (
|
||||
<SubagentCard key={subagent.id} subagent={subagent} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SubagentCard({ subagent }: { subagent: Subagent }) {
|
||||
const getStatusConfig = () => {
|
||||
switch (subagent.status) {
|
||||
case 'pending':
|
||||
return { icon: Clock, badge: 'outline' as const, label: 'PENDING' }
|
||||
case 'running':
|
||||
return { icon: Loader2, badge: 'info' as const, label: 'RUNNING' }
|
||||
case 'completed':
|
||||
return { icon: CheckCircle2, badge: 'nominal' as const, label: 'DONE' }
|
||||
case 'failed':
|
||||
return { icon: XCircle, badge: 'critical' as const, label: 'FAILED' }
|
||||
}
|
||||
}
|
||||
|
||||
const config = getStatusConfig()
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bot className="size-4 text-status-info" />
|
||||
{subagent.name}
|
||||
</CardTitle>
|
||||
<Badge variant={config.badge}>
|
||||
<Icon className={cn("size-3 mr-1", subagent.status === 'running' && "animate-spin")} />
|
||||
{config.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{subagent.description}</p>
|
||||
{subagent.startedAt && (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Started: {new Date(subagent.startedAt).toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { CheckCircle2, Circle, Clock, XCircle } from 'lucide-react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useAppStore } from '@/lib/store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Todo } from '@/types'
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
pending: {
|
||||
icon: Circle,
|
||||
badge: 'outline' as const,
|
||||
label: 'PENDING',
|
||||
color: 'text-muted-foreground'
|
||||
},
|
||||
in_progress: {
|
||||
icon: Clock,
|
||||
badge: 'info' as const,
|
||||
label: 'IN PROGRESS',
|
||||
color: 'text-status-info'
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
badge: 'nominal' as const,
|
||||
label: 'DONE',
|
||||
color: 'text-status-nominal'
|
||||
},
|
||||
cancelled: {
|
||||
icon: XCircle,
|
||||
badge: 'critical' as const,
|
||||
label: 'CANCELLED',
|
||||
color: 'text-muted-foreground'
|
||||
}
|
||||
}
|
||||
|
||||
export function TodoPanel() {
|
||||
const { todos } = useAppStore()
|
||||
|
||||
// Group todos by status
|
||||
const inProgress = todos.filter(t => t.status === 'in_progress')
|
||||
const pending = todos.filter(t => t.status === 'pending')
|
||||
const completed = todos.filter(t => t.status === 'completed')
|
||||
const cancelled = todos.filter(t => t.status === 'cancelled')
|
||||
|
||||
const allTodos = [...inProgress, ...pending, ...completed, ...cancelled]
|
||||
|
||||
// Calculate progress
|
||||
const total = todos.length
|
||||
const done = completed.length
|
||||
const progress = total > 0 ? Math.round((done / total) * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Progress Header */}
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-section-header">PROGRESS</span>
|
||||
<span className="text-data text-sm">{done}/{total}</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-background overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-status-nominal transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Todo List */}
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="p-4 space-y-2">
|
||||
{allTodos.length === 0 ? (
|
||||
<div className="text-center text-sm text-muted-foreground py-8">
|
||||
No tasks yet
|
||||
</div>
|
||||
) : (
|
||||
allTodos.map((todo) => (
|
||||
<TodoItem key={todo.id} todo={todo} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TodoItem({ todo }: { todo: Todo }) {
|
||||
const config = STATUS_CONFIG[todo.status]
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex items-start gap-3 rounded-sm border border-border p-3 transition-colors",
|
||||
todo.status === 'completed' && "opacity-60",
|
||||
todo.status === 'cancelled' && "opacity-40"
|
||||
)}>
|
||||
<Icon className={cn("size-4 shrink-0 mt-0.5", config.color)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn(
|
||||
"text-sm",
|
||||
(todo.status === 'completed' || todo.status === 'cancelled') && "line-through"
|
||||
)}>
|
||||
{todo.content}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={config.badge} className="shrink-0">
|
||||
{config.label}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Eye, EyeOff, Check, AlertCircle, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
interface SettingsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
interface ProviderConfig {
|
||||
id: string
|
||||
name: string
|
||||
envVar: string
|
||||
placeholder: string
|
||||
}
|
||||
|
||||
const PROVIDERS: ProviderConfig[] = [
|
||||
{
|
||||
id: 'anthropic',
|
||||
name: 'Anthropic',
|
||||
envVar: 'ANTHROPIC_API_KEY',
|
||||
placeholder: 'sk-ant-...'
|
||||
},
|
||||
{
|
||||
id: 'openai',
|
||||
name: 'OpenAI',
|
||||
envVar: 'OPENAI_API_KEY',
|
||||
placeholder: 'sk-...'
|
||||
},
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google AI',
|
||||
envVar: 'GOOGLE_API_KEY',
|
||||
placeholder: 'AIza...'
|
||||
}
|
||||
]
|
||||
|
||||
export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
||||
const [apiKeys, setApiKeys] = useState<Record<string, string>>({})
|
||||
const [savedKeys, setSavedKeys] = useState<Record<string, boolean>>({})
|
||||
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({})
|
||||
const [saving, setSaving] = useState<Record<string, boolean>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Load existing API keys on mount
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadApiKeys()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
async function loadApiKeys() {
|
||||
setLoading(true)
|
||||
const keys: Record<string, string> = {}
|
||||
const saved: Record<string, boolean> = {}
|
||||
|
||||
for (const provider of PROVIDERS) {
|
||||
try {
|
||||
const key = await window.api.models.getApiKey(provider.id)
|
||||
if (key) {
|
||||
// Show masked version
|
||||
keys[provider.id] = '••••••••••••••••'
|
||||
saved[provider.id] = true
|
||||
} else {
|
||||
keys[provider.id] = ''
|
||||
saved[provider.id] = false
|
||||
}
|
||||
} catch (e) {
|
||||
keys[provider.id] = ''
|
||||
saved[provider.id] = false
|
||||
}
|
||||
}
|
||||
|
||||
setApiKeys(keys)
|
||||
setSavedKeys(saved)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
async function saveApiKey(providerId: string) {
|
||||
const key = apiKeys[providerId]
|
||||
if (!key || key === '••••••••••••••••') return
|
||||
|
||||
setSaving((prev) => ({ ...prev, [providerId]: true }))
|
||||
|
||||
try {
|
||||
await window.api.models.setApiKey(providerId, key)
|
||||
setSavedKeys((prev) => ({ ...prev, [providerId]: true }))
|
||||
setApiKeys((prev) => ({ ...prev, [providerId]: '••••••••••••••••' }))
|
||||
setShowKeys((prev) => ({ ...prev, [providerId]: false }))
|
||||
} catch (e) {
|
||||
console.error('Failed to save API key:', e)
|
||||
} finally {
|
||||
setSaving((prev) => ({ ...prev, [providerId]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyChange(providerId: string, value: string) {
|
||||
// If user starts typing on a masked field, clear it
|
||||
if (apiKeys[providerId] === '••••••••••••••••' && value.length > 16) {
|
||||
value = value.slice(16)
|
||||
}
|
||||
setApiKeys((prev) => ({ ...prev, [providerId]: value }))
|
||||
setSavedKeys((prev) => ({ ...prev, [providerId]: false }))
|
||||
}
|
||||
|
||||
function toggleShowKey(providerId: string) {
|
||||
setShowKeys((prev) => ({ ...prev, [providerId]: !prev[providerId] }))
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Settings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure API keys for model providers. Keys are stored securely on your device.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-6 py-2">
|
||||
<div className="text-section-header">API KEYS</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{PROVIDERS.map((provider) => (
|
||||
<div key={provider.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">{provider.name}</label>
|
||||
{savedKeys[provider.id] ? (
|
||||
<span className="flex items-center gap-1 text-xs text-status-nominal">
|
||||
<Check className="size-3" />
|
||||
Configured
|
||||
</span>
|
||||
) : apiKeys[provider.id] ? (
|
||||
<span className="flex items-center gap-1 text-xs text-status-warning">
|
||||
<AlertCircle className="size-3" />
|
||||
Unsaved
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Not set</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showKeys[provider.id] ? 'text' : 'password'}
|
||||
value={apiKeys[provider.id] || ''}
|
||||
onChange={(e) => handleKeyChange(provider.id, e.target.value)}
|
||||
placeholder={provider.placeholder}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleShowKey(provider.id)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showKeys[provider.id] ? (
|
||||
<EyeOff className="size-4" />
|
||||
) : (
|
||||
<Eye className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
variant={savedKeys[provider.id] ? 'outline' : 'default'}
|
||||
size="sm"
|
||||
onClick={() => saveApiKey(provider.id)}
|
||||
disabled={
|
||||
saving[provider.id] ||
|
||||
!apiKeys[provider.id] ||
|
||||
apiKeys[provider.id] === '••••••••••••••••'
|
||||
}
|
||||
>
|
||||
{saving[provider.id] ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
'Save'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Environment variable: <code className="text-foreground">{provider.envVar}</code>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect } from 'react'
|
||||
import { ChevronDown, AlertCircle } from 'lucide-react'
|
||||
import { useAppStore } from '@/lib/store'
|
||||
|
||||
export function ModelSelector() {
|
||||
const { models, currentModel, loadModels, setCurrentModel } = useAppStore()
|
||||
|
||||
useEffect(() => {
|
||||
loadModels()
|
||||
}, [loadModels])
|
||||
|
||||
const selectedModel = models.find(m => m.id === currentModel)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-section-header">MODEL</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={currentModel}
|
||||
onChange={(e) => setCurrentModel(e.target.value)}
|
||||
className="w-full appearance-none rounded-sm border border-border bg-background px-3 py-2 pr-8 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{models.map((model) => (
|
||||
<option
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
disabled={!model.available}
|
||||
>
|
||||
{model.name} {!model.available && '(No API key)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
|
||||
</div>
|
||||
{selectedModel && !selectedModel.available && (
|
||||
<div className="flex items-center gap-1 text-[10px] text-status-warning">
|
||||
<AlertCircle className="size-3" />
|
||||
API key required
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, MessageSquare, Trash2, Settings, Pencil } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useAppStore } from '@/lib/store'
|
||||
import { cn, formatRelativeTime, truncate } from '@/lib/utils'
|
||||
import { ModelSelector } from './ModelSelector'
|
||||
import { SettingsDialog } from '@/components/settings/SettingsDialog'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/ui/context-menu'
|
||||
|
||||
// Get version from package.json (injected at build time or via preload)
|
||||
const APP_VERSION = '0.1.0'
|
||||
|
||||
export function ThreadSidebar() {
|
||||
const {
|
||||
threads,
|
||||
currentThreadId,
|
||||
createThread,
|
||||
selectThread,
|
||||
deleteThread,
|
||||
updateThread,
|
||||
settingsOpen,
|
||||
setSettingsOpen
|
||||
} = useAppStore()
|
||||
|
||||
const [editingThreadId, setEditingThreadId] = useState<string | null>(null)
|
||||
const [editingTitle, setEditingTitle] = useState('')
|
||||
|
||||
const startEditing = (threadId: string, currentTitle: string) => {
|
||||
setEditingThreadId(threadId)
|
||||
setEditingTitle(currentTitle || '')
|
||||
}
|
||||
|
||||
const saveTitle = async () => {
|
||||
if (editingThreadId && editingTitle.trim()) {
|
||||
await updateThread(editingThreadId, { title: editingTitle.trim() })
|
||||
}
|
||||
setEditingThreadId(null)
|
||||
setEditingTitle('')
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditingThreadId(null)
|
||||
setEditingTitle('')
|
||||
}
|
||||
|
||||
const handleNewThread = async () => {
|
||||
await createThread({ title: `Thread ${new Date().toLocaleDateString()}` })
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-[240px] flex-col border-r border-border bg-sidebar overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-section-header tracking-wider">OPENWORK</span>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
{APP_VERSION}
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon-sm" onClick={handleNewThread} title="New thread">
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Thread List */}
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="p-2 space-y-1 overflow-hidden">
|
||||
{threads.map((thread) => (
|
||||
<ContextMenu key={thread.thread_id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-center gap-2 rounded-sm px-3 py-2 cursor-pointer transition-colors overflow-hidden",
|
||||
currentThreadId === thread.thread_id
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "hover:bg-sidebar-accent/50"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (editingThreadId !== thread.thread_id) {
|
||||
selectThread(thread.thread_id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
{editingThreadId === thread.thread_id ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editingTitle}
|
||||
onChange={(e) => setEditingTitle(e.target.value)}
|
||||
onBlur={saveTitle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') saveTitle()
|
||||
if (e.key === 'Escape') cancelEditing()
|
||||
}}
|
||||
className="w-full bg-background border border-border rounded px-1 py-0.5 text-sm outline-none focus:ring-1 focus:ring-ring"
|
||||
autoFocus
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm truncate block">
|
||||
{thread.title || truncate(thread.thread_id, 20)}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{formatRelativeTime(thread.updated_at)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="opacity-0 group-hover:opacity-100 shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
deleteThread(thread.thread_id)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => startEditing(thread.thread_id, thread.title || '')}
|
||||
>
|
||||
<Pencil className="size-4 mr-2" />
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => deleteThread(thread.thread_id)}
|
||||
>
|
||||
<Trash2 className="size-4 mr-2" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
|
||||
{threads.length === 0 && (
|
||||
<div className="px-3 py-8 text-center text-sm text-muted-foreground">
|
||||
No threads yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Model Selector */}
|
||||
<div className="p-4 space-y-4">
|
||||
<ModelSelector />
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
Settings
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Settings Dialog */}
|
||||
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-sm border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider transition-colors",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||
destructive: "border-transparent bg-destructive text-white",
|
||||
outline: "border-border text-foreground",
|
||||
// Status variants with 15% bg opacity
|
||||
nominal: "border-status-nominal/30 bg-status-nominal/15 text-status-nominal",
|
||||
warning: "border-status-warning/30 bg-status-warning/15 text-status-warning",
|
||||
critical: "border-status-critical/30 bg-status-critical/15 text-status-critical",
|
||||
info: "border-status-info/30 bg-status-info/15 text-status-info",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-white hover:bg-destructive/90",
|
||||
outline: "border border-border bg-transparent hover:bg-background-interactive",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-background-interactive",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
// Status variants
|
||||
nominal: "bg-status-nominal text-background hover:bg-status-nominal/90",
|
||||
warning: "bg-status-warning text-background hover:bg-status-warning/90",
|
||||
critical: "bg-status-critical text-white hover:bg-status-critical/90",
|
||||
info: "bg-status-info text-white hover:bg-status-info/90",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 px-3 text-xs",
|
||||
lg: "h-10 px-6",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,78 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-sm border border-border bg-card text-card-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-section-header",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-4 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-4 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -0,0 +1,252 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ContextMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioGroup
|
||||
data-slot="context-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-sm border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit] [&>div]:!block">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,356 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
/* Radius scale */
|
||||
--radius-sm: calc(var(--radius) - 2px);
|
||||
--radius-md: var(--radius);
|
||||
--radius-lg: calc(var(--radius) + 1px);
|
||||
--radius-xl: calc(var(--radius) + 2px);
|
||||
--radius-2xl: calc(var(--radius) + 4px);
|
||||
|
||||
/* Foundation colors */
|
||||
--color-background: var(--background);
|
||||
--color-background-elevated: var(--background-elevated);
|
||||
--color-background-interactive: var(--background-interactive);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
/* Component colors */
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
|
||||
/* Border colors */
|
||||
--color-border: var(--border);
|
||||
--color-border-emphasis: var(--border-emphasis);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
/* Status colors */
|
||||
--color-status-critical: var(--status-critical);
|
||||
--color-status-warning: var(--status-warning);
|
||||
--color-status-nominal: var(--status-nominal);
|
||||
--color-status-info: var(--status-info);
|
||||
|
||||
/* Chart colors - using status palette */
|
||||
--color-chart-1: var(--status-critical);
|
||||
--color-chart-2: var(--status-warning);
|
||||
--color-chart-3: var(--status-nominal);
|
||||
--color-chart-4: var(--status-info);
|
||||
--color-chart-5: var(--accent);
|
||||
|
||||
/* Sidebar colors */
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Foundation */
|
||||
--radius: 3px;
|
||||
--background: #0D0D0F;
|
||||
--background-elevated: #141418;
|
||||
--background-interactive: #1C1C22;
|
||||
--foreground: #E8E8EC;
|
||||
|
||||
/* Borders */
|
||||
--border: #2A2A32;
|
||||
--border-emphasis: #3A3A45;
|
||||
--input: #2A2A32;
|
||||
--ring: #3B82F6;
|
||||
|
||||
/* Text hierarchy */
|
||||
--muted: #141418;
|
||||
--muted-foreground: #8A8A96;
|
||||
--tertiary-foreground: #5A5A66;
|
||||
|
||||
/* Semantic mapping for shadcn compatibility */
|
||||
--card: #141418;
|
||||
--card-foreground: #E8E8EC;
|
||||
--popover: #141418;
|
||||
--popover-foreground: #E8E8EC;
|
||||
--primary: #3B82F6;
|
||||
--primary-foreground: #E8E8EC;
|
||||
--secondary: #1C1C22;
|
||||
--secondary-foreground: #E8E8EC;
|
||||
--accent: #FB923C;
|
||||
--accent-foreground: #0D0D0F;
|
||||
--destructive: #E53E3E;
|
||||
|
||||
/* Status colors */
|
||||
--status-critical: #E53E3E;
|
||||
--status-warning: #F59E0B;
|
||||
--status-nominal: #22C55E;
|
||||
--status-info: #3B82F6;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: #141418;
|
||||
--sidebar-foreground: #E8E8EC;
|
||||
--sidebar-primary: #3B82F6;
|
||||
--sidebar-primary-foreground: #E8E8EC;
|
||||
--sidebar-accent: #1C1C22;
|
||||
--sidebar-accent-foreground: #E8E8EC;
|
||||
--sidebar-border: #2A2A32;
|
||||
--sidebar-ring: #3B82F6;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', ui-monospace, monospace;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tactical typography utilities */
|
||||
.text-section-header {
|
||||
@apply text-[11px] font-semibold uppercase tracking-[0.1em] text-muted-foreground;
|
||||
}
|
||||
|
||||
.text-data {
|
||||
@apply font-mono tabular-nums;
|
||||
}
|
||||
|
||||
.text-hero-metric {
|
||||
@apply text-5xl font-light tracking-tight tabular-nums;
|
||||
}
|
||||
|
||||
/* Status color utilities */
|
||||
.text-critical {
|
||||
color: var(--status-critical);
|
||||
}
|
||||
.text-warning {
|
||||
color: var(--status-warning);
|
||||
}
|
||||
.text-nominal {
|
||||
color: var(--status-nominal);
|
||||
}
|
||||
.text-info {
|
||||
color: var(--status-info);
|
||||
}
|
||||
|
||||
.bg-critical {
|
||||
background-color: var(--status-critical);
|
||||
}
|
||||
.bg-warning {
|
||||
background-color: var(--status-warning);
|
||||
}
|
||||
.bg-nominal {
|
||||
background-color: var(--status-nominal);
|
||||
}
|
||||
.bg-info {
|
||||
background-color: var(--status-info);
|
||||
}
|
||||
|
||||
.bg-critical\/15 {
|
||||
background-color: color-mix(in srgb, var(--status-critical) 15%, transparent);
|
||||
}
|
||||
.bg-warning\/15 {
|
||||
background-color: color-mix(in srgb, var(--status-warning) 15%, transparent);
|
||||
}
|
||||
.bg-nominal\/15 {
|
||||
background-color: color-mix(in srgb, var(--status-nominal) 15%, transparent);
|
||||
}
|
||||
.bg-info\/15 {
|
||||
background-color: color-mix(in srgb, var(--status-info) 15%, transparent);
|
||||
}
|
||||
|
||||
.border-critical {
|
||||
border-color: var(--status-critical);
|
||||
}
|
||||
.border-warning {
|
||||
border-color: var(--status-warning);
|
||||
}
|
||||
.border-nominal {
|
||||
border-color: var(--status-nominal);
|
||||
}
|
||||
.border-info {
|
||||
border-color: var(--status-info);
|
||||
}
|
||||
|
||||
/* Pulse animation for live indicators */
|
||||
@keyframes tactical-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-tactical-pulse {
|
||||
animation: tactical-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-emphasis);
|
||||
}
|
||||
|
||||
/* Hide scrollbar but allow scrolling */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Streaming markdown styles */
|
||||
.streaming-markdown {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.streaming-markdown p {
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.streaming-markdown p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.streaming-markdown h1,
|
||||
.streaming-markdown h2,
|
||||
.streaming-markdown h3,
|
||||
.streaming-markdown h4,
|
||||
.streaming-markdown h5,
|
||||
.streaming-markdown h6 {
|
||||
font-weight: 600;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.streaming-markdown h1 { font-size: 1.5em; }
|
||||
.streaming-markdown h2 { font-size: 1.25em; }
|
||||
.streaming-markdown h3 { font-size: 1.125em; }
|
||||
.streaming-markdown h4,
|
||||
.streaming-markdown h5,
|
||||
.streaming-markdown h6 { font-size: 1em; }
|
||||
|
||||
.streaming-markdown ul,
|
||||
.streaming-markdown ol {
|
||||
margin-left: 1.5em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.streaming-markdown ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.streaming-markdown ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.streaming-markdown li {
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.streaming-markdown code {
|
||||
background-color: var(--background);
|
||||
padding: 0.125em 0.25em;
|
||||
border-radius: 3px;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.streaming-markdown pre {
|
||||
background-color: var(--background);
|
||||
padding: 0.75em 1em;
|
||||
border-radius: 3px;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.streaming-markdown pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.streaming-markdown blockquote {
|
||||
border-left: 3px solid var(--border-emphasis);
|
||||
padding-left: 1em;
|
||||
margin-left: 0;
|
||||
margin-bottom: 0.75em;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.streaming-markdown a {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.streaming-markdown a:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.streaming-markdown table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.streaming-markdown th,
|
||||
.streaming-markdown td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.5em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.streaming-markdown th {
|
||||
background-color: var(--background);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.streaming-markdown hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.streaming-markdown img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Window drag region for frameless window */
|
||||
.app-drag-region {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
.app-drag-region button,
|
||||
.app-drag-region input,
|
||||
.app-drag-region select,
|
||||
.app-drag-region a {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
import { create } from 'zustand'
|
||||
import type { Thread, Message, Todo, ModelConfig, HITLRequest, FileInfo, Subagent } from '@/types'
|
||||
|
||||
interface AppState {
|
||||
// Threads
|
||||
threads: Thread[]
|
||||
currentThreadId: string | null
|
||||
|
||||
// Messages for current thread
|
||||
messages: Message[]
|
||||
|
||||
// Streaming state - per-thread to allow concurrent runs
|
||||
streamingThreads: Set<string>
|
||||
streamingContent: Record<string, string> // threadId -> content
|
||||
|
||||
// HITL state
|
||||
pendingApproval: HITLRequest | null
|
||||
|
||||
// Todos (from agent)
|
||||
todos: Todo[]
|
||||
|
||||
// Workspace files (from agent)
|
||||
workspaceFiles: FileInfo[]
|
||||
workspacePath: string | null
|
||||
|
||||
// Subagents (from agent)
|
||||
subagents: Subagent[]
|
||||
|
||||
// Models
|
||||
models: ModelConfig[]
|
||||
currentModel: string
|
||||
|
||||
// Right panel state
|
||||
rightPanelTab: 'todos' | 'files' | 'subagents'
|
||||
|
||||
// Settings dialog state
|
||||
settingsOpen: boolean
|
||||
|
||||
// Sidebar state
|
||||
sidebarCollapsed: boolean
|
||||
|
||||
// Actions
|
||||
loadThreads: () => Promise<void>
|
||||
createThread: (metadata?: Record<string, unknown>) => Promise<Thread>
|
||||
selectThread: (threadId: string) => Promise<void>
|
||||
deleteThread: (threadId: string) => Promise<void>
|
||||
updateThread: (threadId: string, updates: Partial<Thread>) => Promise<void>
|
||||
|
||||
// Message actions
|
||||
sendMessage: (content: string) => Promise<void>
|
||||
appendMessage: (message: Message) => void
|
||||
setMessages: (messages: Message[]) => void
|
||||
|
||||
// Streaming actions
|
||||
isThreadStreaming: (threadId: string) => boolean
|
||||
getStreamingContent: (threadId: string) => string
|
||||
setThreadStreaming: (threadId: string, streaming: boolean) => void
|
||||
appendStreamingContent: (threadId: string, content: string) => void
|
||||
clearStreamingContent: (threadId: string) => void
|
||||
|
||||
// HITL actions
|
||||
setPendingApproval: (request: HITLRequest | null) => void
|
||||
respondToApproval: (decision: 'approve' | 'reject' | 'edit', editedArgs?: Record<string, unknown>) => Promise<void>
|
||||
|
||||
// Todo actions
|
||||
setTodos: (todos: Todo[]) => void
|
||||
|
||||
// Workspace actions
|
||||
setWorkspaceFiles: (files: FileInfo[]) => void
|
||||
setWorkspacePath: (path: string | null) => void
|
||||
|
||||
// Subagent actions
|
||||
setSubagents: (subagents: Subagent[]) => void
|
||||
|
||||
// Model actions
|
||||
loadModels: () => Promise<void>
|
||||
setCurrentModel: (modelId: string) => Promise<void>
|
||||
|
||||
// Panel actions
|
||||
setRightPanelTab: (tab: 'todos' | 'files' | 'subagents') => void
|
||||
|
||||
// Settings actions
|
||||
setSettingsOpen: (open: boolean) => void
|
||||
|
||||
// Sidebar actions
|
||||
toggleSidebar: () => void
|
||||
setSidebarCollapsed: (collapsed: boolean) => void
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set, get) => ({
|
||||
// Initial state
|
||||
threads: [],
|
||||
currentThreadId: null,
|
||||
messages: [],
|
||||
streamingThreads: new Set<string>(),
|
||||
streamingContent: {},
|
||||
pendingApproval: null,
|
||||
todos: [],
|
||||
workspaceFiles: [],
|
||||
workspacePath: null,
|
||||
subagents: [],
|
||||
models: [],
|
||||
currentModel: 'claude-sonnet-4-20250514',
|
||||
rightPanelTab: 'todos',
|
||||
settingsOpen: false,
|
||||
sidebarCollapsed: false,
|
||||
|
||||
// Thread actions
|
||||
loadThreads: async () => {
|
||||
const threads = await window.api.threads.list()
|
||||
set({ threads })
|
||||
|
||||
// Select first thread if none selected
|
||||
if (!get().currentThreadId && threads.length > 0) {
|
||||
await get().selectThread(threads[0].thread_id)
|
||||
}
|
||||
},
|
||||
|
||||
createThread: async (metadata?: Record<string, unknown>) => {
|
||||
const thread = await window.api.threads.create(metadata)
|
||||
set(state => ({
|
||||
threads: [thread, ...state.threads],
|
||||
currentThreadId: thread.thread_id,
|
||||
messages: []
|
||||
}))
|
||||
return thread
|
||||
},
|
||||
|
||||
selectThread: async (threadId: string) => {
|
||||
set({ currentThreadId: threadId, messages: [], todos: [], workspaceFiles: [], workspacePath: null, subagents: [] })
|
||||
|
||||
// Load thread history from checkpoints
|
||||
try {
|
||||
const history = await window.api.threads.getHistory(threadId)
|
||||
|
||||
// Get the most recent checkpoint (first in the list since it's ordered DESC)
|
||||
if (history.length > 0) {
|
||||
const latestCheckpoint = history[0] as {
|
||||
checkpoint?: {
|
||||
channel_values?: {
|
||||
messages?: Array<{
|
||||
id?: string
|
||||
_getType?: () => string
|
||||
type?: string
|
||||
content?: string | unknown[]
|
||||
tool_calls?: unknown[]
|
||||
}>
|
||||
todos?: Array<{
|
||||
id?: string
|
||||
content?: string
|
||||
status?: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const channelValues = latestCheckpoint.checkpoint?.channel_values
|
||||
|
||||
// Extract messages
|
||||
if (channelValues?.messages && Array.isArray(channelValues.messages)) {
|
||||
const messages: Message[] = channelValues.messages.map((msg, index) => {
|
||||
// Determine role from message type
|
||||
let role: 'user' | 'assistant' | 'system' | 'tool' = 'assistant'
|
||||
if (typeof msg._getType === 'function') {
|
||||
const type = msg._getType()
|
||||
if (type === 'human') role = 'user'
|
||||
else if (type === 'ai') role = 'assistant'
|
||||
else if (type === 'system') role = 'system'
|
||||
else if (type === 'tool') role = 'tool'
|
||||
} else if (msg.type) {
|
||||
if (msg.type === 'human') role = 'user'
|
||||
else if (msg.type === 'ai') role = 'assistant'
|
||||
else if (msg.type === 'system') role = 'system'
|
||||
else if (msg.type === 'tool') role = 'tool'
|
||||
}
|
||||
|
||||
// Handle content - could be string or array of content blocks
|
||||
let content: Message['content'] = ''
|
||||
if (typeof msg.content === 'string') {
|
||||
content = msg.content
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
content = msg.content as Message['content']
|
||||
}
|
||||
|
||||
return {
|
||||
id: msg.id || `msg-${index}`,
|
||||
role,
|
||||
content,
|
||||
tool_calls: msg.tool_calls as Message['tool_calls'],
|
||||
created_at: new Date()
|
||||
}
|
||||
})
|
||||
|
||||
set({ messages })
|
||||
}
|
||||
|
||||
// Extract todos if present
|
||||
if (channelValues?.todos && Array.isArray(channelValues.todos)) {
|
||||
const todos: Todo[] = channelValues.todos.map((todo, index) => ({
|
||||
id: todo.id || `todo-${index}`,
|
||||
content: todo.content || '',
|
||||
status: (todo.status as Todo['status']) || 'pending'
|
||||
}))
|
||||
|
||||
set({ todos })
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load thread history:', error)
|
||||
}
|
||||
},
|
||||
|
||||
deleteThread: async (threadId: string) => {
|
||||
console.log('[Store] Deleting thread:', threadId)
|
||||
try {
|
||||
await window.api.threads.delete(threadId)
|
||||
console.log('[Store] Thread deleted from backend')
|
||||
|
||||
set(state => {
|
||||
const threads = state.threads.filter(t => t.thread_id !== threadId)
|
||||
const wasCurrentThread = state.currentThreadId === threadId
|
||||
const newCurrentId = wasCurrentThread
|
||||
? threads[0]?.thread_id || null
|
||||
: state.currentThreadId
|
||||
|
||||
console.log('[Store] Updating state:', {
|
||||
remainingThreads: threads.length,
|
||||
wasCurrentThread,
|
||||
newCurrentId
|
||||
})
|
||||
|
||||
return {
|
||||
threads,
|
||||
currentThreadId: newCurrentId,
|
||||
// Clear messages if we deleted the current thread
|
||||
messages: wasCurrentThread ? [] : state.messages,
|
||||
// Clear other state if we deleted the current thread
|
||||
todos: wasCurrentThread ? [] : state.todos,
|
||||
workspaceFiles: wasCurrentThread ? [] : state.workspaceFiles,
|
||||
workspacePath: wasCurrentThread ? null : state.workspacePath,
|
||||
subagents: wasCurrentThread ? [] : state.subagents
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Store] Failed to delete thread:', error)
|
||||
}
|
||||
},
|
||||
|
||||
updateThread: async (threadId: string, updates: Partial<Thread>) => {
|
||||
const updated = await window.api.threads.update(threadId, updates)
|
||||
set(state => ({
|
||||
threads: state.threads.map(t => t.thread_id === threadId ? updated : t)
|
||||
}))
|
||||
},
|
||||
|
||||
// Message actions
|
||||
sendMessage: async (content: string) => {
|
||||
const { currentThreadId } = get()
|
||||
console.log('[Store] sendMessage called', { currentThreadId, content: content.substring(0, 50) })
|
||||
|
||||
if (!currentThreadId) {
|
||||
console.error('[Store] No currentThreadId!')
|
||||
return
|
||||
}
|
||||
|
||||
const threadId = currentThreadId
|
||||
|
||||
// Add user message immediately
|
||||
const userMessage: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content,
|
||||
created_at: new Date()
|
||||
}
|
||||
|
||||
const isFirstMessage = get().messages.length === 0
|
||||
|
||||
set(state => ({
|
||||
messages: [...state.messages, userMessage]
|
||||
}))
|
||||
|
||||
// Auto-generate title on first message
|
||||
if (isFirstMessage) {
|
||||
try {
|
||||
const generatedTitle = await window.api.threads.generateTitle(content)
|
||||
await get().updateThread(threadId, { title: generatedTitle })
|
||||
} catch (error) {
|
||||
console.error('[Store] Failed to generate title:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Set this thread as streaming
|
||||
get().setThreadStreaming(threadId, true)
|
||||
get().clearStreamingContent(threadId)
|
||||
|
||||
// Stream agent response using callback pattern
|
||||
try {
|
||||
console.log('[Store] Checking window.api:', !!window.api, !!window.api?.agent)
|
||||
console.log('[Store] About to call window.api.agent.invoke')
|
||||
|
||||
// The cleanup function is returned but auto-removes on done/error events
|
||||
window.api.agent.invoke(threadId, content, (event) => {
|
||||
console.log('[Store] Received event:', event.type)
|
||||
switch (event.type) {
|
||||
case 'message':
|
||||
// Only update if this is still the current thread
|
||||
if (get().currentThreadId === threadId) {
|
||||
get().appendMessage(event.message)
|
||||
}
|
||||
break
|
||||
case 'token':
|
||||
get().appendStreamingContent(threadId, event.token)
|
||||
break
|
||||
case 'interrupt':
|
||||
set({ pendingApproval: event.request })
|
||||
break
|
||||
case 'tool_call':
|
||||
// Could show tool call in progress
|
||||
break
|
||||
case 'todos':
|
||||
// Only update if this is still the current thread
|
||||
if (get().currentThreadId === threadId) {
|
||||
get().setTodos(event.todos)
|
||||
}
|
||||
break
|
||||
case 'workspace':
|
||||
console.log('[Store] Received workspace event:', {
|
||||
files: event.files.length,
|
||||
path: event.path,
|
||||
isCurrentThread: get().currentThreadId === threadId
|
||||
})
|
||||
// Only update if this is still the current thread
|
||||
if (get().currentThreadId === threadId) {
|
||||
get().setWorkspaceFiles(event.files)
|
||||
get().setWorkspacePath(event.path)
|
||||
}
|
||||
break
|
||||
case 'subagents':
|
||||
console.log('[Store] Received subagents event:', {
|
||||
count: event.subagents.length,
|
||||
isCurrentThread: get().currentThreadId === threadId
|
||||
})
|
||||
// Only update if this is still the current thread
|
||||
if (get().currentThreadId === threadId) {
|
||||
get().setSubagents(event.subagents)
|
||||
}
|
||||
break
|
||||
case 'done':
|
||||
get().setThreadStreaming(threadId, false)
|
||||
get().clearStreamingContent(threadId)
|
||||
break
|
||||
case 'error':
|
||||
console.error('[Store] Stream error:', event.error)
|
||||
get().setThreadStreaming(threadId, false)
|
||||
get().clearStreamingContent(threadId)
|
||||
break
|
||||
}
|
||||
})
|
||||
console.log('[Store] invoke() called')
|
||||
} catch (error) {
|
||||
console.error('[Store] Failed to send message:', error)
|
||||
get().setThreadStreaming(threadId, false)
|
||||
}
|
||||
},
|
||||
|
||||
appendMessage: (message: Message) => {
|
||||
set(state => {
|
||||
// Check if message already exists (by id)
|
||||
const exists = state.messages.some(m => m.id === message.id)
|
||||
if (exists) {
|
||||
return { messages: state.messages.map(m => m.id === message.id ? message : m) }
|
||||
}
|
||||
return { messages: [...state.messages, message] }
|
||||
})
|
||||
},
|
||||
|
||||
setMessages: (messages: Message[]) => {
|
||||
set({ messages })
|
||||
},
|
||||
|
||||
// Streaming actions
|
||||
isThreadStreaming: (threadId: string) => {
|
||||
return get().streamingThreads.has(threadId)
|
||||
},
|
||||
|
||||
getStreamingContent: (threadId: string) => {
|
||||
return get().streamingContent[threadId] || ''
|
||||
},
|
||||
|
||||
setThreadStreaming: (threadId: string, streaming: boolean) => {
|
||||
set(state => {
|
||||
const newSet = new Set(state.streamingThreads)
|
||||
if (streaming) {
|
||||
newSet.add(threadId)
|
||||
} else {
|
||||
newSet.delete(threadId)
|
||||
}
|
||||
return { streamingThreads: newSet }
|
||||
})
|
||||
},
|
||||
|
||||
appendStreamingContent: (threadId: string, content: string) => {
|
||||
set(state => ({
|
||||
streamingContent: {
|
||||
...state.streamingContent,
|
||||
[threadId]: (state.streamingContent[threadId] || '') + content
|
||||
}
|
||||
}))
|
||||
},
|
||||
|
||||
clearStreamingContent: (threadId: string) => {
|
||||
set(state => {
|
||||
const newContent = { ...state.streamingContent }
|
||||
delete newContent[threadId]
|
||||
return { streamingContent: newContent }
|
||||
})
|
||||
},
|
||||
|
||||
// HITL actions
|
||||
setPendingApproval: (request: HITLRequest | null) => {
|
||||
set({ pendingApproval: request })
|
||||
},
|
||||
|
||||
respondToApproval: async (decision: 'approve' | 'reject' | 'edit', editedArgs?: Record<string, unknown>) => {
|
||||
const { currentThreadId, pendingApproval } = get()
|
||||
if (!currentThreadId || !pendingApproval) return
|
||||
|
||||
await window.api.agent.interrupt(currentThreadId, {
|
||||
type: decision,
|
||||
tool_call_id: pendingApproval.tool_call.id,
|
||||
edited_args: editedArgs
|
||||
})
|
||||
|
||||
set({ pendingApproval: null })
|
||||
},
|
||||
|
||||
// Todo actions
|
||||
setTodos: (todos: Todo[]) => {
|
||||
set({ todos })
|
||||
},
|
||||
|
||||
// Workspace actions
|
||||
setWorkspaceFiles: (files: FileInfo[]) => {
|
||||
set({ workspaceFiles: files })
|
||||
},
|
||||
|
||||
setWorkspacePath: (path: string | null) => {
|
||||
set({ workspacePath: path })
|
||||
},
|
||||
|
||||
// Subagent actions
|
||||
setSubagents: (subagents: Subagent[]) => {
|
||||
set({ subagents })
|
||||
},
|
||||
|
||||
// Model actions
|
||||
loadModels: async () => {
|
||||
const models = await window.api.models.list()
|
||||
const currentModel = await window.api.models.getDefault()
|
||||
set({ models, currentModel })
|
||||
},
|
||||
|
||||
setCurrentModel: async (modelId: string) => {
|
||||
await window.api.models.setDefault(modelId)
|
||||
set({ currentModel: modelId })
|
||||
},
|
||||
|
||||
// Panel actions
|
||||
setRightPanelTab: (tab: 'todos' | 'files' | 'subagents') => {
|
||||
set({ rightPanelTab: tab })
|
||||
},
|
||||
|
||||
// Settings actions
|
||||
setSettingsOpen: (open: boolean) => {
|
||||
set({ settingsOpen: open })
|
||||
},
|
||||
|
||||
// Sidebar actions
|
||||
toggleSidebar: () => {
|
||||
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed }))
|
||||
},
|
||||
|
||||
setSidebarCollapsed: (collapsed: boolean) => {
|
||||
set({ sidebarCollapsed: collapsed })
|
||||
}
|
||||
}))
|
||||
@@ -0,0 +1,43 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
return d.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
export function formatRelativeTime(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (seconds < 60) return 'just now'
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
if (days < 7) return `${days}d ago`
|
||||
|
||||
return formatDate(d)
|
||||
}
|
||||
|
||||
export function truncate(str: string, length: number): string {
|
||||
if (str.length <= length) return str
|
||||
return str.slice(0, length) + '...'
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,116 @@
|
||||
// Re-export types from electron for use in renderer
|
||||
export type ThreadStatus = 'idle' | 'busy' | 'interrupted' | 'error'
|
||||
|
||||
export interface Thread {
|
||||
thread_id: string
|
||||
created_at: Date
|
||||
updated_at: Date
|
||||
metadata?: Record<string, unknown>
|
||||
status: ThreadStatus
|
||||
thread_values?: Record<string, unknown>
|
||||
title?: string
|
||||
}
|
||||
|
||||
export type RunStatus = 'pending' | 'running' | 'error' | 'success' | 'interrupted'
|
||||
|
||||
export interface Run {
|
||||
run_id: string
|
||||
thread_id: string
|
||||
assistant_id?: string
|
||||
created_at: Date
|
||||
updated_at: Date
|
||||
status: RunStatus
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ModelConfig {
|
||||
id: string
|
||||
name: string
|
||||
provider: 'anthropic' | 'openai' | 'google' | 'ollama'
|
||||
model: string
|
||||
description?: string
|
||||
available: boolean
|
||||
}
|
||||
|
||||
// Subagent types (from deepagentsjs)
|
||||
export interface Subagent {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
status: 'pending' | 'running' | 'completed' | 'failed'
|
||||
startedAt?: Date
|
||||
completedAt?: Date
|
||||
}
|
||||
|
||||
export type StreamEvent =
|
||||
| { type: 'message'; message: Message }
|
||||
| { type: 'tool_call'; toolCall: ToolCall }
|
||||
| { type: 'tool_result'; toolResult: ToolResult }
|
||||
| { type: 'interrupt'; request: HITLRequest }
|
||||
| { type: 'token'; token: string }
|
||||
| { type: 'todos'; todos: Todo[] }
|
||||
| { type: 'workspace'; files: FileInfo[]; path: string }
|
||||
| { type: 'subagents'; subagents: Subagent[] }
|
||||
| { type: 'done'; result: unknown }
|
||||
| { type: 'error'; error: string }
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system' | 'tool'
|
||||
content: string | ContentBlock[]
|
||||
tool_calls?: ToolCall[]
|
||||
created_at: Date
|
||||
}
|
||||
|
||||
export interface ContentBlock {
|
||||
type: 'text' | 'image' | 'tool_use' | 'tool_result'
|
||||
text?: string
|
||||
tool_use_id?: string
|
||||
name?: string
|
||||
input?: unknown
|
||||
content?: string
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string
|
||||
name: string
|
||||
args: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
tool_call_id: string
|
||||
content: string | unknown
|
||||
is_error?: boolean
|
||||
}
|
||||
|
||||
export interface HITLRequest {
|
||||
id: string
|
||||
tool_call: ToolCall
|
||||
allowed_decisions: HITLDecision['type'][]
|
||||
}
|
||||
|
||||
export interface HITLDecision {
|
||||
type: 'approve' | 'reject' | 'edit'
|
||||
tool_call_id: string
|
||||
edited_args?: Record<string, unknown>
|
||||
feedback?: string
|
||||
}
|
||||
|
||||
export interface Todo {
|
||||
id: string
|
||||
content: string
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'cancelled'
|
||||
}
|
||||
|
||||
export interface FileInfo {
|
||||
path: string
|
||||
is_dir?: boolean
|
||||
size?: number
|
||||
modified_at?: string
|
||||
}
|
||||
|
||||
export interface GrepMatch {
|
||||
path: string
|
||||
line: number
|
||||
text: string
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.web.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||
"include": [
|
||||
"electron.vite.config.*",
|
||||
"src/main/**/*",
|
||||
"src/preload/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": [
|
||||
"electron-vite/node"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||
"include": [
|
||||
"src/renderer/src/env.d.ts",
|
||||
"src/renderer/src/**/*",
|
||||
"src/renderer/src/**/*.tsx",
|
||||
"src/preload/*.d.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/renderer/src/*"
|
||||
],
|
||||
"@renderer/*": [
|
||||
"src/renderer/src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user