working commit

This commit is contained in:
Hunter Lovell
2026-01-13 12:34:30 -08:00
commit 65c424b2a2
62 changed files with 25142 additions and 0 deletions
+9
View File
@@ -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
+49
View File
@@ -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
+106
View File
@@ -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 }}
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
out
.DS_Store
.eslintcache
*.log*
+2
View File
@@ -0,0 +1,2 @@
node-linker=hoisted
shamefully-hoist=true
+6
View File
@@ -0,0 +1,6 @@
out
dist
pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json
+4
View File
@@ -0,0 +1,4 @@
singleQuote: true
semi: false
printWidth: 100
trailingComma: none
+137
View File
@@ -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.
+21
View File
@@ -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.
+135
View File
@@ -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.
![openwork screenshot](docs/screenshot.png)
## 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
View File
@@ -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);
}
}
+44
View File
@@ -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
+18
View File
@@ -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()]
}
})
+32
View File
@@ -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
);
+25
View File
@@ -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
+10800
View File
File diff suppressed because it is too large Load Diff
+81
View File
@@ -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"
}
}
+8007
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
onlyBuiltDependencies:
- better-sqlite3
- electron
- electron-winstaller
- esbuild
+1
View File
@@ -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

+85
View File
@@ -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
}
}
+436
View File
@@ -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
}
}
}
+251
View File
@@ -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()
}
+90
View File
@@ -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()
}
})
+245
View File
@@ -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)
}
})
}
+144
View File
@@ -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
}
+130
View File
@@ -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)
})
}
+51
View File
@@ -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
}
+122
View File
@@ -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
}
+45
View File
@@ -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
}
}
+117
View File
@@ -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
}
+15
View File
@@ -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>
+79
View File
@@ -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>
)
}
+37
View File
@@ -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 }
+58
View File
@@ -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 }
+78
View File
@@ -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,
}
+110
View File
@@ -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
}
+24
View File
@@ -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 }
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+356
View File
@@ -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;
}
+486
View File
@@ -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 })
}
}))
+43
View File
@@ -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()
}
+10
View File
@@ -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>
)
+116
View File
@@ -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
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.web.json" }
]
}
+14
View File
@@ -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"
]
}
}
+22
View File
@@ -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/*"
]
}
}
}