mirror of
https://github.com/langchain-ai/openwork.git
synced 2026-07-01 20:37:03 -04:00
lints and formats
This commit is contained in:
@@ -3,7 +3,7 @@ name: Bug Report
|
||||
about: Report a bug or unexpected behavior
|
||||
title: "[Bug]: "
|
||||
labels: bug
|
||||
assignees: ''
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -3,7 +3,7 @@ name: Feature Request
|
||||
about: Suggest a new feature or improvement
|
||||
title: "[Feature]: "
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
@@ -17,8 +17,8 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
@@ -41,8 +41,8 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
singleQuote: true
|
||||
singleQuote: false
|
||||
semi: false
|
||||
printWidth: 100
|
||||
trailingComma: none
|
||||
|
||||
+21
-18
@@ -13,12 +13,14 @@ Thank you for your interest in contributing to openwork! This document provides
|
||||
### 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
|
||||
```
|
||||
@@ -86,15 +88,15 @@ 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` |
|
||||
| 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
|
||||
|
||||
@@ -145,20 +147,21 @@ Use conventional commits:
|
||||
|
||||
We use labels to organize issues:
|
||||
|
||||
| Label | Description |
|
||||
|-------|-------------|
|
||||
| `bug` | Something isn't working |
|
||||
| `enhancement` | New feature or improvement |
|
||||
| `good first issue` | Good for newcomers |
|
||||
| `help wanted` | Extra attention needed |
|
||||
| `documentation` | Documentation improvements |
|
||||
| `question` | Further information requested |
|
||||
| `wontfix` | This will not be worked on |
|
||||
| Label | Description |
|
||||
| ------------------ | ----------------------------- |
|
||||
| `bug` | Something isn't working |
|
||||
| `enhancement` | New feature or improvement |
|
||||
| `good first issue` | Good for newcomers |
|
||||
| `help wanted` | Extra attention needed |
|
||||
| `documentation` | Documentation improvements |
|
||||
| `question` | Further information requested |
|
||||
| `wontfix` | This will not be worked on |
|
||||
|
||||
## Questions?
|
||||
|
||||
Open an issue or start a discussion on GitHub.
|
||||
changes
|
||||
|
||||
- `chore:` Build/tooling changes
|
||||
|
||||
## Questions?
|
||||
|
||||
@@ -35,15 +35,16 @@ cd openwork
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Or configure them in-app via the settings panel.
|
||||
|
||||
## Supported Models
|
||||
|
||||
| Provider | Models |
|
||||
| --------- | ----------------------------------------------------------------- |
|
||||
| Provider | Models |
|
||||
| --------- | -------------------------------------------------------------------------------------- |
|
||||
| Anthropic | Claude Opus 4.5, Claude Sonnet 4.5, Claude Haiku 4.5, Claude Opus 4.1, Claude Sonnet 4 |
|
||||
| OpenAI | GPT-5.2, GPT-5.1, o3, o3 Mini, o4 Mini, o1, GPT-4.1, GPT-4o |
|
||||
| Google | Gemini 3 Pro Preview, Gemini 2.5 Pro, Gemini 2.5 Flash, Gemini 2.5 Flash Lite |
|
||||
| OpenAI | GPT-5.2, GPT-5.1, o3, o3 Mini, o4 Mini, o1, GPT-4.1, GPT-4o |
|
||||
| Google | Gemini 3 Pro Preview, Gemini 2.5 Pro, Gemini 2.5 Flash, Gemini 2.5 Flash Lite |
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
+14
-14
@@ -4,23 +4,23 @@
|
||||
* openwork CLI - Launches the Electron app
|
||||
*/
|
||||
|
||||
const { spawn } = require('child_process')
|
||||
const path = require('path')
|
||||
const { spawn } = require("child_process")
|
||||
const path = require("path")
|
||||
|
||||
// Set process title for Activity Monitor
|
||||
process.title = 'openwork'
|
||||
process.title = "openwork"
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
|
||||
// Handle --version flag
|
||||
if (args.includes('--version') || args.includes('-v')) {
|
||||
const { version } = require('../package.json')
|
||||
if (args.includes("--version") || args.includes("-v")) {
|
||||
const { version } = require("../package.json")
|
||||
console.log(`openwork v${version}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// Handle --help flag
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
if (args.includes("--help") || args.includes("-h")) {
|
||||
console.log(`
|
||||
openwork - A tactical agent interface for deepagentsjs
|
||||
|
||||
@@ -33,13 +33,13 @@ Usage:
|
||||
}
|
||||
|
||||
// Get the path to electron
|
||||
const electron = require('electron')
|
||||
const electron = require("electron")
|
||||
|
||||
// Launch electron with our main process
|
||||
const mainPath = path.join(__dirname, '..', 'out', 'main', 'index.js')
|
||||
const mainPath = path.join(__dirname, "..", "out", "main", "index.js")
|
||||
|
||||
const child = spawn(electron, [mainPath, ...args], {
|
||||
stdio: 'inherit'
|
||||
stdio: "inherit"
|
||||
})
|
||||
|
||||
// Forward signals to child process
|
||||
@@ -50,15 +50,15 @@ function forwardSignal(signal) {
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => forwardSignal('SIGINT'))
|
||||
process.on('SIGTERM', () => forwardSignal('SIGTERM'))
|
||||
process.on("SIGINT", () => forwardSignal("SIGINT"))
|
||||
process.on("SIGTERM", () => forwardSignal("SIGTERM"))
|
||||
|
||||
// Exit with the same code as the child
|
||||
child.on('close', (code) => {
|
||||
child.on("close", (code) => {
|
||||
process.exit(code ?? 0)
|
||||
})
|
||||
|
||||
child.on('error', (err) => {
|
||||
console.error('Failed to start openwork:', err.message)
|
||||
child.on("error", (err) => {
|
||||
console.error("Failed to start openwork:", err.message)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
+15
-15
@@ -1,19 +1,19 @@
|
||||
import { resolve } from 'path'
|
||||
import { readFileSync, copyFileSync, existsSync, mkdirSync } from 'fs'
|
||||
import { defineConfig } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { resolve } from "path"
|
||||
import { readFileSync, copyFileSync, existsSync, mkdirSync } from "fs"
|
||||
import { defineConfig } from "electron-vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
|
||||
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'))
|
||||
const pkg = JSON.parse(readFileSync("./package.json", "utf-8"))
|
||||
|
||||
// Plugin to copy resources to output
|
||||
function copyResources(): { name: string; closeBundle: () => void } {
|
||||
return {
|
||||
name: 'copy-resources',
|
||||
name: "copy-resources",
|
||||
closeBundle(): void {
|
||||
const srcIcon = resolve('resources/icon.png')
|
||||
const destDir = resolve('out/resources')
|
||||
const destIcon = resolve('out/resources/icon.png')
|
||||
const srcIcon = resolve("resources/icon.png")
|
||||
const destDir = resolve("out/resources")
|
||||
const destIcon = resolve("out/resources/icon.png")
|
||||
|
||||
if (existsSync(srcIcon)) {
|
||||
if (!existsSync(destDir)) {
|
||||
@@ -30,11 +30,11 @@ export default defineConfig({
|
||||
// Bundle all dependencies into the main process
|
||||
build: {
|
||||
lib: {
|
||||
entry: 'src/main/index.ts',
|
||||
formats: ['cjs']
|
||||
entry: "src/main/index.ts",
|
||||
formats: ["cjs"]
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ['electron'],
|
||||
external: ["electron"],
|
||||
plugins: [copyResources()]
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,8 @@ export default defineConfig({
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve('src/renderer/src'),
|
||||
'@renderer': resolve('src/renderer/src')
|
||||
"@": resolve("src/renderer/src"),
|
||||
"@renderer": resolve("src/renderer/src")
|
||||
}
|
||||
},
|
||||
plugins: [react(), tailwindcss()]
|
||||
|
||||
+15
-12
@@ -1,32 +1,35 @@
|
||||
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";
|
||||
import { defineConfig } from "eslint/config"
|
||||
import eslint from "@eslint/js"
|
||||
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"] },
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
eslintPluginReact.configs.flat.recommended,
|
||||
eslintPluginReact.configs.flat["jsx-runtime"],
|
||||
{
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
version: "detect"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
plugins: {
|
||||
"react-hooks": eslintPluginReactHooks,
|
||||
"react-refresh": eslintPluginReactRefresh,
|
||||
"react-refresh": eslintPluginReactRefresh
|
||||
},
|
||||
rules: {
|
||||
...eslintPluginReactHooks.configs.recommended.rules,
|
||||
...eslintPluginReactRefresh.configs.vite.rules,
|
||||
},
|
||||
"@typescript-eslint/explicit-function-return-type": "off"
|
||||
}
|
||||
},
|
||||
eslintConfigPrettier
|
||||
);
|
||||
)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,16 +0,0 @@
|
||||
# Resources
|
||||
|
||||
Place your app icon here:
|
||||
|
||||
- `icon.png` - PNG icon (512x512 recommended, used for macOS dock)
|
||||
- `icon.icns` - macOS icon (optional, for packaged apps)
|
||||
- `icon.ico` - Windows icon (optional, for packaged apps)
|
||||
|
||||
## Creating an Icon
|
||||
|
||||
You can create an icon using tools like:
|
||||
- [Figma](https://figma.com)
|
||||
- [IconKitchen](https://icon.kitchen)
|
||||
- [MakeAppIcon](https://makeappicon.com)
|
||||
|
||||
Export as 512x512 PNG for best results.
|
||||
@@ -9,9 +9,9 @@
|
||||
* handled via HITL configuration.
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { FilesystemBackend, type ExecuteResponse, type SandboxBackendProtocol } from 'deepagents'
|
||||
import { spawn } from "node:child_process"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { FilesystemBackend, type ExecuteResponse, type SandboxBackendProtocol } from "deepagents"
|
||||
|
||||
/**
|
||||
* Options for LocalSandbox configuration.
|
||||
@@ -88,9 +88,9 @@ export class LocalSandbox extends FilesystemBackend implements SandboxBackendPro
|
||||
* ```
|
||||
*/
|
||||
async execute(command: string): Promise<ExecuteResponse> {
|
||||
if (!command || typeof command !== 'string') {
|
||||
if (!command || typeof command !== "string") {
|
||||
return {
|
||||
output: 'Error: Shell tool expects a non-empty command string.',
|
||||
output: "Error: Shell tool expects a non-empty command string.",
|
||||
exitCode: 1,
|
||||
truncated: false
|
||||
}
|
||||
@@ -103,23 +103,23 @@ export class LocalSandbox extends FilesystemBackend implements SandboxBackendPro
|
||||
let resolved = false
|
||||
|
||||
// Determine shell based on platform
|
||||
const isWindows = process.platform === 'win32'
|
||||
const shell = isWindows ? 'cmd.exe' : '/bin/sh'
|
||||
const shellArgs = isWindows ? ['/c', command] : ['-c', command]
|
||||
const isWindows = process.platform === "win32"
|
||||
const shell = isWindows ? "cmd.exe" : "/bin/sh"
|
||||
const shellArgs = isWindows ? ["/c", command] : ["-c", command]
|
||||
|
||||
const proc = spawn(shell, shellArgs, {
|
||||
cwd: this.workingDir,
|
||||
env: this.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
})
|
||||
|
||||
// Handle timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
proc.kill('SIGTERM')
|
||||
proc.kill("SIGTERM")
|
||||
// Give it a moment, then force kill
|
||||
setTimeout(() => proc.kill('SIGKILL'), 1000)
|
||||
setTimeout(() => proc.kill("SIGKILL"), 1000)
|
||||
resolve({
|
||||
output: `Error: Command timed out after ${(this.timeout / 1000).toFixed(1)} seconds.`,
|
||||
exitCode: null,
|
||||
@@ -129,7 +129,7 @@ export class LocalSandbox extends FilesystemBackend implements SandboxBackendPro
|
||||
}, this.timeout)
|
||||
|
||||
// Collect stdout
|
||||
proc.stdout.on('data', (data: Buffer) => {
|
||||
proc.stdout.on("data", (data: Buffer) => {
|
||||
if (truncated) return
|
||||
|
||||
const chunk = data.toString()
|
||||
@@ -150,20 +150,20 @@ export class LocalSandbox extends FilesystemBackend implements SandboxBackendPro
|
||||
})
|
||||
|
||||
// Collect stderr with [stderr] prefix per line
|
||||
proc.stderr.on('data', (data: Buffer) => {
|
||||
proc.stderr.on("data", (data: Buffer) => {
|
||||
if (truncated) return
|
||||
|
||||
const chunk = data.toString()
|
||||
// Prefix each line with [stderr]
|
||||
const prefixedLines = chunk
|
||||
.split('\n')
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => `[stderr] ${line}`)
|
||||
.join('\n')
|
||||
.join("\n")
|
||||
|
||||
if (prefixedLines.length === 0) return
|
||||
|
||||
const withNewline = prefixedLines + (chunk.endsWith('\n') ? '\n' : '')
|
||||
const withNewline = prefixedLines + (chunk.endsWith("\n") ? "\n" : "")
|
||||
const newTotal = totalBytes + withNewline.length
|
||||
|
||||
if (newTotal > this.maxOutputBytes) {
|
||||
@@ -180,12 +180,12 @@ export class LocalSandbox extends FilesystemBackend implements SandboxBackendPro
|
||||
})
|
||||
|
||||
// Handle process exit
|
||||
proc.on('close', (code, signal) => {
|
||||
proc.on("close", (code, signal) => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
let output = outputParts.join('')
|
||||
let output = outputParts.join("")
|
||||
|
||||
// Add truncation notice if needed
|
||||
if (truncated) {
|
||||
@@ -194,7 +194,7 @@ export class LocalSandbox extends FilesystemBackend implements SandboxBackendPro
|
||||
|
||||
// If no output, show placeholder
|
||||
if (!output.trim()) {
|
||||
output = '<no output>'
|
||||
output = "<no output>"
|
||||
}
|
||||
|
||||
resolve({
|
||||
@@ -205,7 +205,7 @@ export class LocalSandbox extends FilesystemBackend implements SandboxBackendPro
|
||||
})
|
||||
|
||||
// Handle spawn errors
|
||||
proc.on('error', (err) => {
|
||||
proc.on("error", (err) => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
+37
-38
@@ -1,19 +1,19 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { createDeepAgent } from 'deepagents'
|
||||
import { getDefaultModel } from '../ipc/models'
|
||||
import { getApiKey, getThreadCheckpointPath } from '../storage'
|
||||
import { ChatAnthropic } from '@langchain/anthropic'
|
||||
import { ChatOpenAI } from '@langchain/openai'
|
||||
import { ChatGoogleGenerativeAI } from '@langchain/google-genai'
|
||||
import { SqlJsSaver } from '../checkpointer/sqljs-saver'
|
||||
import { LocalSandbox } from './local-sandbox'
|
||||
import { createDeepAgent } from "deepagents"
|
||||
import { getDefaultModel } from "../ipc/models"
|
||||
import { getApiKey, getThreadCheckpointPath } from "../storage"
|
||||
import { ChatAnthropic } from "@langchain/anthropic"
|
||||
import { ChatOpenAI } from "@langchain/openai"
|
||||
import { ChatGoogleGenerativeAI } from "@langchain/google-genai"
|
||||
import { SqlJsSaver } from "../checkpointer/sqljs-saver"
|
||||
import { LocalSandbox } from "./local-sandbox"
|
||||
|
||||
import type * as _lcTypes from 'langchain'
|
||||
import type * as _lcMessages from '@langchain/core/messages'
|
||||
import type * as _lcLanggraph from '@langchain/langgraph'
|
||||
import type * as _lcZodTypes from '@langchain/core/utils/types'
|
||||
import type * as _lcTypes from "langchain"
|
||||
import type * as _lcMessages from "@langchain/core/messages"
|
||||
import type * as _lcLanggraph from "@langchain/langgraph"
|
||||
import type * as _lcZodTypes from "@langchain/core/utils/types"
|
||||
|
||||
import { BASE_SYSTEM_PROMPT } from './system-prompt'
|
||||
import { BASE_SYSTEM_PROMPT } from "./system-prompt"
|
||||
|
||||
/**
|
||||
* Generate the full system prompt for the agent.
|
||||
@@ -63,39 +63,39 @@ function getModelInstance(
|
||||
modelId?: string
|
||||
): ChatAnthropic | ChatOpenAI | ChatGoogleGenerativeAI | string {
|
||||
const model = modelId || getDefaultModel()
|
||||
console.log('[Runtime] Using model:', model)
|
||||
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 (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')
|
||||
throw new Error("Anthropic API key not configured")
|
||||
}
|
||||
return new ChatAnthropic({
|
||||
model,
|
||||
anthropicApiKey: apiKey
|
||||
})
|
||||
} else if (
|
||||
model.startsWith('gpt') ||
|
||||
model.startsWith('o1') ||
|
||||
model.startsWith('o3') ||
|
||||
model.startsWith('o4')
|
||||
model.startsWith("gpt") ||
|
||||
model.startsWith("o1") ||
|
||||
model.startsWith("o3") ||
|
||||
model.startsWith("o4")
|
||||
) {
|
||||
const apiKey = getApiKey('openai')
|
||||
console.log('[Runtime] OpenAI API key present:', !!apiKey)
|
||||
const apiKey = getApiKey("openai")
|
||||
console.log("[Runtime] OpenAI API key present:", !!apiKey)
|
||||
if (!apiKey) {
|
||||
throw new Error('OpenAI API key not configured')
|
||||
throw new Error("OpenAI API key not configured")
|
||||
}
|
||||
return new ChatOpenAI({
|
||||
model,
|
||||
openAIApiKey: apiKey
|
||||
})
|
||||
} else if (model.startsWith('gemini')) {
|
||||
const apiKey = getApiKey('google')
|
||||
console.log('[Runtime] Google API key present:', !!apiKey)
|
||||
} else if (model.startsWith("gemini")) {
|
||||
const apiKey = getApiKey("google")
|
||||
console.log("[Runtime] Google API key present:", !!apiKey)
|
||||
if (!apiKey) {
|
||||
throw new Error('Google API key not configured')
|
||||
throw new Error("Google API key not configured")
|
||||
}
|
||||
return new ChatGoogleGenerativeAI({
|
||||
model,
|
||||
@@ -119,29 +119,28 @@ export interface CreateAgentRuntimeOptions {
|
||||
// Create agent runtime with configured model and checkpointer
|
||||
export type AgentRuntime = ReturnType<typeof createDeepAgent>
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
export async function createAgentRuntime(options: CreateAgentRuntimeOptions) {
|
||||
const { threadId, modelId, workspacePath } = options
|
||||
|
||||
if (!threadId) {
|
||||
throw new Error('Thread ID is required for checkpointing.')
|
||||
throw new Error("Thread ID is required for checkpointing.")
|
||||
}
|
||||
|
||||
if (!workspacePath) {
|
||||
throw new Error(
|
||||
'Workspace path is required. Please select a workspace folder before running the agent.'
|
||||
"Workspace path is required. Please select a workspace folder before running the agent."
|
||||
)
|
||||
}
|
||||
|
||||
console.log('[Runtime] Creating agent runtime...')
|
||||
console.log('[Runtime] Thread ID:', threadId)
|
||||
console.log('[Runtime] Workspace path:', workspacePath)
|
||||
console.log("[Runtime] Creating agent runtime...")
|
||||
console.log("[Runtime] Thread ID:", threadId)
|
||||
console.log("[Runtime] Workspace path:", workspacePath)
|
||||
|
||||
const model = getModelInstance(modelId)
|
||||
console.log('[Runtime] Model instance created:', typeof model)
|
||||
console.log("[Runtime] Model instance created:", typeof model)
|
||||
|
||||
const checkpointer = await getCheckpointer(threadId)
|
||||
console.log('[Runtime] Checkpointer ready for thread:', threadId)
|
||||
console.log("[Runtime] Checkpointer ready for thread:", threadId)
|
||||
|
||||
const backend = new LocalSandbox({
|
||||
rootDir: workspacePath,
|
||||
@@ -175,7 +174,7 @@ The workspace root is: ${workspacePath}`
|
||||
interruptOn: { execute: true }
|
||||
} as Parameters<typeof createDeepAgent>[0])
|
||||
|
||||
console.log('[Runtime] Deep agent created with LocalSandbox at:', workspacePath)
|
||||
console.log("[Runtime] Deep agent created with LocalSandbox at:", workspacePath)
|
||||
return agent
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// @ts-ignore this is a workaround to avoid type errors in the main process
|
||||
import type { createAgentRuntime } from './runtime'
|
||||
import type { createAgentRuntime } from "./runtime"
|
||||
|
||||
export type DeepAgent = Awaited<ReturnType<typeof createAgentRuntime>>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import initSqlJs, { Database as SqlJsDatabase } from 'sql.js'
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, renameSync, unlinkSync } from 'fs'
|
||||
import { dirname } from 'path'
|
||||
import type { RunnableConfig } from '@langchain/core/runnables'
|
||||
import initSqlJs, { Database as SqlJsDatabase } from "sql.js"
|
||||
import {
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
statSync,
|
||||
renameSync,
|
||||
unlinkSync
|
||||
} from "fs"
|
||||
import { dirname } from "path"
|
||||
import type { RunnableConfig } from "@langchain/core/runnables"
|
||||
import {
|
||||
BaseCheckpointSaver,
|
||||
type Checkpoint,
|
||||
@@ -11,7 +19,7 @@ import {
|
||||
type PendingWrite,
|
||||
type CheckpointMetadata,
|
||||
copyCheckpoint
|
||||
} from '@langchain/langgraph-checkpoint'
|
||||
} from "@langchain/langgraph-checkpoint"
|
||||
|
||||
interface CheckpointRow {
|
||||
thread_id: string
|
||||
@@ -66,17 +74,17 @@ export class SqlJsSaver extends BaseCheckpointSaver {
|
||||
`Creating fresh database to prevent memory issues.`
|
||||
)
|
||||
// Rename the old file for backup
|
||||
const backupPath = this.dbPath + '.bak.' + Date.now()
|
||||
const backupPath = this.dbPath + ".bak." + Date.now()
|
||||
try {
|
||||
renameSync(this.dbPath, backupPath)
|
||||
console.log(`[SqlJsSaver] Old database backed up to: ${backupPath}`)
|
||||
} catch (e) {
|
||||
console.warn('[SqlJsSaver] Could not backup old database:', e)
|
||||
console.warn("[SqlJsSaver] Could not backup old database:", e)
|
||||
// Try to delete instead
|
||||
try {
|
||||
unlinkSync(this.dbPath)
|
||||
} catch (e2) {
|
||||
console.error('[SqlJsSaver] Could not delete old database:', e2)
|
||||
console.error("[SqlJsSaver] Could not delete old database:", e2)
|
||||
}
|
||||
}
|
||||
this.db = new SQL.Database()
|
||||
@@ -170,9 +178,9 @@ export class SqlJsSaver extends BaseCheckpointSaver {
|
||||
|
||||
async getTuple(config: RunnableConfig): Promise<CheckpointTuple | undefined> {
|
||||
await this.initialize()
|
||||
if (!this.db) throw new Error('Database not initialized')
|
||||
if (!this.db) throw new Error("Database not initialized")
|
||||
|
||||
const { thread_id, checkpoint_ns = '', checkpoint_id } = config.configurable ?? {}
|
||||
const { thread_id, checkpoint_ns = "", checkpoint_id } = config.configurable ?? {}
|
||||
|
||||
let sql: string
|
||||
let params: (string | undefined)[]
|
||||
@@ -217,13 +225,13 @@ export class SqlJsSaver extends BaseCheckpointSaver {
|
||||
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 ?? '')
|
||||
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.type ?? "json",
|
||||
row.checkpoint
|
||||
)) as Checkpoint
|
||||
|
||||
@@ -241,7 +249,7 @@ export class SqlJsSaver extends BaseCheckpointSaver {
|
||||
checkpoint,
|
||||
config: finalConfig,
|
||||
metadata: (await this.serde.loadsTyped(
|
||||
row.type ?? 'json',
|
||||
row.type ?? "json",
|
||||
row.metadata
|
||||
)) as CheckpointMetadata,
|
||||
parentConfig: row.parent_checkpoint_id
|
||||
@@ -262,11 +270,11 @@ export class SqlJsSaver extends BaseCheckpointSaver {
|
||||
options?: CheckpointListOptions
|
||||
): AsyncGenerator<CheckpointTuple> {
|
||||
await this.initialize()
|
||||
if (!this.db) throw new Error('Database not initialized')
|
||||
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 ?? ''
|
||||
const checkpoint_ns = config.configurable?.checkpoint_ns ?? ""
|
||||
|
||||
let sql = `
|
||||
SELECT thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata
|
||||
@@ -303,13 +311,13 @@ export class SqlJsSaver extends BaseCheckpointSaver {
|
||||
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 ?? '')
|
||||
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.type ?? "json",
|
||||
row.checkpoint
|
||||
)) as Checkpoint
|
||||
|
||||
@@ -323,7 +331,7 @@ export class SqlJsSaver extends BaseCheckpointSaver {
|
||||
},
|
||||
checkpoint,
|
||||
metadata: (await this.serde.loadsTyped(
|
||||
row.type ?? 'json',
|
||||
row.type ?? "json",
|
||||
row.metadata
|
||||
)) as CheckpointMetadata,
|
||||
parentConfig: row.parent_checkpoint_id
|
||||
@@ -348,14 +356,14 @@ export class SqlJsSaver extends BaseCheckpointSaver {
|
||||
metadata: CheckpointMetadata
|
||||
): Promise<RunnableConfig> {
|
||||
await this.initialize()
|
||||
if (!this.db) throw new Error('Database not initialized')
|
||||
if (!this.db) throw new Error("Database not initialized")
|
||||
|
||||
if (!config.configurable) {
|
||||
throw new Error('Empty configuration supplied.')
|
||||
throw new Error("Empty configuration supplied.")
|
||||
}
|
||||
|
||||
const thread_id = config.configurable?.thread_id
|
||||
const checkpoint_ns = config.configurable?.checkpoint_ns ?? ''
|
||||
const checkpoint_ns = config.configurable?.checkpoint_ns ?? ""
|
||||
const parent_checkpoint_id = config.configurable?.checkpoint_id
|
||||
|
||||
if (!thread_id) {
|
||||
@@ -370,7 +378,7 @@ export class SqlJsSaver extends BaseCheckpointSaver {
|
||||
])
|
||||
|
||||
if (type1 !== type2) {
|
||||
throw new Error('Failed to serialize checkpoint and metadata to the same type.')
|
||||
throw new Error("Failed to serialize checkpoint and metadata to the same type.")
|
||||
}
|
||||
|
||||
this.db.run(
|
||||
@@ -401,18 +409,18 @@ export class SqlJsSaver extends BaseCheckpointSaver {
|
||||
|
||||
async putWrites(config: RunnableConfig, writes: PendingWrite[], taskId: string): Promise<void> {
|
||||
await this.initialize()
|
||||
if (!this.db) throw new Error('Database not initialized')
|
||||
if (!this.db) throw new Error("Database not initialized")
|
||||
|
||||
if (!config.configurable) {
|
||||
throw new Error('Empty configuration supplied.')
|
||||
throw new Error("Empty configuration supplied.")
|
||||
}
|
||||
|
||||
if (!config.configurable?.thread_id) {
|
||||
throw new Error('Missing thread_id field in config.configurable.')
|
||||
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.')
|
||||
throw new Error("Missing checkpoint_id field in config.configurable.")
|
||||
}
|
||||
|
||||
for (let idx = 0; idx < writes.length; idx++) {
|
||||
@@ -425,7 +433,7 @@ export class SqlJsSaver extends BaseCheckpointSaver {
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
config.configurable.thread_id,
|
||||
config.configurable.checkpoint_ns ?? '',
|
||||
config.configurable.checkpoint_ns ?? "",
|
||||
config.configurable.checkpoint_id,
|
||||
taskId,
|
||||
idx,
|
||||
@@ -441,7 +449,7 @@ export class SqlJsSaver extends BaseCheckpointSaver {
|
||||
|
||||
async deleteThread(threadId: string): Promise<void> {
|
||||
await this.initialize()
|
||||
if (!this.db) throw new Error('Database not initialized')
|
||||
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])
|
||||
|
||||
+29
-28
@@ -1,7 +1,7 @@
|
||||
import initSqlJs, { Database as SqlJsDatabase } from 'sql.js'
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
|
||||
import { dirname } from 'path'
|
||||
import { getDbPath } from '../storage'
|
||||
import initSqlJs, { Database as SqlJsDatabase } from "sql.js"
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"
|
||||
import { dirname } from "path"
|
||||
import { getDbPath } from "../storage"
|
||||
|
||||
let db: SqlJsDatabase | null = null
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -45,14 +45,14 @@ export async function flush(): Promise<void> {
|
||||
|
||||
export function getDb(): SqlJsDatabase {
|
||||
if (!db) {
|
||||
throw new Error('Database not initialized. Call initializeDatabase() first.')
|
||||
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)
|
||||
console.log("Initializing database at:", dbPath)
|
||||
|
||||
const SQL = await initSqlJs()
|
||||
|
||||
@@ -113,7 +113,7 @@ export async function initializeDatabase(): Promise<SqlJsDatabase> {
|
||||
|
||||
saveToDisk()
|
||||
|
||||
console.log('Database initialized successfully')
|
||||
console.log("Database initialized successfully")
|
||||
return db
|
||||
}
|
||||
|
||||
@@ -135,7 +135,8 @@ export function closeDatabase(): void {
|
||||
|
||||
// Helper functions for common operations
|
||||
|
||||
export interface Thread {
|
||||
/** Raw thread row from SQLite database (timestamps as numbers, metadata as JSON string) */
|
||||
export interface ThreadRow {
|
||||
thread_id: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
@@ -145,22 +146,22 @@ export interface Thread {
|
||||
title: string | null
|
||||
}
|
||||
|
||||
export function getAllThreads(): Thread[] {
|
||||
export function getAllThreads(): ThreadRow[] {
|
||||
const database = getDb()
|
||||
const stmt = database.prepare('SELECT * FROM threads ORDER BY updated_at DESC')
|
||||
const threads: Thread[] = []
|
||||
const stmt = database.prepare("SELECT * FROM threads ORDER BY updated_at DESC")
|
||||
const threads: ThreadRow[] = []
|
||||
|
||||
while (stmt.step()) {
|
||||
threads.push(stmt.getAsObject() as unknown as Thread)
|
||||
threads.push(stmt.getAsObject() as unknown as ThreadRow)
|
||||
}
|
||||
stmt.free()
|
||||
|
||||
return threads
|
||||
}
|
||||
|
||||
export function getThread(threadId: string): Thread | null {
|
||||
export function getThread(threadId: string): ThreadRow | null {
|
||||
const database = getDb()
|
||||
const stmt = database.prepare('SELECT * FROM threads WHERE thread_id = ?')
|
||||
const stmt = database.prepare("SELECT * FROM threads WHERE thread_id = ?")
|
||||
stmt.bind([threadId])
|
||||
|
||||
if (!stmt.step()) {
|
||||
@@ -168,19 +169,19 @@ export function getThread(threadId: string): Thread | null {
|
||||
return null
|
||||
}
|
||||
|
||||
const thread = stmt.getAsObject() as unknown as Thread
|
||||
const thread = stmt.getAsObject() as unknown as ThreadRow
|
||||
stmt.free()
|
||||
return thread
|
||||
}
|
||||
|
||||
export function createThread(threadId: string, metadata?: Record<string, unknown>): Thread {
|
||||
export function createThread(threadId: string, metadata?: Record<string, unknown>): ThreadRow {
|
||||
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']
|
||||
[threadId, now, now, metadata ? JSON.stringify(metadata) : null, "idle"]
|
||||
)
|
||||
|
||||
saveToDisk()
|
||||
@@ -190,7 +191,7 @@ export function createThread(threadId: string, metadata?: Record<string, unknown
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
metadata: metadata ? JSON.stringify(metadata) : null,
|
||||
status: 'idle',
|
||||
status: "idle",
|
||||
thread_values: null,
|
||||
title: null
|
||||
}
|
||||
@@ -198,39 +199,39 @@ export function createThread(threadId: string, metadata?: Record<string, unknown
|
||||
|
||||
export function updateThread(
|
||||
threadId: string,
|
||||
updates: Partial<Omit<Thread, 'thread_id' | 'created_at'>>
|
||||
): Thread | null {
|
||||
updates: Partial<Omit<ThreadRow, "thread_id" | "created_at">>
|
||||
): ThreadRow | null {
|
||||
const database = getDb()
|
||||
const existing = getThread(threadId)
|
||||
|
||||
if (!existing) return null
|
||||
|
||||
const now = Date.now()
|
||||
const setClauses: string[] = ['updated_at = ?']
|
||||
const setClauses: string[] = ["updated_at = ?"]
|
||||
const values: (string | number | null)[] = [now]
|
||||
|
||||
if (updates.metadata !== undefined) {
|
||||
setClauses.push('metadata = ?')
|
||||
setClauses.push("metadata = ?")
|
||||
values.push(
|
||||
typeof updates.metadata === 'string' ? updates.metadata : JSON.stringify(updates.metadata)
|
||||
typeof updates.metadata === "string" ? updates.metadata : JSON.stringify(updates.metadata)
|
||||
)
|
||||
}
|
||||
if (updates.status !== undefined) {
|
||||
setClauses.push('status = ?')
|
||||
setClauses.push("status = ?")
|
||||
values.push(updates.status)
|
||||
}
|
||||
if (updates.thread_values !== undefined) {
|
||||
setClauses.push('thread_values = ?')
|
||||
setClauses.push("thread_values = ?")
|
||||
values.push(updates.thread_values)
|
||||
}
|
||||
if (updates.title !== undefined) {
|
||||
setClauses.push('title = ?')
|
||||
setClauses.push("title = ?")
|
||||
values.push(updates.title)
|
||||
}
|
||||
|
||||
values.push(threadId)
|
||||
|
||||
database.run(`UPDATE threads SET ${setClauses.join(', ')} WHERE thread_id = ?`, values)
|
||||
database.run(`UPDATE threads SET ${setClauses.join(", ")} WHERE thread_id = ?`, values)
|
||||
|
||||
saveToDisk()
|
||||
|
||||
@@ -239,6 +240,6 @@ export function updateThread(
|
||||
|
||||
export function deleteThread(threadId: string): void {
|
||||
const database = getDb()
|
||||
database.run('DELETE FROM threads WHERE thread_id = ?', [threadId])
|
||||
database.run("DELETE FROM threads WHERE thread_id = ?", [threadId])
|
||||
saveToDisk()
|
||||
}
|
||||
|
||||
+25
-25
@@ -1,9 +1,9 @@
|
||||
import { app, shell, BrowserWindow, ipcMain, nativeImage } 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'
|
||||
import { app, shell, BrowserWindow, ipcMain, nativeImage } 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
|
||||
|
||||
@@ -17,45 +17,45 @@ function createWindow(): void {
|
||||
minWidth: 1200,
|
||||
minHeight: 700,
|
||||
show: false,
|
||||
backgroundColor: '#0D0D0F',
|
||||
titleBarStyle: 'hiddenInset',
|
||||
backgroundColor: "#0D0D0F",
|
||||
titleBarStyle: "hiddenInset",
|
||||
trafficLightPosition: { x: 16, y: 16 },
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
preload: join(__dirname, "../preload/index.js"),
|
||||
sandbox: false
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow.on("ready-to-show", () => {
|
||||
mainWindow?.show()
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
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'])
|
||||
if (isDev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"])
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
mainWindow.loadFile(join(__dirname, "../renderer/index.html"))
|
||||
}
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
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')
|
||||
if (process.platform === "win32") {
|
||||
app.setAppUserModelId(isDev ? process.execPath : "com.langchain.openwork")
|
||||
}
|
||||
|
||||
// Set dock icon on macOS
|
||||
if (process.platform === 'darwin' && app.dock) {
|
||||
const iconPath = join(__dirname, '../../resources/icon.png')
|
||||
if (process.platform === "darwin" && app.dock) {
|
||||
const iconPath = join(__dirname, "../../resources/icon.png")
|
||||
try {
|
||||
const icon = nativeImage.createFromPath(iconPath)
|
||||
if (!icon.isEmpty()) {
|
||||
@@ -68,9 +68,9 @@ app.whenReady().then(async () => {
|
||||
|
||||
// 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') {
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
window.webContents.on("before-input-event", (event, input) => {
|
||||
if (input.key === "F12") {
|
||||
window.webContents.toggleDevTools()
|
||||
event.preventDefault()
|
||||
}
|
||||
@@ -88,15 +88,15 @@ app.whenReady().then(async () => {
|
||||
|
||||
createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
+246
-260
@@ -1,314 +1,300 @@
|
||||
import { IpcMain, BrowserWindow } from 'electron'
|
||||
import { HumanMessage } from '@langchain/core/messages'
|
||||
import { Command } from '@langchain/langgraph'
|
||||
import { createAgentRuntime } from '../agent/runtime'
|
||||
import { getThread } from '../db'
|
||||
import type { HITLDecision } from '../types'
|
||||
import { IpcMain, BrowserWindow } from "electron"
|
||||
import { HumanMessage } from "@langchain/core/messages"
|
||||
import { Command } from "@langchain/langgraph"
|
||||
import { createAgentRuntime } from "../agent/runtime"
|
||||
import { getThread } from "../db"
|
||||
import type {
|
||||
AgentInvokeParams,
|
||||
AgentResumeParams,
|
||||
AgentInterruptParams,
|
||||
AgentCancelParams
|
||||
} from "../types"
|
||||
|
||||
// Track active runs for cancellation
|
||||
const activeRuns = new Map<string, AbortController>()
|
||||
|
||||
export function registerAgentHandlers(ipcMain: IpcMain): void {
|
||||
console.log('[Agent] Registering agent handlers...')
|
||||
console.log("[Agent] Registering agent handlers...")
|
||||
|
||||
// Handle agent invocation with streaming
|
||||
ipcMain.on(
|
||||
'agent:invoke',
|
||||
async (event, { threadId, message, modelId }: { threadId: string; message: string; modelId?: string }) => {
|
||||
const channel = `agent:stream:${threadId}`
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
ipcMain.on("agent:invoke", async (event, { threadId, message, modelId }: AgentInvokeParams) => {
|
||||
const channel = `agent:stream:${threadId}`
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
|
||||
console.log('[Agent] Received invoke request:', {
|
||||
threadId,
|
||||
message: message.substring(0, 50),
|
||||
modelId
|
||||
})
|
||||
console.log("[Agent] Received invoke request:", {
|
||||
threadId,
|
||||
message: message.substring(0, 50),
|
||||
modelId
|
||||
})
|
||||
|
||||
if (!window) {
|
||||
console.error('[Agent] No window found')
|
||||
return
|
||||
}
|
||||
|
||||
// Abort any existing stream for this thread before starting a new one
|
||||
// This prevents concurrent streams which can cause checkpoint corruption
|
||||
const existingController = activeRuns.get(threadId)
|
||||
if (existingController) {
|
||||
console.log('[Agent] Aborting existing stream for thread:', threadId)
|
||||
existingController.abort()
|
||||
activeRuns.delete(threadId)
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
activeRuns.set(threadId, abortController)
|
||||
|
||||
// Abort the stream if the window is closed/destroyed
|
||||
const onWindowClosed = (): void => {
|
||||
console.log('[Agent] Window closed, aborting stream for thread:', threadId)
|
||||
abortController.abort()
|
||||
}
|
||||
window.once('closed', onWindowClosed)
|
||||
|
||||
try {
|
||||
// Get workspace path from thread metadata - REQUIRED
|
||||
const thread = getThread(threadId)
|
||||
const metadata = thread?.metadata ? JSON.parse(thread.metadata) : {}
|
||||
console.log('[Agent] Thread metadata:', metadata)
|
||||
|
||||
const workspacePath = metadata.workspacePath as string | undefined
|
||||
const modelId = metadata.model as string | undefined
|
||||
console.log('[Agent] Extracted modelId:', modelId)
|
||||
|
||||
if (!workspacePath) {
|
||||
window.webContents.send(channel, {
|
||||
type: 'error',
|
||||
error: 'WORKSPACE_REQUIRED',
|
||||
message: 'Please select a workspace folder before sending messages.'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const agent = await createAgentRuntime({ threadId, workspacePath, modelId })
|
||||
const humanMessage = new HumanMessage(message)
|
||||
|
||||
// Stream with both modes:
|
||||
// - 'messages' for real-time token streaming
|
||||
// - 'values' for full state (todos, files, etc.)
|
||||
const stream = await agent.stream(
|
||||
{ messages: [humanMessage] },
|
||||
{
|
||||
configurable: { thread_id: threadId },
|
||||
signal: abortController.signal,
|
||||
streamMode: ['messages', 'values'],
|
||||
recursionLimit: 1000
|
||||
}
|
||||
)
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (abortController.signal.aborted) break
|
||||
|
||||
// With multiple stream modes, chunks are tuples: [mode, data]
|
||||
const [mode, data] = chunk as [string, unknown]
|
||||
|
||||
// Forward raw stream events - transport layer handles parsing
|
||||
// Serialize to plain objects for IPC (class instances don't transfer)
|
||||
window.webContents.send(channel, {
|
||||
type: 'stream',
|
||||
mode,
|
||||
data: JSON.parse(JSON.stringify(data))
|
||||
})
|
||||
}
|
||||
|
||||
// Send done event (only if not aborted)
|
||||
if (!abortController.signal.aborted) {
|
||||
window.webContents.send(channel, { type: 'done' })
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore abort-related errors (expected when stream is cancelled)
|
||||
const isAbortError =
|
||||
error instanceof Error &&
|
||||
(error.name === 'AbortError' ||
|
||||
error.message.includes('aborted') ||
|
||||
error.message.includes('Controller is already closed'))
|
||||
|
||||
if (!isAbortError) {
|
||||
console.error('[Agent] Error:', error)
|
||||
window.webContents.send(channel, {
|
||||
type: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
window.removeListener('closed', onWindowClosed)
|
||||
activeRuns.delete(threadId)
|
||||
}
|
||||
if (!window) {
|
||||
console.error("[Agent] No window found")
|
||||
return
|
||||
}
|
||||
)
|
||||
|
||||
// Handle agent resume (after interrupt approval/rejection via useStream)
|
||||
ipcMain.on(
|
||||
'agent:resume',
|
||||
async (
|
||||
event,
|
||||
{
|
||||
threadId,
|
||||
command,
|
||||
modelId
|
||||
}: { threadId: string; command: { resume?: { decision?: string } }; modelId?: string }
|
||||
) => {
|
||||
const channel = `agent:stream:${threadId}`
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
// Abort any existing stream for this thread before starting a new one
|
||||
// This prevents concurrent streams which can cause checkpoint corruption
|
||||
const existingController = activeRuns.get(threadId)
|
||||
if (existingController) {
|
||||
console.log("[Agent] Aborting existing stream for thread:", threadId)
|
||||
existingController.abort()
|
||||
activeRuns.delete(threadId)
|
||||
}
|
||||
|
||||
console.log('[Agent] Received resume request:', { threadId, command, modelId })
|
||||
const abortController = new AbortController()
|
||||
activeRuns.set(threadId, abortController)
|
||||
|
||||
if (!window) {
|
||||
console.error('[Agent] No window found for resume')
|
||||
return
|
||||
}
|
||||
// Abort the stream if the window is closed/destroyed
|
||||
const onWindowClosed = (): void => {
|
||||
console.log("[Agent] Window closed, aborting stream for thread:", threadId)
|
||||
abortController.abort()
|
||||
}
|
||||
window.once("closed", onWindowClosed)
|
||||
|
||||
// Get workspace path from thread metadata
|
||||
try {
|
||||
// Get workspace path from thread metadata - REQUIRED
|
||||
const thread = getThread(threadId)
|
||||
const metadata = thread?.metadata ? JSON.parse(thread.metadata) : {}
|
||||
console.log("[Agent] Thread metadata:", metadata)
|
||||
|
||||
const workspacePath = metadata.workspacePath as string | undefined
|
||||
const modelId = metadata.model as string | undefined
|
||||
|
||||
if (!workspacePath) {
|
||||
window.webContents.send(channel, {
|
||||
type: 'error',
|
||||
error: 'Workspace path is required'
|
||||
type: "error",
|
||||
error: "WORKSPACE_REQUIRED",
|
||||
message: "Please select a workspace folder before sending messages."
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Abort any existing stream before resuming
|
||||
const existingController = activeRuns.get(threadId)
|
||||
if (existingController) {
|
||||
existingController.abort()
|
||||
activeRuns.delete(threadId)
|
||||
}
|
||||
const agent = await createAgentRuntime({ threadId, workspacePath, modelId })
|
||||
const humanMessage = new HumanMessage(message)
|
||||
|
||||
const abortController = new AbortController()
|
||||
activeRuns.set(threadId, abortController)
|
||||
|
||||
try {
|
||||
const agent = await createAgentRuntime({ threadId, workspacePath, modelId })
|
||||
const config = {
|
||||
// Stream with both modes:
|
||||
// - 'messages' for real-time token streaming
|
||||
// - 'values' for full state (todos, files, etc.)
|
||||
const stream = await agent.stream(
|
||||
{ messages: [humanMessage] },
|
||||
{
|
||||
configurable: { thread_id: threadId },
|
||||
signal: abortController.signal,
|
||||
streamMode: ['messages', 'values'] as const,
|
||||
streamMode: ["messages", "values"],
|
||||
recursionLimit: 1000
|
||||
}
|
||||
)
|
||||
|
||||
// Resume from checkpoint by streaming with Command containing the decision
|
||||
// The HITL middleware expects { decisions: [{ type: 'approve' | 'reject' | 'edit' }] }
|
||||
const decisionType = command?.resume?.decision || 'approve'
|
||||
const resumeValue = { decisions: [{ type: decisionType }] }
|
||||
const stream = await agent.stream(new Command({ resume: resumeValue }), config)
|
||||
for await (const chunk of stream) {
|
||||
if (abortController.signal.aborted) break
|
||||
|
||||
// With multiple stream modes, chunks are tuples: [mode, data]
|
||||
const [mode, data] = chunk as [string, unknown]
|
||||
|
||||
// Forward raw stream events - transport layer handles parsing
|
||||
// Serialize to plain objects for IPC (class instances don't transfer)
|
||||
window.webContents.send(channel, {
|
||||
type: "stream",
|
||||
mode,
|
||||
data: JSON.parse(JSON.stringify(data))
|
||||
})
|
||||
}
|
||||
|
||||
// Send done event (only if not aborted)
|
||||
if (!abortController.signal.aborted) {
|
||||
window.webContents.send(channel, { type: "done" })
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore abort-related errors (expected when stream is cancelled)
|
||||
const isAbortError =
|
||||
error instanceof Error &&
|
||||
(error.name === "AbortError" ||
|
||||
error.message.includes("aborted") ||
|
||||
error.message.includes("Controller is already closed"))
|
||||
|
||||
if (!isAbortError) {
|
||||
console.error("[Agent] Error:", error)
|
||||
window.webContents.send(channel, {
|
||||
type: "error",
|
||||
error: error instanceof Error ? error.message : "Unknown error"
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
window.removeListener("closed", onWindowClosed)
|
||||
activeRuns.delete(threadId)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle agent resume (after interrupt approval/rejection via useStream)
|
||||
ipcMain.on("agent:resume", async (event, { threadId, command, modelId }: AgentResumeParams) => {
|
||||
const channel = `agent:stream:${threadId}`
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
|
||||
console.log("[Agent] Received resume request:", { threadId, command, modelId })
|
||||
|
||||
if (!window) {
|
||||
console.error("[Agent] No window found for resume")
|
||||
return
|
||||
}
|
||||
|
||||
// Get workspace path from thread metadata
|
||||
const thread = getThread(threadId)
|
||||
const metadata = thread?.metadata ? JSON.parse(thread.metadata) : {}
|
||||
const workspacePath = metadata.workspacePath as string | undefined
|
||||
|
||||
if (!workspacePath) {
|
||||
window.webContents.send(channel, {
|
||||
type: "error",
|
||||
error: "Workspace path is required"
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Abort any existing stream before resuming
|
||||
const existingController = activeRuns.get(threadId)
|
||||
if (existingController) {
|
||||
existingController.abort()
|
||||
activeRuns.delete(threadId)
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
activeRuns.set(threadId, abortController)
|
||||
|
||||
try {
|
||||
const agent = await createAgentRuntime({ threadId, workspacePath, modelId })
|
||||
const config = {
|
||||
configurable: { thread_id: threadId },
|
||||
signal: abortController.signal,
|
||||
streamMode: ["messages", "values"] as const,
|
||||
recursionLimit: 1000
|
||||
}
|
||||
|
||||
// Resume from checkpoint by streaming with Command containing the decision
|
||||
// The HITL middleware expects { decisions: [{ type: 'approve' | 'reject' | 'edit' }] }
|
||||
const decisionType = command?.resume?.decision || "approve"
|
||||
const resumeValue = { decisions: [{ type: decisionType }] }
|
||||
const stream = await agent.stream(new Command({ resume: resumeValue }), config)
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (abortController.signal.aborted) break
|
||||
|
||||
const [mode, data] = chunk as unknown as [string, unknown]
|
||||
window.webContents.send(channel, {
|
||||
type: "stream",
|
||||
mode,
|
||||
data: JSON.parse(JSON.stringify(data))
|
||||
})
|
||||
}
|
||||
|
||||
if (!abortController.signal.aborted) {
|
||||
window.webContents.send(channel, { type: "done" })
|
||||
}
|
||||
} catch (error) {
|
||||
const isAbortError =
|
||||
error instanceof Error &&
|
||||
(error.name === "AbortError" ||
|
||||
error.message.includes("aborted") ||
|
||||
error.message.includes("Controller is already closed"))
|
||||
|
||||
if (!isAbortError) {
|
||||
console.error("[Agent] Resume error:", error)
|
||||
window.webContents.send(channel, {
|
||||
type: "error",
|
||||
error: error instanceof Error ? error.message : "Unknown error"
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
activeRuns.delete(threadId)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle HITL interrupt response
|
||||
ipcMain.on("agent:interrupt", async (event, { threadId, decision }: AgentInterruptParams) => {
|
||||
const channel = `agent:stream:${threadId}`
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
|
||||
if (!window) {
|
||||
console.error("[Agent] No window found for interrupt response")
|
||||
return
|
||||
}
|
||||
|
||||
// Get workspace path from thread metadata - REQUIRED
|
||||
const thread = getThread(threadId)
|
||||
const metadata = thread?.metadata ? JSON.parse(thread.metadata) : {}
|
||||
const workspacePath = metadata.workspacePath as string | undefined
|
||||
const modelId = metadata.model as string | undefined
|
||||
|
||||
if (!workspacePath) {
|
||||
window.webContents.send(channel, {
|
||||
type: "error",
|
||||
error: "Workspace path is required"
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Abort any existing stream before continuing
|
||||
const existingController = activeRuns.get(threadId)
|
||||
if (existingController) {
|
||||
existingController.abort()
|
||||
activeRuns.delete(threadId)
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
activeRuns.set(threadId, abortController)
|
||||
|
||||
try {
|
||||
const agent = await createAgentRuntime({ threadId, workspacePath, modelId })
|
||||
const config = {
|
||||
configurable: { thread_id: threadId },
|
||||
signal: abortController.signal,
|
||||
streamMode: ["messages", "values"] as const,
|
||||
recursionLimit: 1000
|
||||
}
|
||||
|
||||
if (decision.type === "approve") {
|
||||
// Resume execution by invoking with null (continues from checkpoint)
|
||||
const stream = await agent.stream(null, config)
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (abortController.signal.aborted) break
|
||||
|
||||
const [mode, data] = chunk as unknown as [string, unknown]
|
||||
window.webContents.send(channel, {
|
||||
type: 'stream',
|
||||
type: "stream",
|
||||
mode,
|
||||
data: JSON.parse(JSON.stringify(data))
|
||||
})
|
||||
}
|
||||
|
||||
if (!abortController.signal.aborted) {
|
||||
window.webContents.send(channel, { type: 'done' })
|
||||
window.webContents.send(channel, { type: "done" })
|
||||
}
|
||||
} catch (error) {
|
||||
const isAbortError =
|
||||
error instanceof Error &&
|
||||
(error.name === 'AbortError' ||
|
||||
error.message.includes('aborted') ||
|
||||
error.message.includes('Controller is already closed'))
|
||||
|
||||
if (!isAbortError) {
|
||||
console.error('[Agent] Resume error:', error)
|
||||
window.webContents.send(channel, {
|
||||
type: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
activeRuns.delete(threadId)
|
||||
} else if (decision.type === "reject") {
|
||||
// For reject, we need to send a Command with reject decision
|
||||
// For now, just send done - the agent will see no resumption happened
|
||||
window.webContents.send(channel, { type: "done" })
|
||||
}
|
||||
}
|
||||
)
|
||||
// edit case handled similarly to approve with modified args
|
||||
} catch (error) {
|
||||
const isAbortError =
|
||||
error instanceof Error &&
|
||||
(error.name === "AbortError" ||
|
||||
error.message.includes("aborted") ||
|
||||
error.message.includes("Controller is already closed"))
|
||||
|
||||
// Handle HITL interrupt response
|
||||
ipcMain.on(
|
||||
'agent:interrupt',
|
||||
async (event, { threadId, decision }: { threadId: string; decision: HITLDecision }) => {
|
||||
const channel = `agent:stream:${threadId}`
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
|
||||
if (!window) {
|
||||
console.error('[Agent] No window found for interrupt response')
|
||||
return
|
||||
}
|
||||
|
||||
// Get workspace path from thread metadata - REQUIRED
|
||||
const thread = getThread(threadId)
|
||||
const metadata = thread?.metadata ? JSON.parse(thread.metadata) : {}
|
||||
const workspacePath = metadata.workspacePath as string | undefined
|
||||
const modelId = metadata.model as string | undefined
|
||||
|
||||
if (!workspacePath) {
|
||||
if (!isAbortError) {
|
||||
console.error("[Agent] Interrupt error:", error)
|
||||
window.webContents.send(channel, {
|
||||
type: 'error',
|
||||
error: 'Workspace path is required'
|
||||
type: "error",
|
||||
error: error instanceof Error ? error.message : "Unknown error"
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Abort any existing stream before continuing
|
||||
const existingController = activeRuns.get(threadId)
|
||||
if (existingController) {
|
||||
existingController.abort()
|
||||
activeRuns.delete(threadId)
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
activeRuns.set(threadId, abortController)
|
||||
|
||||
try {
|
||||
const agent = await createAgentRuntime({ threadId, workspacePath, modelId })
|
||||
const config = {
|
||||
configurable: { thread_id: threadId },
|
||||
signal: abortController.signal,
|
||||
streamMode: ['messages', 'values'] as const,
|
||||
recursionLimit: 1000
|
||||
}
|
||||
|
||||
if (decision.type === 'approve') {
|
||||
// Resume execution by invoking with null (continues from checkpoint)
|
||||
const stream = await agent.stream(null, config)
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (abortController.signal.aborted) break
|
||||
|
||||
const [mode, data] = chunk as unknown as [string, unknown]
|
||||
window.webContents.send(channel, {
|
||||
type: 'stream',
|
||||
mode,
|
||||
data: JSON.parse(JSON.stringify(data))
|
||||
})
|
||||
}
|
||||
|
||||
if (!abortController.signal.aborted) {
|
||||
window.webContents.send(channel, { type: 'done' })
|
||||
}
|
||||
} else if (decision.type === 'reject') {
|
||||
// For reject, we need to send a Command with reject decision
|
||||
// For now, just send done - the agent will see no resumption happened
|
||||
window.webContents.send(channel, { type: 'done' })
|
||||
}
|
||||
// edit case handled similarly to approve with modified args
|
||||
} catch (error) {
|
||||
const isAbortError =
|
||||
error instanceof Error &&
|
||||
(error.name === 'AbortError' ||
|
||||
error.message.includes('aborted') ||
|
||||
error.message.includes('Controller is already closed'))
|
||||
|
||||
if (!isAbortError) {
|
||||
console.error('[Agent] Interrupt error:', error)
|
||||
window.webContents.send(channel, {
|
||||
type: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
activeRuns.delete(threadId)
|
||||
}
|
||||
} finally {
|
||||
activeRuns.delete(threadId)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// Handle cancellation
|
||||
ipcMain.handle('agent:cancel', async (_event, { threadId }: { threadId: string }) => {
|
||||
ipcMain.handle("agent:cancel", async (_event, { threadId }: AgentCancelParams) => {
|
||||
const controller = activeRuns.get(threadId)
|
||||
if (controller) {
|
||||
controller.abort()
|
||||
|
||||
+178
-174
@@ -1,205 +1,212 @@
|
||||
import { IpcMain, dialog, app } from 'electron'
|
||||
import Store from 'electron-store'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import type { ModelConfig, Provider } from '../types'
|
||||
import { startWatching, stopWatching } from '../services/workspace-watcher'
|
||||
import { getOpenworkDir, getApiKey, setApiKey, deleteApiKey, hasApiKey } from '../storage'
|
||||
import { IpcMain, dialog, app } from "electron"
|
||||
import Store from "electron-store"
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import type {
|
||||
ModelConfig,
|
||||
Provider,
|
||||
SetApiKeyParams,
|
||||
WorkspaceSetParams,
|
||||
WorkspaceLoadParams,
|
||||
WorkspaceFileParams
|
||||
} from "../types"
|
||||
import { startWatching, stopWatching } from "../services/workspace-watcher"
|
||||
import { getOpenworkDir, getApiKey, setApiKey, deleteApiKey, hasApiKey } from "../storage"
|
||||
|
||||
// Store for non-sensitive settings only (no encryption needed)
|
||||
const store = new Store({
|
||||
name: 'settings',
|
||||
name: "settings",
|
||||
cwd: getOpenworkDir()
|
||||
})
|
||||
|
||||
// Provider configurations
|
||||
const PROVIDERS: Omit<Provider, 'hasApiKey'>[] = [
|
||||
{ id: 'anthropic', name: 'Anthropic' },
|
||||
{ id: 'openai', name: 'OpenAI' },
|
||||
{ id: 'google', name: 'Google' }
|
||||
const PROVIDERS: Omit<Provider, "hasApiKey">[] = [
|
||||
{ id: "anthropic", name: "Anthropic" },
|
||||
{ id: "openai", name: "OpenAI" },
|
||||
{ id: "google", name: "Google" }
|
||||
]
|
||||
|
||||
// Available models configuration (updated Jan 2026)
|
||||
const AVAILABLE_MODELS: ModelConfig[] = [
|
||||
// Anthropic Claude 4.5 series (latest as of Jan 2026)
|
||||
{
|
||||
id: 'claude-opus-4-5-20251101',
|
||||
name: 'Claude Opus 4.5',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
description: 'Premium model with maximum intelligence',
|
||||
id: "claude-opus-4-5-20251101",
|
||||
name: "Claude Opus 4.5",
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5-20251101",
|
||||
description: "Premium model with maximum intelligence",
|
||||
available: true
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4-5-20250929',
|
||||
name: 'Claude Sonnet 4.5',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-5-20250929',
|
||||
description: 'Best balance of intelligence, speed, and cost for agents',
|
||||
id: "claude-sonnet-4-5-20250929",
|
||||
name: "Claude Sonnet 4.5",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5-20250929",
|
||||
description: "Best balance of intelligence, speed, and cost for agents",
|
||||
available: true
|
||||
},
|
||||
{
|
||||
id: 'claude-haiku-4-5-20251001',
|
||||
name: 'Claude Haiku 4.5',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
description: 'Fastest model with near-frontier intelligence',
|
||||
id: "claude-haiku-4-5-20251001",
|
||||
name: "Claude Haiku 4.5",
|
||||
provider: "anthropic",
|
||||
model: "claude-haiku-4-5-20251001",
|
||||
description: "Fastest model with near-frontier intelligence",
|
||||
available: true
|
||||
},
|
||||
// Anthropic Claude legacy models
|
||||
{
|
||||
id: 'claude-opus-4-1-20250805',
|
||||
name: 'Claude Opus 4.1',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-opus-4-1-20250805',
|
||||
description: 'Previous generation premium model with extended thinking',
|
||||
id: "claude-opus-4-1-20250805",
|
||||
name: "Claude Opus 4.1",
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-1-20250805",
|
||||
description: "Previous generation premium model with extended thinking",
|
||||
available: true
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4-20250514',
|
||||
name: 'Claude Sonnet 4',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
description: 'Fast and capable previous generation model',
|
||||
id: "claude-sonnet-4-20250514",
|
||||
name: "Claude Sonnet 4",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-20250514",
|
||||
description: "Fast and capable previous generation model",
|
||||
available: true
|
||||
},
|
||||
// OpenAI GPT-5 series (latest as of Jan 2026)
|
||||
{
|
||||
id: 'gpt-5.2',
|
||||
name: 'GPT-5.2',
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.2',
|
||||
description: 'Latest flagship with enhanced coding and agentic capabilities',
|
||||
id: "gpt-5.2",
|
||||
name: "GPT-5.2",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
description: "Latest flagship with enhanced coding and agentic capabilities",
|
||||
available: true
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.1',
|
||||
name: 'GPT-5.1',
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.1',
|
||||
description: 'Advanced reasoning and robust performance',
|
||||
id: "gpt-5.1",
|
||||
name: "GPT-5.1",
|
||||
provider: "openai",
|
||||
model: "gpt-5.1",
|
||||
description: "Advanced reasoning and robust performance",
|
||||
available: true
|
||||
},
|
||||
// OpenAI o-series reasoning models
|
||||
{
|
||||
id: 'o3',
|
||||
name: 'o3',
|
||||
provider: 'openai',
|
||||
model: 'o3',
|
||||
description: 'Advanced reasoning for complex problem-solving',
|
||||
id: "o3",
|
||||
name: "o3",
|
||||
provider: "openai",
|
||||
model: "o3",
|
||||
description: "Advanced reasoning for complex problem-solving",
|
||||
available: true
|
||||
},
|
||||
{
|
||||
id: 'o3-mini',
|
||||
name: 'o3 Mini',
|
||||
provider: 'openai',
|
||||
model: 'o3-mini',
|
||||
description: 'Cost-effective reasoning with faster response times',
|
||||
id: "o3-mini",
|
||||
name: "o3 Mini",
|
||||
provider: "openai",
|
||||
model: "o3-mini",
|
||||
description: "Cost-effective reasoning with faster response times",
|
||||
available: true
|
||||
},
|
||||
{
|
||||
id: 'o4-mini',
|
||||
name: 'o4 Mini',
|
||||
provider: 'openai',
|
||||
model: 'o4-mini',
|
||||
description: 'Fast, efficient reasoning model succeeding o3',
|
||||
id: "o4-mini",
|
||||
name: "o4 Mini",
|
||||
provider: "openai",
|
||||
model: "o4-mini",
|
||||
description: "Fast, efficient reasoning model succeeding o3",
|
||||
available: true
|
||||
},
|
||||
{
|
||||
id: 'o1',
|
||||
name: 'o1',
|
||||
provider: 'openai',
|
||||
model: 'o1',
|
||||
description: 'Premium reasoning for research, coding, math and science',
|
||||
id: "o1",
|
||||
name: "o1",
|
||||
provider: "openai",
|
||||
model: "o1",
|
||||
description: "Premium reasoning for research, coding, math and science",
|
||||
available: true
|
||||
},
|
||||
// OpenAI GPT-4 series
|
||||
{
|
||||
id: 'gpt-4.1',
|
||||
name: 'GPT-4.1',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4.1',
|
||||
description: 'Strong instruction-following with 1M context window',
|
||||
id: "gpt-4.1",
|
||||
name: "GPT-4.1",
|
||||
provider: "openai",
|
||||
model: "gpt-4.1",
|
||||
description: "Strong instruction-following with 1M context window",
|
||||
available: true
|
||||
},
|
||||
{
|
||||
id: 'gpt-4.1-mini',
|
||||
name: 'GPT-4.1 Mini',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4.1-mini',
|
||||
description: 'Faster, smaller version balancing performance and efficiency',
|
||||
id: "gpt-4.1-mini",
|
||||
name: "GPT-4.1 Mini",
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
description: "Faster, smaller version balancing performance and efficiency",
|
||||
available: true
|
||||
},
|
||||
{
|
||||
id: 'gpt-4.1-nano',
|
||||
name: 'GPT-4.1 Nano',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4.1-nano',
|
||||
description: 'Most cost-efficient for lighter tasks',
|
||||
id: "gpt-4.1-nano",
|
||||
name: "GPT-4.1 Nano",
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-nano",
|
||||
description: "Most cost-efficient for lighter tasks",
|
||||
available: true
|
||||
},
|
||||
{
|
||||
id: 'gpt-4o',
|
||||
name: 'GPT-4o',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4o',
|
||||
description: 'Versatile model for text generation and comprehension',
|
||||
id: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
provider: "openai",
|
||||
model: "gpt-4o",
|
||||
description: "Versatile model for text generation and comprehension",
|
||||
available: true
|
||||
},
|
||||
{
|
||||
id: 'gpt-4o-mini',
|
||||
name: 'GPT-4o Mini',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4o-mini',
|
||||
description: 'Cost-efficient variant with faster response times',
|
||||
id: "gpt-4o-mini",
|
||||
name: "GPT-4o Mini",
|
||||
provider: "openai",
|
||||
model: "gpt-4o-mini",
|
||||
description: "Cost-efficient variant with faster response times",
|
||||
available: true
|
||||
},
|
||||
// Google Gemini models
|
||||
{
|
||||
id: 'gemini-3-pro-preview',
|
||||
name: 'Gemini 3 Pro Preview',
|
||||
provider: 'google',
|
||||
model: 'gemini-3-pro-preview',
|
||||
description: 'State-of-the-art reasoning and multimodal understanding',
|
||||
id: "gemini-3-pro-preview",
|
||||
name: "Gemini 3 Pro Preview",
|
||||
provider: "google",
|
||||
model: "gemini-3-pro-preview",
|
||||
description: "State-of-the-art reasoning and multimodal understanding",
|
||||
available: true
|
||||
},
|
||||
{
|
||||
id: 'gemini-3-flash-preview',
|
||||
name: 'Gemini 3 Flash Preview',
|
||||
provider: 'google',
|
||||
model: 'gemini-3-flash-preview',
|
||||
description: 'Fast frontier-class model with low latency and cost',
|
||||
id: "gemini-3-flash-preview",
|
||||
name: "Gemini 3 Flash Preview",
|
||||
provider: "google",
|
||||
model: "gemini-3-flash-preview",
|
||||
description: "Fast frontier-class model with low latency and cost",
|
||||
available: true
|
||||
},
|
||||
{
|
||||
id: 'gemini-2.5-pro',
|
||||
name: 'Gemini 2.5 Pro',
|
||||
provider: 'google',
|
||||
model: 'gemini-2.5-pro',
|
||||
description: 'High-capability model for complex reasoning and coding',
|
||||
id: "gemini-2.5-pro",
|
||||
name: "Gemini 2.5 Pro",
|
||||
provider: "google",
|
||||
model: "gemini-2.5-pro",
|
||||
description: "High-capability model for complex reasoning and coding",
|
||||
available: true
|
||||
},
|
||||
{
|
||||
id: 'gemini-2.5-flash',
|
||||
name: 'Gemini 2.5 Flash',
|
||||
provider: 'google',
|
||||
model: 'gemini-2.5-flash',
|
||||
description: 'Lightning-fast with balance of intelligence and latency',
|
||||
id: "gemini-2.5-flash",
|
||||
name: "Gemini 2.5 Flash",
|
||||
provider: "google",
|
||||
model: "gemini-2.5-flash",
|
||||
description: "Lightning-fast with balance of intelligence and latency",
|
||||
available: true
|
||||
},
|
||||
{
|
||||
id: 'gemini-2.5-flash-lite',
|
||||
name: 'Gemini 2.5 Flash Lite',
|
||||
provider: 'google',
|
||||
model: 'gemini-2.5-flash-lite',
|
||||
description: 'Fast, low-cost, high-performance model',
|
||||
id: "gemini-2.5-flash-lite",
|
||||
name: "Gemini 2.5 Flash Lite",
|
||||
provider: "google",
|
||||
model: "gemini-2.5-flash-lite",
|
||||
description: "Fast, low-cost, high-performance model",
|
||||
available: true
|
||||
}
|
||||
]
|
||||
|
||||
export function registerModelHandlers(ipcMain: IpcMain): void {
|
||||
// List available models
|
||||
ipcMain.handle('models:list', async () => {
|
||||
ipcMain.handle("models:list", async () => {
|
||||
// Check which models have API keys configured
|
||||
return AVAILABLE_MODELS.map((model) => ({
|
||||
...model,
|
||||
@@ -208,35 +215,32 @@ export function registerModelHandlers(ipcMain: IpcMain): void {
|
||||
})
|
||||
|
||||
// Get default model
|
||||
ipcMain.handle('models:getDefault', async () => {
|
||||
return store.get('defaultModel', 'claude-sonnet-4-5-20250929') as string
|
||||
ipcMain.handle("models:getDefault", async () => {
|
||||
return store.get("defaultModel", "claude-sonnet-4-5-20250929") as string
|
||||
})
|
||||
|
||||
// Set default model
|
||||
ipcMain.handle('models:setDefault', async (_event, modelId: string) => {
|
||||
store.set('defaultModel', modelId)
|
||||
ipcMain.handle("models:setDefault", async (_event, modelId: string) => {
|
||||
store.set("defaultModel", modelId)
|
||||
})
|
||||
|
||||
// Set API key for a provider (stored in ~/.openwork/.env)
|
||||
ipcMain.handle(
|
||||
'models:setApiKey',
|
||||
async (_event, { provider, apiKey }: { provider: string; apiKey: string }) => {
|
||||
setApiKey(provider, apiKey)
|
||||
}
|
||||
)
|
||||
ipcMain.handle("models:setApiKey", async (_event, { provider, apiKey }: SetApiKeyParams) => {
|
||||
setApiKey(provider, apiKey)
|
||||
})
|
||||
|
||||
// Get API key for a provider (from ~/.openwork/.env or process.env)
|
||||
ipcMain.handle('models:getApiKey', async (_event, provider: string) => {
|
||||
ipcMain.handle("models:getApiKey", async (_event, provider: string) => {
|
||||
return getApiKey(provider) ?? null
|
||||
})
|
||||
|
||||
// Delete API key for a provider
|
||||
ipcMain.handle('models:deleteApiKey', async (_event, provider: string) => {
|
||||
ipcMain.handle("models:deleteApiKey", async (_event, provider: string) => {
|
||||
deleteApiKey(provider)
|
||||
})
|
||||
|
||||
// List providers with their API key status
|
||||
ipcMain.handle('models:listProviders', async () => {
|
||||
ipcMain.handle("models:listProviders", async () => {
|
||||
return PROVIDERS.map((provider) => ({
|
||||
...provider,
|
||||
hasApiKey: hasApiKey(provider.id)
|
||||
@@ -244,19 +248,19 @@ export function registerModelHandlers(ipcMain: IpcMain): void {
|
||||
})
|
||||
|
||||
// Sync version info
|
||||
ipcMain.on('app:version', (event) => {
|
||||
ipcMain.on("app:version", (event) => {
|
||||
event.returnValue = app.getVersion()
|
||||
})
|
||||
|
||||
// Get workspace path for a thread (from thread metadata)
|
||||
ipcMain.handle('workspace:get', async (_event, threadId?: string) => {
|
||||
ipcMain.handle("workspace:get", async (_event, threadId?: string) => {
|
||||
if (!threadId) {
|
||||
// Fallback to global setting for backwards compatibility
|
||||
return store.get('workspacePath', null) as string | null
|
||||
return store.get("workspacePath", null) as string | null
|
||||
}
|
||||
|
||||
// Get from thread metadata via threads:get
|
||||
const { getThread } = await import('../db')
|
||||
const { getThread } = await import("../db")
|
||||
const thread = getThread(threadId)
|
||||
if (!thread?.metadata) return null
|
||||
|
||||
@@ -266,19 +270,19 @@ export function registerModelHandlers(ipcMain: IpcMain): void {
|
||||
|
||||
// Set workspace path for a thread (stores in thread metadata)
|
||||
ipcMain.handle(
|
||||
'workspace:set',
|
||||
async (_event, { threadId, path: newPath }: { threadId?: string; path: string | null }) => {
|
||||
"workspace:set",
|
||||
async (_event, { threadId, path: newPath }: WorkspaceSetParams) => {
|
||||
if (!threadId) {
|
||||
// Fallback to global setting
|
||||
if (newPath) {
|
||||
store.set('workspacePath', newPath)
|
||||
store.set("workspacePath", newPath)
|
||||
} else {
|
||||
store.delete('workspacePath')
|
||||
store.delete("workspacePath")
|
||||
}
|
||||
return newPath
|
||||
}
|
||||
|
||||
const { getThread, updateThread } = await import('../db')
|
||||
const { getThread, updateThread } = await import("../db")
|
||||
const thread = getThread(threadId)
|
||||
if (!thread) return null
|
||||
|
||||
@@ -298,11 +302,11 @@ export function registerModelHandlers(ipcMain: IpcMain): void {
|
||||
)
|
||||
|
||||
// Select workspace folder via dialog (for a specific thread)
|
||||
ipcMain.handle('workspace:select', async (_event, threadId?: string) => {
|
||||
ipcMain.handle("workspace:select", async (_event, threadId?: string) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
title: 'Select Workspace Folder',
|
||||
message: 'Choose a folder for the agent to work in'
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
title: "Select Workspace Folder",
|
||||
message: "Choose a folder for the agent to work in"
|
||||
})
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
@@ -312,7 +316,7 @@ export function registerModelHandlers(ipcMain: IpcMain): void {
|
||||
const selectedPath = result.filePaths[0]
|
||||
|
||||
if (threadId) {
|
||||
const { getThread, updateThread } = await import('../db')
|
||||
const { getThread, updateThread } = await import("../db")
|
||||
const thread = getThread(threadId)
|
||||
if (thread) {
|
||||
const metadata = thread.metadata ? JSON.parse(thread.metadata) : {}
|
||||
@@ -324,15 +328,15 @@ export function registerModelHandlers(ipcMain: IpcMain): void {
|
||||
}
|
||||
} else {
|
||||
// Fallback to global
|
||||
store.set('workspacePath', selectedPath)
|
||||
store.set("workspacePath", selectedPath)
|
||||
}
|
||||
|
||||
return selectedPath
|
||||
})
|
||||
|
||||
// Load files from disk into the workspace view
|
||||
ipcMain.handle('workspace:loadFromDisk', async (_event, { threadId }: { threadId: string }) => {
|
||||
const { getThread } = await import('../db')
|
||||
ipcMain.handle("workspace:loadFromDisk", async (_event, { threadId }: WorkspaceLoadParams) => {
|
||||
const { getThread } = await import("../db")
|
||||
|
||||
// Get workspace path from thread metadata
|
||||
const thread = getThread(threadId)
|
||||
@@ -340,7 +344,7 @@ export function registerModelHandlers(ipcMain: IpcMain): void {
|
||||
const workspacePath = metadata.workspacePath as string | null
|
||||
|
||||
if (!workspacePath) {
|
||||
return { success: false, error: 'No workspace folder linked', files: [] }
|
||||
return { success: false, error: "No workspace folder linked", files: [] }
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -352,12 +356,12 @@ export function registerModelHandlers(ipcMain: IpcMain): void {
|
||||
}> = []
|
||||
|
||||
// Recursively read directory
|
||||
async function readDir(dirPath: string, relativePath: string = ''): Promise<void> {
|
||||
async function readDir(dirPath: string, relativePath: string = ""): Promise<void> {
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
// Skip hidden files and common non-project files
|
||||
if (entry.name.startsWith('.') || entry.name === 'node_modules') {
|
||||
if (entry.name.startsWith(".") || entry.name === "node_modules") {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -366,14 +370,14 @@ export function registerModelHandlers(ipcMain: IpcMain): void {
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push({
|
||||
path: '/' + relPath,
|
||||
path: "/" + relPath,
|
||||
is_dir: true
|
||||
})
|
||||
await readDir(fullPath, relPath)
|
||||
} else {
|
||||
const stat = await fs.stat(fullPath)
|
||||
files.push({
|
||||
path: '/' + relPath,
|
||||
path: "/" + relPath,
|
||||
is_dir: false,
|
||||
size: stat.size,
|
||||
modified_at: stat.mtime.toISOString()
|
||||
@@ -395,7 +399,7 @@ export function registerModelHandlers(ipcMain: IpcMain): void {
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : 'Unknown error',
|
||||
error: e instanceof Error ? e.message : "Unknown error",
|
||||
files: []
|
||||
}
|
||||
}
|
||||
@@ -403,9 +407,9 @@ export function registerModelHandlers(ipcMain: IpcMain): void {
|
||||
|
||||
// Read a single file's contents from disk
|
||||
ipcMain.handle(
|
||||
'workspace:readFile',
|
||||
async (_event, { threadId, filePath }: { threadId: string; filePath: string }) => {
|
||||
const { getThread } = await import('../db')
|
||||
"workspace:readFile",
|
||||
async (_event, { threadId, filePath }: WorkspaceFileParams) => {
|
||||
const { getThread } = await import("../db")
|
||||
|
||||
// Get workspace path from thread metadata
|
||||
const thread = getThread(threadId)
|
||||
@@ -415,30 +419,30 @@ export function registerModelHandlers(ipcMain: IpcMain): void {
|
||||
if (!workspacePath) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No workspace folder linked'
|
||||
error: "No workspace folder linked"
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert virtual path to full disk path
|
||||
const relativePath = filePath.startsWith('/') ? filePath.slice(1) : filePath
|
||||
const relativePath = filePath.startsWith("/") ? filePath.slice(1) : filePath
|
||||
const fullPath = path.join(workspacePath, relativePath)
|
||||
|
||||
// Security check: ensure the resolved path is within the workspace
|
||||
const resolvedPath = path.resolve(fullPath)
|
||||
const resolvedWorkspace = path.resolve(workspacePath)
|
||||
if (!resolvedPath.startsWith(resolvedWorkspace)) {
|
||||
return { success: false, error: 'Access denied: path outside workspace' }
|
||||
return { success: false, error: "Access denied: path outside workspace" }
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
const stat = await fs.stat(fullPath)
|
||||
if (stat.isDirectory()) {
|
||||
return { success: false, error: 'Cannot read directory as file' }
|
||||
return { success: false, error: "Cannot read directory as file" }
|
||||
}
|
||||
|
||||
// Read file contents
|
||||
const content = await fs.readFile(fullPath, 'utf-8')
|
||||
const content = await fs.readFile(fullPath, "utf-8")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -449,7 +453,7 @@ export function registerModelHandlers(ipcMain: IpcMain): void {
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : 'Unknown error'
|
||||
error: e instanceof Error ? e.message : "Unknown error"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -457,9 +461,9 @@ export function registerModelHandlers(ipcMain: IpcMain): void {
|
||||
|
||||
// Read a binary file (images, PDFs, etc.) and return as base64
|
||||
ipcMain.handle(
|
||||
'workspace:readBinaryFile',
|
||||
async (_event, { threadId, filePath }: { threadId: string; filePath: string }) => {
|
||||
const { getThread } = await import('../db')
|
||||
"workspace:readBinaryFile",
|
||||
async (_event, { threadId, filePath }: WorkspaceFileParams) => {
|
||||
const { getThread } = await import("../db")
|
||||
|
||||
// Get workspace path from thread metadata
|
||||
const thread = getThread(threadId)
|
||||
@@ -469,31 +473,31 @@ export function registerModelHandlers(ipcMain: IpcMain): void {
|
||||
if (!workspacePath) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No workspace folder linked'
|
||||
error: "No workspace folder linked"
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert virtual path to full disk path
|
||||
const relativePath = filePath.startsWith('/') ? filePath.slice(1) : filePath
|
||||
const relativePath = filePath.startsWith("/") ? filePath.slice(1) : filePath
|
||||
const fullPath = path.join(workspacePath, relativePath)
|
||||
|
||||
// Security check: ensure the resolved path is within the workspace
|
||||
const resolvedPath = path.resolve(fullPath)
|
||||
const resolvedWorkspace = path.resolve(workspacePath)
|
||||
if (!resolvedPath.startsWith(resolvedWorkspace)) {
|
||||
return { success: false, error: 'Access denied: path outside workspace' }
|
||||
return { success: false, error: "Access denied: path outside workspace" }
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
const stat = await fs.stat(fullPath)
|
||||
if (stat.isDirectory()) {
|
||||
return { success: false, error: 'Cannot read directory as file' }
|
||||
return { success: false, error: "Cannot read directory as file" }
|
||||
}
|
||||
|
||||
// Read file as binary and convert to base64
|
||||
const buffer = await fs.readFile(fullPath)
|
||||
const base64 = buffer.toString('base64')
|
||||
const base64 = buffer.toString("base64")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -504,7 +508,7 @@ export function registerModelHandlers(ipcMain: IpcMain): void {
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : 'Unknown error'
|
||||
error: e instanceof Error ? e.message : "Unknown error"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -512,8 +516,8 @@ export function registerModelHandlers(ipcMain: IpcMain): void {
|
||||
}
|
||||
|
||||
// Re-export getApiKey from storage for use in agent runtime
|
||||
export { getApiKey } from '../storage'
|
||||
export { getApiKey } from "../storage"
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return store.get('defaultModel', 'claude-sonnet-4-5-20250929') as string
|
||||
return store.get("defaultModel", "claude-sonnet-4-5-20250929") as string
|
||||
}
|
||||
|
||||
+41
-44
@@ -1,34 +1,34 @@
|
||||
import { IpcMain } from 'electron'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
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, closeCheckpointer } from '../agent/runtime'
|
||||
import { deleteThreadCheckpoint } from '../storage'
|
||||
import { generateTitle } from '../services/title-generator'
|
||||
import type { Thread } from '../types'
|
||||
} from "../db"
|
||||
import { getCheckpointer, closeCheckpointer } from "../agent/runtime"
|
||||
import { deleteThreadCheckpoint } from "../storage"
|
||||
import { generateTitle } from "../services/title-generator"
|
||||
import type { Thread, ThreadUpdateParams } from "../types"
|
||||
|
||||
export function registerThreadHandlers(ipcMain: IpcMain): void {
|
||||
// List all threads
|
||||
ipcMain.handle('threads:list', async () => {
|
||||
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'],
|
||||
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) => {
|
||||
ipcMain.handle("threads:get", async (_event, threadId: string) => {
|
||||
const row = getThread(threadId)
|
||||
if (!row) return null
|
||||
return {
|
||||
@@ -36,14 +36,14 @@ export function registerThreadHandlers(ipcMain: IpcMain): void {
|
||||
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'],
|
||||
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>) => {
|
||||
ipcMain.handle("threads:create", async (_event, metadata?: Record<string, unknown>) => {
|
||||
const threadId = uuid()
|
||||
const title = (metadata?.title as string) || `Thread ${new Date().toLocaleDateString()}`
|
||||
|
||||
@@ -54,66 +54,63 @@ export function registerThreadHandlers(ipcMain: IpcMain): void {
|
||||
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'],
|
||||
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] = {}
|
||||
ipcMain.handle("threads:update", async (_event, { threadId, updates }: ThreadUpdateParams) => {
|
||||
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)
|
||||
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')
|
||||
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
|
||||
}
|
||||
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)
|
||||
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')
|
||||
console.log("[Threads] Deleted from metadata store")
|
||||
|
||||
// Close any open checkpointer for this thread
|
||||
try {
|
||||
await closeCheckpointer(threadId)
|
||||
console.log('[Threads] Closed checkpointer')
|
||||
console.log("[Threads] Closed checkpointer")
|
||||
} catch (e) {
|
||||
console.warn('[Threads] Failed to close checkpointer:', e)
|
||||
console.warn("[Threads] Failed to close checkpointer:", e)
|
||||
}
|
||||
|
||||
// Delete the thread's checkpoint file
|
||||
try {
|
||||
deleteThreadCheckpoint(threadId)
|
||||
console.log('[Threads] Deleted checkpoint file')
|
||||
console.log("[Threads] Deleted checkpoint file")
|
||||
} catch (e) {
|
||||
console.warn('[Threads] Failed to delete checkpoint file:', e)
|
||||
console.warn("[Threads] Failed to delete checkpoint file:", e)
|
||||
}
|
||||
})
|
||||
|
||||
// Get thread history (checkpoints)
|
||||
ipcMain.handle('threads:history', async (_event, threadId: string) => {
|
||||
ipcMain.handle("threads:history", async (_event, threadId: string) => {
|
||||
try {
|
||||
const checkpointer = await getCheckpointer(threadId)
|
||||
|
||||
@@ -126,13 +123,13 @@ export function registerThreadHandlers(ipcMain: IpcMain): void {
|
||||
|
||||
return history
|
||||
} catch (e) {
|
||||
console.warn('Failed to get thread history:', e)
|
||||
console.warn("Failed to get thread history:", e)
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
// Generate a title from a message
|
||||
ipcMain.handle('threads:generateTitle', async (_event, message: string) => {
|
||||
ipcMain.handle("threads:generateTitle", async (_event, message: string) => {
|
||||
return generateTitle(message)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
/**
|
||||
* 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, ' ')
|
||||
|
||||
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 = ''
|
||||
|
||||
let title = ""
|
||||
|
||||
for (const word of words) {
|
||||
if ((title + ' ' + word).length > 47) {
|
||||
if ((title + " " + word).length > 47) {
|
||||
break
|
||||
}
|
||||
title = title ? title + ' ' + word : word
|
||||
title = title ? title + " " + word : word
|
||||
}
|
||||
|
||||
|
||||
// Add ellipsis if we truncated
|
||||
if (words.join(' ').length > title.length) {
|
||||
title += '...'
|
||||
if (words.join(" ").length > title.length) {
|
||||
title += "..."
|
||||
}
|
||||
|
||||
|
||||
return title
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { BrowserWindow } from 'electron'
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import { BrowserWindow } from "electron"
|
||||
|
||||
// Store active watchers by thread ID
|
||||
const activeWatchers = new Map<string, fs.FSWatcher>()
|
||||
@@ -36,7 +36,7 @@ export function startWatching(threadId: string, workspacePath: string): void {
|
||||
// Skip hidden files and common non-project files
|
||||
if (filename) {
|
||||
const parts = filename.split(path.sep)
|
||||
if (parts.some((p) => p.startsWith('.') || p === 'node_modules')) {
|
||||
if (parts.some((p) => p.startsWith(".") || p === "node_modules")) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export function startWatching(threadId: string, workspacePath: string): void {
|
||||
debounceTimers.set(threadId, timer)
|
||||
})
|
||||
|
||||
watcher.on('error', (error) => {
|
||||
watcher.on("error", (error) => {
|
||||
console.error(`[WorkspaceWatcher] Error watching ${workspacePath}:`, error)
|
||||
stopWatching(threadId)
|
||||
})
|
||||
@@ -103,7 +103,7 @@ function notifyRenderer(threadId: string, workspacePath: string): void {
|
||||
const windows = BrowserWindow.getAllWindows()
|
||||
|
||||
for (const win of windows) {
|
||||
win.webContents.send('workspace:files-changed', {
|
||||
win.webContents.send("workspace:files-changed", {
|
||||
threadId,
|
||||
workspacePath
|
||||
})
|
||||
|
||||
+19
-17
@@ -1,15 +1,17 @@
|
||||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs'
|
||||
import { homedir } from "os"
|
||||
import { join } from "path"
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs"
|
||||
import type { ProviderId } from "./types"
|
||||
|
||||
const OPENWORK_DIR = join(homedir(), '.openwork')
|
||||
const ENV_FILE = join(OPENWORK_DIR, '.env')
|
||||
const OPENWORK_DIR = join(homedir(), ".openwork")
|
||||
const ENV_FILE = join(OPENWORK_DIR, ".env")
|
||||
|
||||
// Environment variable names for each provider
|
||||
const ENV_VAR_NAMES: Record<string, string> = {
|
||||
anthropic: 'ANTHROPIC_API_KEY',
|
||||
openai: 'OPENAI_API_KEY',
|
||||
google: 'GOOGLE_API_KEY'
|
||||
const ENV_VAR_NAMES: Record<ProviderId, string> = {
|
||||
anthropic: "ANTHROPIC_API_KEY",
|
||||
openai: "OPENAI_API_KEY",
|
||||
google: "GOOGLE_API_KEY",
|
||||
ollama: "" // Ollama doesn't require an API key
|
||||
}
|
||||
|
||||
export function getOpenworkDir(): string {
|
||||
@@ -20,15 +22,15 @@ export function getOpenworkDir(): string {
|
||||
}
|
||||
|
||||
export function getDbPath(): string {
|
||||
return join(getOpenworkDir(), 'openwork.sqlite')
|
||||
return join(getOpenworkDir(), "openwork.sqlite")
|
||||
}
|
||||
|
||||
export function getCheckpointDbPath(): string {
|
||||
return join(getOpenworkDir(), 'langgraph.sqlite')
|
||||
return join(getOpenworkDir(), "langgraph.sqlite")
|
||||
}
|
||||
|
||||
export function getThreadCheckpointDir(): string {
|
||||
const dir = join(getOpenworkDir(), 'threads')
|
||||
const dir = join(getOpenworkDir(), "threads")
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
@@ -55,13 +57,13 @@ function parseEnvFile(): Record<string, string> {
|
||||
const envPath = getEnvFilePath()
|
||||
if (!existsSync(envPath)) return {}
|
||||
|
||||
const content = readFileSync(envPath, 'utf-8')
|
||||
const content = readFileSync(envPath, "utf-8")
|
||||
const result: Record<string, string> = {}
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
for (const line of content.split("\n")) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
const eqIndex = trimmed.indexOf('=')
|
||||
if (!trimmed || trimmed.startsWith("#")) continue
|
||||
const eqIndex = trimmed.indexOf("=")
|
||||
if (eqIndex > 0) {
|
||||
const key = trimmed.slice(0, eqIndex).trim()
|
||||
const value = trimmed.slice(eqIndex + 1).trim()
|
||||
@@ -77,7 +79,7 @@ function writeEnvFile(env: Record<string, string>): void {
|
||||
const lines = Object.entries(env)
|
||||
.filter((entry) => entry[1])
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
writeFileSync(getEnvFilePath(), lines.join('\n') + '\n')
|
||||
writeFileSync(getEnvFilePath(), lines.join("\n") + "\n")
|
||||
}
|
||||
|
||||
// API key management
|
||||
|
||||
+74
-19
@@ -1,5 +1,60 @@
|
||||
// Thread types matching langgraph-api
|
||||
export type ThreadStatus = 'idle' | 'busy' | 'interrupted' | 'error'
|
||||
export type ThreadStatus = "idle" | "busy" | "interrupted" | "error"
|
||||
|
||||
// =============================================================================
|
||||
// IPC Handler Parameter Types
|
||||
// =============================================================================
|
||||
|
||||
// Agent IPC
|
||||
export interface AgentInvokeParams {
|
||||
threadId: string
|
||||
message: string
|
||||
modelId?: string
|
||||
}
|
||||
|
||||
export interface AgentResumeParams {
|
||||
threadId: string
|
||||
command: { resume?: { decision?: string } }
|
||||
modelId?: string
|
||||
}
|
||||
|
||||
export interface AgentInterruptParams {
|
||||
threadId: string
|
||||
decision: HITLDecision
|
||||
}
|
||||
|
||||
export interface AgentCancelParams {
|
||||
threadId: string
|
||||
}
|
||||
|
||||
// Thread IPC
|
||||
export interface ThreadUpdateParams {
|
||||
threadId: string
|
||||
updates: Partial<Thread>
|
||||
}
|
||||
|
||||
// Workspace IPC
|
||||
export interface WorkspaceSetParams {
|
||||
threadId?: string
|
||||
path: string | null
|
||||
}
|
||||
|
||||
export interface WorkspaceLoadParams {
|
||||
threadId: string
|
||||
}
|
||||
|
||||
export interface WorkspaceFileParams {
|
||||
threadId: string
|
||||
filePath: string
|
||||
}
|
||||
|
||||
// Model IPC
|
||||
export interface SetApiKeyParams {
|
||||
provider: string
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
export interface Thread {
|
||||
thread_id: string
|
||||
@@ -12,7 +67,7 @@ export interface Thread {
|
||||
}
|
||||
|
||||
// Run types
|
||||
export type RunStatus = 'pending' | 'running' | 'error' | 'success' | 'interrupted'
|
||||
export type RunStatus = "pending" | "running" | "error" | "success" | "interrupted"
|
||||
|
||||
export interface Run {
|
||||
run_id: string
|
||||
@@ -25,7 +80,7 @@ export interface Run {
|
||||
}
|
||||
|
||||
// Provider configuration
|
||||
export type ProviderId = 'anthropic' | 'openai' | 'google' | 'ollama'
|
||||
export type ProviderId = "anthropic" | "openai" | "google" | "ollama"
|
||||
|
||||
export interface Provider {
|
||||
id: ProviderId
|
||||
@@ -48,34 +103,34 @@ export interface Subagent {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
status: 'pending' | 'running' | 'completed' | 'failed'
|
||||
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 }
|
||||
| { 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'
|
||||
role: "user" | "assistant" | "system" | "tool"
|
||||
content: string | ContentBlock[]
|
||||
tool_calls?: ToolCall[]
|
||||
created_at: Date
|
||||
}
|
||||
|
||||
export interface ContentBlock {
|
||||
type: 'text' | 'image' | 'tool_use' | 'tool_result'
|
||||
type: "text" | "image" | "tool_use" | "tool_result"
|
||||
text?: string
|
||||
tool_use_id?: string
|
||||
name?: string
|
||||
@@ -99,11 +154,11 @@ export interface ToolResult {
|
||||
export interface HITLRequest {
|
||||
id: string
|
||||
tool_call: ToolCall
|
||||
allowed_decisions: HITLDecision['type'][]
|
||||
allowed_decisions: HITLDecision["type"][]
|
||||
}
|
||||
|
||||
export interface HITLDecision {
|
||||
type: 'approve' | 'reject' | 'edit'
|
||||
type: "approve" | "reject" | "edit"
|
||||
tool_call_id: string
|
||||
edited_args?: Record<string, unknown>
|
||||
feedback?: string
|
||||
@@ -113,7 +168,7 @@ export interface HITLDecision {
|
||||
export interface Todo {
|
||||
id: string
|
||||
content: string
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'cancelled'
|
||||
status: "pending" | "in_progress" | "completed" | "cancelled"
|
||||
}
|
||||
|
||||
// File types (from deepagentsjs backends)
|
||||
|
||||
Vendored
+9
-3
@@ -1,4 +1,4 @@
|
||||
import type { Thread, ModelConfig, Provider, StreamEvent, HITLDecision } from '../main/types'
|
||||
import type { Thread, ModelConfig, Provider, StreamEvent, HITLDecision } from "../main/types"
|
||||
|
||||
interface ElectronAPI {
|
||||
ipcRenderer: {
|
||||
@@ -68,14 +68,20 @@ interface CustomAPI {
|
||||
workspacePath?: string
|
||||
error?: string
|
||||
}>
|
||||
readFile: (threadId: string, filePath: string) => Promise<{
|
||||
readFile: (
|
||||
threadId: string,
|
||||
filePath: string
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
content?: string
|
||||
size?: number
|
||||
modified_at?: string
|
||||
error?: string
|
||||
}>
|
||||
readBinaryFile: (threadId: string, filePath: string) => Promise<{
|
||||
readBinaryFile: (
|
||||
threadId: string,
|
||||
filePath: string
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
content?: string
|
||||
size?: number
|
||||
|
||||
+45
-37
@@ -1,5 +1,5 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import type { Thread, ModelConfig, Provider, StreamEvent, HITLDecision } from '../main/types'
|
||||
import { contextBridge, ipcRenderer } from "electron"
|
||||
import type { Thread, ModelConfig, Provider, StreamEvent, HITLDecision } from "../main/types"
|
||||
|
||||
// Simple electron API - replaces @electron-toolkit/preload
|
||||
const electronAPI = {
|
||||
@@ -34,13 +34,13 @@ const api = {
|
||||
|
||||
const handler = (_: unknown, data: StreamEvent): void => {
|
||||
onEvent(data)
|
||||
if (data.type === 'done' || data.type === 'error') {
|
||||
if (data.type === "done" || data.type === "error") {
|
||||
ipcRenderer.removeListener(channel, handler)
|
||||
}
|
||||
}
|
||||
|
||||
ipcRenderer.on(channel, handler)
|
||||
ipcRenderer.send('agent:invoke', { threadId, message, modelId })
|
||||
ipcRenderer.send("agent:invoke", { threadId, message, modelId })
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
@@ -59,7 +59,7 @@ const api = {
|
||||
|
||||
const handler = (_: unknown, data: StreamEvent): void => {
|
||||
onEvent(data)
|
||||
if (data.type === 'done' || data.type === 'error') {
|
||||
if (data.type === "done" || data.type === "error") {
|
||||
ipcRenderer.removeListener(channel, handler)
|
||||
}
|
||||
}
|
||||
@@ -68,9 +68,9 @@ const api = {
|
||||
|
||||
// If we have a command, it might be a resume/retry
|
||||
if (command) {
|
||||
ipcRenderer.send('agent:resume', { threadId, command, modelId })
|
||||
ipcRenderer.send("agent:resume", { threadId, command, modelId })
|
||||
} else {
|
||||
ipcRenderer.send('agent:invoke', { threadId, message, modelId })
|
||||
ipcRenderer.send("agent:invoke", { threadId, message, modelId })
|
||||
}
|
||||
|
||||
// Return cleanup function
|
||||
@@ -87,13 +87,13 @@ const api = {
|
||||
|
||||
const handler = (_: unknown, data: StreamEvent): void => {
|
||||
onEvent?.(data)
|
||||
if (data.type === 'done' || data.type === 'error') {
|
||||
if (data.type === "done" || data.type === "error") {
|
||||
ipcRenderer.removeListener(channel, handler)
|
||||
}
|
||||
}
|
||||
|
||||
ipcRenderer.on(channel, handler)
|
||||
ipcRenderer.send('agent:interrupt', { threadId, decision })
|
||||
ipcRenderer.send("agent:interrupt", { threadId, decision })
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
@@ -101,66 +101,68 @@ const api = {
|
||||
}
|
||||
},
|
||||
cancel: (threadId: string): Promise<void> => {
|
||||
return ipcRenderer.invoke('agent:cancel', { threadId })
|
||||
return ipcRenderer.invoke("agent:cancel", { threadId })
|
||||
}
|
||||
},
|
||||
threads: {
|
||||
list: (): Promise<Thread[]> => {
|
||||
return ipcRenderer.invoke('threads:list')
|
||||
return ipcRenderer.invoke("threads:list")
|
||||
},
|
||||
get: (threadId: string): Promise<Thread | null> => {
|
||||
return ipcRenderer.invoke('threads:get', threadId)
|
||||
return ipcRenderer.invoke("threads:get", threadId)
|
||||
},
|
||||
create: (metadata?: Record<string, unknown>): Promise<Thread> => {
|
||||
return ipcRenderer.invoke('threads:create', metadata)
|
||||
return ipcRenderer.invoke("threads:create", metadata)
|
||||
},
|
||||
update: (threadId: string, updates: Partial<Thread>): Promise<Thread> => {
|
||||
return ipcRenderer.invoke('threads:update', { threadId, updates })
|
||||
return ipcRenderer.invoke("threads:update", { threadId, updates })
|
||||
},
|
||||
delete: (threadId: string): Promise<void> => {
|
||||
return ipcRenderer.invoke('threads:delete', threadId)
|
||||
return ipcRenderer.invoke("threads:delete", threadId)
|
||||
},
|
||||
getHistory: (threadId: string): Promise<unknown[]> => {
|
||||
return ipcRenderer.invoke('threads:history', threadId)
|
||||
return ipcRenderer.invoke("threads:history", threadId)
|
||||
},
|
||||
generateTitle: (message: string): Promise<string> => {
|
||||
return ipcRenderer.invoke('threads:generateTitle', message)
|
||||
return ipcRenderer.invoke("threads:generateTitle", message)
|
||||
}
|
||||
},
|
||||
models: {
|
||||
list: (): Promise<ModelConfig[]> => {
|
||||
return ipcRenderer.invoke('models:list')
|
||||
return ipcRenderer.invoke("models:list")
|
||||
},
|
||||
listProviders: (): Promise<Provider[]> => {
|
||||
return ipcRenderer.invoke('models:listProviders')
|
||||
return ipcRenderer.invoke("models:listProviders")
|
||||
},
|
||||
getDefault: (): Promise<string> => {
|
||||
return ipcRenderer.invoke('models:getDefault')
|
||||
return ipcRenderer.invoke("models:getDefault")
|
||||
},
|
||||
setDefault: (modelId: string): Promise<void> => {
|
||||
return ipcRenderer.invoke('models:setDefault', modelId)
|
||||
return ipcRenderer.invoke("models:setDefault", modelId)
|
||||
},
|
||||
setApiKey: (provider: string, apiKey: string): Promise<void> => {
|
||||
return ipcRenderer.invoke('models:setApiKey', { provider, apiKey })
|
||||
return ipcRenderer.invoke("models:setApiKey", { provider, apiKey })
|
||||
},
|
||||
getApiKey: (provider: string): Promise<string | null> => {
|
||||
return ipcRenderer.invoke('models:getApiKey', provider)
|
||||
return ipcRenderer.invoke("models:getApiKey", provider)
|
||||
},
|
||||
deleteApiKey: (provider: string): Promise<void> => {
|
||||
return ipcRenderer.invoke('models:deleteApiKey', provider)
|
||||
return ipcRenderer.invoke("models:deleteApiKey", provider)
|
||||
}
|
||||
},
|
||||
workspace: {
|
||||
get: (threadId?: string): Promise<string | null> => {
|
||||
return ipcRenderer.invoke('workspace:get', threadId)
|
||||
return ipcRenderer.invoke("workspace:get", threadId)
|
||||
},
|
||||
set: (threadId: string | undefined, path: string | null): Promise<string | null> => {
|
||||
return ipcRenderer.invoke('workspace:set', { threadId, path })
|
||||
return ipcRenderer.invoke("workspace:set", { threadId, path })
|
||||
},
|
||||
select: (threadId?: string): Promise<string | null> => {
|
||||
return ipcRenderer.invoke('workspace:select', threadId)
|
||||
return ipcRenderer.invoke("workspace:select", threadId)
|
||||
},
|
||||
loadFromDisk: (threadId: string): Promise<{
|
||||
loadFromDisk: (
|
||||
threadId: string
|
||||
): Promise<{
|
||||
success: boolean
|
||||
files: Array<{
|
||||
path: string
|
||||
@@ -171,25 +173,31 @@ const api = {
|
||||
workspacePath?: string
|
||||
error?: string
|
||||
}> => {
|
||||
return ipcRenderer.invoke('workspace:loadFromDisk', { threadId })
|
||||
return ipcRenderer.invoke("workspace:loadFromDisk", { threadId })
|
||||
},
|
||||
readFile: (threadId: string, filePath: string): Promise<{
|
||||
readFile: (
|
||||
threadId: string,
|
||||
filePath: string
|
||||
): Promise<{
|
||||
success: boolean
|
||||
content?: string
|
||||
size?: number
|
||||
modified_at?: string
|
||||
error?: string
|
||||
}> => {
|
||||
return ipcRenderer.invoke('workspace:readFile', { threadId, filePath })
|
||||
return ipcRenderer.invoke("workspace:readFile", { threadId, filePath })
|
||||
},
|
||||
readBinaryFile: (threadId: string, filePath: string): Promise<{
|
||||
readBinaryFile: (
|
||||
threadId: string,
|
||||
filePath: string
|
||||
): Promise<{
|
||||
success: boolean
|
||||
content?: string
|
||||
size?: number
|
||||
modified_at?: string
|
||||
error?: string
|
||||
}> => {
|
||||
return ipcRenderer.invoke('workspace:readBinaryFile', { threadId, filePath })
|
||||
return ipcRenderer.invoke("workspace:readBinaryFile", { threadId, filePath })
|
||||
},
|
||||
// Listen for file changes in the workspace
|
||||
onFilesChanged: (
|
||||
@@ -198,10 +206,10 @@ const api = {
|
||||
const handler = (_: unknown, data: { threadId: string; workspacePath: string }): void => {
|
||||
callback(data)
|
||||
}
|
||||
ipcRenderer.on('workspace:files-changed', handler)
|
||||
ipcRenderer.on("workspace:files-changed", handler)
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
ipcRenderer.removeListener('workspace:files-changed', handler)
|
||||
ipcRenderer.removeListener("workspace:files-changed", handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,8 +218,8 @@ const api = {
|
||||
// Use `contextBridge` APIs to expose Electron APIs to renderer
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||
contextBridge.exposeInMainWorld('api', api)
|
||||
contextBridge.exposeInMainWorld("electron", electronAPI)
|
||||
contextBridge.exposeInMainWorld("api", api)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
+66
-66
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState, useCallback, useRef, useLayoutEffect } from 'react'
|
||||
import { ThreadSidebar } from '@/components/sidebar/ThreadSidebar'
|
||||
import { TabbedPanel, TabBar } from '@/components/tabs'
|
||||
import { RightPanel } from '@/components/panels/RightPanel'
|
||||
import { ResizeHandle } from '@/components/ui/resizable'
|
||||
import { useAppStore } from '@/lib/store'
|
||||
import { ThreadProvider } from '@/lib/thread-context'
|
||||
import { useEffect, useState, useCallback, useRef, useLayoutEffect } from "react"
|
||||
import { ThreadSidebar } from "@/components/sidebar/ThreadSidebar"
|
||||
import { TabbedPanel, TabBar } from "@/components/tabs"
|
||||
import { RightPanel } from "@/components/panels/RightPanel"
|
||||
import { ResizeHandle } from "@/components/ui/resizable"
|
||||
import { useAppStore } from "@/lib/store"
|
||||
import { ThreadProvider } from "@/lib/thread-context"
|
||||
|
||||
// Badge requires ~235 screen pixels to display with comfortable margin
|
||||
const BADGE_MIN_SCREEN_WIDTH = 235
|
||||
@@ -42,13 +42,13 @@ function App(): React.JSX.Element {
|
||||
const extraPaddingScreen = Math.max(0, TRAFFIC_LIGHT_BOTTOM_SCREEN - titlebarScreenHeight)
|
||||
const extraPaddingCss = Math.round(extraPaddingScreen / detectedZoom)
|
||||
|
||||
document.documentElement.style.setProperty('--sidebar-safe-padding', `${extraPaddingCss}px`)
|
||||
document.documentElement.style.setProperty("--sidebar-safe-padding", `${extraPaddingCss}px`)
|
||||
}
|
||||
}
|
||||
|
||||
updateZoom()
|
||||
window.addEventListener('resize', updateZoom)
|
||||
return () => window.removeEventListener('resize', updateZoom)
|
||||
window.addEventListener("resize", updateZoom)
|
||||
return () => window.removeEventListener("resize", updateZoom)
|
||||
}, [])
|
||||
|
||||
// Calculate zoom-compensated minimum width to always contain the badge
|
||||
@@ -88,8 +88,8 @@ function App(): React.JSX.Element {
|
||||
const handleMouseUp = (): void => {
|
||||
dragStartWidths.current = null
|
||||
}
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
return () => document.removeEventListener('mouseup', handleMouseUp)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
return () => document.removeEventListener("mouseup", handleMouseUp)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -102,7 +102,7 @@ function App(): React.JSX.Element {
|
||||
await createThread()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize:', error)
|
||||
console.error("Failed to initialize:", error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -120,68 +120,68 @@ function App(): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<ThreadProvider>
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{/* Fixed app badge - zoom independent position and size */}
|
||||
<div
|
||||
className="app-badge"
|
||||
style={{
|
||||
// Compensate both position and scale for zoom
|
||||
// Target screen position: top 14px, left 82px (just past traffic lights)
|
||||
top: `${14 / zoomLevel}px`,
|
||||
left: `${82 / zoomLevel}px`,
|
||||
transform: `scale(${1 / zoomLevel})`,
|
||||
transformOrigin: 'top left'
|
||||
}}
|
||||
>
|
||||
<span className="app-badge-name">OPENWORK</span>
|
||||
<span className="app-badge-version">{__APP_VERSION__}</span>
|
||||
</div>
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{/* Fixed app badge - zoom independent position and size */}
|
||||
<div
|
||||
className="app-badge"
|
||||
style={{
|
||||
// Compensate both position and scale for zoom
|
||||
// Target screen position: top 14px, left 82px (just past traffic lights)
|
||||
top: `${14 / zoomLevel}px`,
|
||||
left: `${82 / zoomLevel}px`,
|
||||
transform: `scale(${1 / zoomLevel})`,
|
||||
transformOrigin: "top left"
|
||||
}}
|
||||
>
|
||||
<span className="app-badge-name">OPENWORK</span>
|
||||
<span className="app-badge-version">{__APP_VERSION__}</span>
|
||||
</div>
|
||||
|
||||
{/* Left + Center column */}
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
{/* Titlebar row with tabs integrated */}
|
||||
<div className="flex h-9 w-full shrink-0 app-drag-region bg-sidebar">
|
||||
{/* Left section - spacer for traffic lights + badge (matches left sidebar width) */}
|
||||
<div style={{ width: leftWidth }} className="shrink-0" />
|
||||
{/* Left + Center column */}
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
{/* Titlebar row with tabs integrated */}
|
||||
<div className="flex h-9 w-full shrink-0 app-drag-region bg-sidebar">
|
||||
{/* Left section - spacer for traffic lights + badge (matches left sidebar width) */}
|
||||
<div style={{ width: leftWidth }} className="shrink-0" />
|
||||
|
||||
{/* Resize handle spacer */}
|
||||
<div className="w-[1px] shrink-0" />
|
||||
{/* Resize handle spacer */}
|
||||
<div className="w-[1px] shrink-0" />
|
||||
|
||||
{/* Center section - Tab bar */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{currentThreadId && <TabBar className="h-full border-b-0" />}
|
||||
{/* Center section - Tab bar */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{currentThreadId && <TabBar className="h-full border-b-0" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left Sidebar - Thread List */}
|
||||
<div style={{ width: leftWidth }} className="shrink-0">
|
||||
<ThreadSidebar />
|
||||
</div>
|
||||
|
||||
<ResizeHandle onDrag={handleLeftResize} />
|
||||
|
||||
{/* Center - Content Panel (Agent Chat + File Viewer) */}
|
||||
<main className="flex flex-1 flex-col min-w-0 overflow-hidden">
|
||||
{currentThreadId ? (
|
||||
<TabbedPanel threadId={currentThreadId} showTabBar={false} />
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center text-muted-foreground">
|
||||
Select or create a thread to begin
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left Sidebar - Thread List */}
|
||||
<div style={{ width: leftWidth }} className="shrink-0">
|
||||
<ThreadSidebar />
|
||||
</div>
|
||||
<ResizeHandle onDrag={handleRightResize} />
|
||||
|
||||
<ResizeHandle onDrag={handleLeftResize} />
|
||||
|
||||
{/* Center - Content Panel (Agent Chat + File Viewer) */}
|
||||
<main className="flex flex-1 flex-col min-w-0 overflow-hidden">
|
||||
{currentThreadId ? (
|
||||
<TabbedPanel threadId={currentThreadId} showTabBar={false} />
|
||||
) : (
|
||||
<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 (full height) */}
|
||||
<div style={{ width: rightWidth }} className="shrink-0">
|
||||
<RightPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ResizeHandle onDrag={handleRightResize} />
|
||||
|
||||
{/* Right Panel - Status Panels (full height) */}
|
||||
<div style={{ width: rightWidth }} className="shrink-0">
|
||||
<RightPanel />
|
||||
</div>
|
||||
</div>
|
||||
</ThreadProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Eye, EyeOff, Loader2, Trash2 } from 'lucide-react'
|
||||
import { useState, useEffect } from "react"
|
||||
import { Eye, EyeOff, Loader2, Trash2 } 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 { useAppStore } from '@/lib/store'
|
||||
import type { Provider } from '@/types'
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useAppStore } from "@/lib/store"
|
||||
import type { Provider } from "@/types"
|
||||
|
||||
interface ApiKeyDialogProps {
|
||||
open: boolean
|
||||
@@ -19,13 +19,17 @@ interface ApiKeyDialogProps {
|
||||
}
|
||||
|
||||
const PROVIDER_INFO: Record<string, { placeholder: string; envVar: string }> = {
|
||||
anthropic: { placeholder: 'sk-ant-...', envVar: 'ANTHROPIC_API_KEY' },
|
||||
openai: { placeholder: 'sk-...', envVar: 'OPENAI_API_KEY' },
|
||||
google: { placeholder: 'AIza...', envVar: 'GOOGLE_API_KEY' }
|
||||
anthropic: { placeholder: "sk-ant-...", envVar: "ANTHROPIC_API_KEY" },
|
||||
openai: { placeholder: "sk-...", envVar: "OPENAI_API_KEY" },
|
||||
google: { placeholder: "AIza...", envVar: "GOOGLE_API_KEY" }
|
||||
}
|
||||
|
||||
export function ApiKeyDialog({ open, onOpenChange, provider }: ApiKeyDialogProps): React.JSX.Element | null {
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
export function ApiKeyDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
provider
|
||||
}: ApiKeyDialogProps): React.JSX.Element | null {
|
||||
const [apiKey, setApiKey] = useState("")
|
||||
const [showKey, setShowKey] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
@@ -37,27 +41,27 @@ export function ApiKeyDialog({ open, onOpenChange, provider }: ApiKeyDialogProps
|
||||
useEffect(() => {
|
||||
if (open && provider) {
|
||||
setHasExistingKey(provider.hasApiKey)
|
||||
setApiKey('')
|
||||
setApiKey("")
|
||||
setShowKey(false)
|
||||
}
|
||||
}, [open, provider])
|
||||
|
||||
if (!provider) return null
|
||||
|
||||
const info = PROVIDER_INFO[provider.id] || { placeholder: '...', envVar: '' }
|
||||
const info = PROVIDER_INFO[provider.id] || { placeholder: "...", envVar: "" }
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
if (!apiKey.trim()) return
|
||||
if (!provider) return
|
||||
|
||||
console.log('[ApiKeyDialog] Saving API key for provider:', provider.id)
|
||||
console.log("[ApiKeyDialog] Saving API key for provider:", provider.id)
|
||||
setSaving(true)
|
||||
try {
|
||||
await saveApiKey(provider.id, apiKey.trim())
|
||||
console.log('[ApiKeyDialog] API key saved successfully')
|
||||
console.log("[ApiKeyDialog] API key saved successfully")
|
||||
onOpenChange(false)
|
||||
} catch (e) {
|
||||
console.error('[ApiKeyDialog] Failed to save API key:', e)
|
||||
console.error("[ApiKeyDialog] Failed to save API key:", e)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -70,7 +74,7 @@ export function ApiKeyDialog({ open, onOpenChange, provider }: ApiKeyDialogProps
|
||||
await deleteApiKey(provider.id)
|
||||
onOpenChange(false)
|
||||
} catch (e) {
|
||||
console.error('Failed to delete API key:', e)
|
||||
console.error("Failed to delete API key:", e)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
@@ -85,9 +89,8 @@ export function ApiKeyDialog({ open, onOpenChange, provider }: ApiKeyDialogProps
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{hasExistingKey
|
||||
? 'Enter a new API key to replace the existing one, or remove it.'
|
||||
: `Enter your ${provider.name} API key to use their models.`
|
||||
}
|
||||
? "Enter a new API key to replace the existing one, or remove it."
|
||||
: `Enter your ${provider.name} API key to use their models.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -95,10 +98,10 @@ export function ApiKeyDialog({ open, onOpenChange, provider }: ApiKeyDialogProps
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showKey ? 'text' : 'password'}
|
||||
type={showKey ? "text" : "password"}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={hasExistingKey ? '••••••••••••••••' : info.placeholder}
|
||||
placeholder={hasExistingKey ? "••••••••••••••••" : info.placeholder}
|
||||
className="pr-10"
|
||||
autoFocus
|
||||
/>
|
||||
@@ -139,16 +142,8 @@ export function ApiKeyDialog({ open, onOpenChange, provider }: ApiKeyDialogProps
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!apiKey.trim() || saving}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
'Save'
|
||||
)}
|
||||
<Button type="button" onClick={handleSave} disabled={!apiKey.trim() || saving}>
|
||||
{saving ? <Loader2 className="size-4 animate-spin" /> : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
|
||||
import { Send, Square, Loader2, AlertCircle, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { useAppStore } from '@/lib/store'
|
||||
import { useCurrentThread, useThreadStream } from '@/lib/thread-context'
|
||||
import { MessageBubble } from './MessageBubble'
|
||||
import { ModelSwitcher } from './ModelSwitcher'
|
||||
import { Folder } from 'lucide-react'
|
||||
import { WorkspacePicker } from './WorkspacePicker'
|
||||
import { selectWorkspaceFolder } from '@/lib/workspace-utils'
|
||||
import { ChatTodos } from './ChatTodos'
|
||||
import { ContextUsageIndicator } from './ContextUsageIndicator'
|
||||
import type { Message } from '@/types'
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from "react"
|
||||
import { Send, Square, Loader2, AlertCircle, X } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { useAppStore } from "@/lib/store"
|
||||
import { useCurrentThread, useThreadStream } from "@/lib/thread-context"
|
||||
import { MessageBubble } from "./MessageBubble"
|
||||
import { ModelSwitcher } from "./ModelSwitcher"
|
||||
import { Folder } from "lucide-react"
|
||||
import { WorkspacePicker } from "./WorkspacePicker"
|
||||
import { selectWorkspaceFolder } from "@/lib/workspace-utils"
|
||||
import { ChatTodos } from "./ChatTodos"
|
||||
import { ContextUsageIndicator } from "./ContextUsageIndicator"
|
||||
import type { Message } from "@/types"
|
||||
|
||||
interface AgentStreamValues {
|
||||
todos?: Array<{ id?: string; content?: string; status?: string }>
|
||||
@@ -21,7 +21,7 @@ interface StreamMessage {
|
||||
id?: string
|
||||
type?: string
|
||||
content?: string | unknown[]
|
||||
tool_calls?: Message['tool_calls']
|
||||
tool_calls?: Message["tool_calls"]
|
||||
tool_call_id?: string
|
||||
name?: string
|
||||
}
|
||||
@@ -31,7 +31,7 @@ interface ChatContainerProps {
|
||||
}
|
||||
|
||||
export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Element {
|
||||
const [input, setInput] = useState('')
|
||||
const [input, setInput] = useState("")
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const isAtBottomRef = useRef(true)
|
||||
@@ -62,7 +62,7 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
|
||||
const isLoading = streamData.isLoading
|
||||
|
||||
const handleApprovalDecision = useCallback(
|
||||
async (decision: 'approve' | 'reject' | 'edit'): Promise<void> => {
|
||||
async (decision: "approve" | "reject" | "edit"): Promise<void> => {
|
||||
if (!pendingApproval || !stream) return
|
||||
|
||||
setPendingApproval(null)
|
||||
@@ -73,7 +73,7 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
|
||||
config: { configurable: { thread_id: threadId, model_id: currentModel } }
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[ChatContainer] Resume command failed:', err)
|
||||
console.error("[ChatContainer] Resume command failed:", err)
|
||||
}
|
||||
},
|
||||
[pendingApproval, setPendingApproval, stream, threadId, currentModel]
|
||||
@@ -86,8 +86,8 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
|
||||
setTodos(
|
||||
streamTodos.map((t) => ({
|
||||
id: t.id || crypto.randomUUID(),
|
||||
content: t.content || '',
|
||||
status: (t.status || 'pending') as 'pending' | 'in_progress' | 'completed' | 'cancelled'
|
||||
content: t.content || "",
|
||||
status: (t.status || "pending") as "pending" | "in_progress" | "completed" | "cancelled"
|
||||
}))
|
||||
)
|
||||
}
|
||||
@@ -101,18 +101,19 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
|
||||
if (msg.id) {
|
||||
const streamMsg = msg as StreamMessage & { id: string }
|
||||
|
||||
let role: Message['role'] = 'assistant'
|
||||
if (streamMsg.type === 'human') role = 'user'
|
||||
else if (streamMsg.type === 'tool') role = 'tool'
|
||||
else if (streamMsg.type === 'ai') role = 'assistant'
|
||||
let role: Message["role"] = "assistant"
|
||||
if (streamMsg.type === "human") role = "user"
|
||||
else if (streamMsg.type === "tool") role = "tool"
|
||||
else if (streamMsg.type === "ai") role = "assistant"
|
||||
|
||||
const storeMsg: Message = {
|
||||
id: streamMsg.id,
|
||||
role,
|
||||
content: typeof streamMsg.content === 'string' ? streamMsg.content : '',
|
||||
content: typeof streamMsg.content === "string" ? streamMsg.content : "",
|
||||
tool_calls: streamMsg.tool_calls,
|
||||
...(role === 'tool' && streamMsg.tool_call_id && { tool_call_id: streamMsg.tool_call_id }),
|
||||
...(role === 'tool' && streamMsg.name && { name: streamMsg.name }),
|
||||
...(role === "tool" &&
|
||||
streamMsg.tool_call_id && { tool_call_id: streamMsg.tool_call_id }),
|
||||
...(role === "tool" && streamMsg.name && { name: streamMsg.name }),
|
||||
created_at: new Date()
|
||||
}
|
||||
appendMessage(storeMsg)
|
||||
@@ -129,19 +130,19 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
|
||||
const streamingMsgs: Message[] = ((streamData.messages || []) as StreamMessage[])
|
||||
.filter((m): m is StreamMessage & { id: string } => !!m.id && !threadMessageIds.has(m.id))
|
||||
.map((streamMsg) => {
|
||||
|
||||
let role: Message['role'] = 'assistant'
|
||||
if (streamMsg.type === 'human') role = 'user'
|
||||
else if (streamMsg.type === 'tool') role = 'tool'
|
||||
else if (streamMsg.type === 'ai') role = 'assistant'
|
||||
let role: Message["role"] = "assistant"
|
||||
if (streamMsg.type === "human") role = "user"
|
||||
else if (streamMsg.type === "tool") role = "tool"
|
||||
else if (streamMsg.type === "ai") role = "assistant"
|
||||
|
||||
return {
|
||||
id: streamMsg.id,
|
||||
role,
|
||||
content: typeof streamMsg.content === 'string' ? streamMsg.content : '',
|
||||
content: typeof streamMsg.content === "string" ? streamMsg.content : "",
|
||||
tool_calls: streamMsg.tool_calls,
|
||||
...(role === 'tool' && streamMsg.tool_call_id && { tool_call_id: streamMsg.tool_call_id }),
|
||||
...(role === 'tool' && streamMsg.name && { name: streamMsg.name }),
|
||||
...(role === "tool" &&
|
||||
streamMsg.tool_call_id && { tool_call_id: streamMsg.tool_call_id }),
|
||||
...(role === "tool" && streamMsg.name && { name: streamMsg.name }),
|
||||
created_at: new Date()
|
||||
}
|
||||
})
|
||||
@@ -153,7 +154,7 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
|
||||
const toolResults = useMemo(() => {
|
||||
const results = new Map<string, { content: string | unknown; is_error?: boolean }>()
|
||||
for (const msg of displayMessages) {
|
||||
if (msg.role === 'tool' && msg.tool_call_id) {
|
||||
if (msg.role === "tool" && msg.tool_call_id) {
|
||||
results.set(msg.tool_call_id, {
|
||||
content: msg.content,
|
||||
is_error: false // Could be enhanced to track errors
|
||||
@@ -166,7 +167,7 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
|
||||
// Get the actual scrollable viewport element from Radix ScrollArea
|
||||
const getViewport = useCallback((): HTMLDivElement | null => {
|
||||
return scrollRef.current?.querySelector(
|
||||
'[data-radix-scroll-area-viewport]'
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
) as HTMLDivElement | null
|
||||
}, [])
|
||||
|
||||
@@ -186,8 +187,8 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
|
||||
const viewport = getViewport()
|
||||
if (!viewport) return
|
||||
|
||||
viewport.addEventListener('scroll', handleScroll)
|
||||
return () => viewport.removeEventListener('scroll', handleScroll)
|
||||
viewport.addEventListener("scroll", handleScroll)
|
||||
return () => viewport.removeEventListener("scroll", handleScroll)
|
||||
}, [getViewport, handleScroll])
|
||||
|
||||
// Auto-scroll on new messages only if already at bottom
|
||||
@@ -221,7 +222,7 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
|
||||
if (!input.trim() || isLoading || !stream) return
|
||||
|
||||
if (!workspacePath) {
|
||||
setError('Please select a workspace folder before sending messages.')
|
||||
setError("Please select a workspace folder before sending messages.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -234,13 +235,13 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
|
||||
}
|
||||
|
||||
const message = input.trim()
|
||||
setInput('')
|
||||
setInput("")
|
||||
|
||||
const isFirstMessage = threadMessages.length === 0
|
||||
|
||||
const userMessage: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
role: "user",
|
||||
content: message,
|
||||
created_at: new Date()
|
||||
}
|
||||
@@ -252,7 +253,7 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
|
||||
|
||||
await stream.submit(
|
||||
{
|
||||
messages: [{ type: 'human', content: message }]
|
||||
messages: [{ type: "human", content: message }]
|
||||
},
|
||||
{
|
||||
config: {
|
||||
@@ -263,7 +264,7 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent): void => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit(e)
|
||||
}
|
||||
@@ -273,7 +274,7 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
|
||||
const adjustTextareaHeight = (): void => {
|
||||
const textarea = inputRef.current
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = "auto"
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
|
||||
}
|
||||
}
|
||||
@@ -287,7 +288,7 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
|
||||
}
|
||||
|
||||
const handleSelectWorkspaceFromEmptyState = async (): Promise<void> => {
|
||||
await selectWorkspaceFolder(threadId, setWorkspacePath, setWorkspaceFiles, () => { }, undefined)
|
||||
await selectWorkspaceFolder(threadId, setWorkspacePath, setWorkspaceFiles, () => {}, undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -383,7 +384,7 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
|
||||
disabled={isLoading}
|
||||
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' }}
|
||||
style={{ minHeight: "48px", maxHeight: "200px" }}
|
||||
/>
|
||||
<div className="flex items-center justify-center shrink-0 h-12">
|
||||
{isLoading ? (
|
||||
@@ -391,7 +392,13 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
|
||||
<Square className="size-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" variant="default" size="icon" disabled={!input.trim()} className="rounded-md">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
size="icon"
|
||||
disabled={!input.trim()}
|
||||
className="rounded-md"
|
||||
>
|
||||
<Send className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
@@ -410,7 +417,6 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CheckCircle2, Circle, Clock, XCircle, ListTodo } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Todo } from '@/types'
|
||||
import { CheckCircle2, Circle, Clock, XCircle, ListTodo } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { Todo } from "@/types"
|
||||
|
||||
interface ChatTodosProps {
|
||||
todos: Todo[]
|
||||
@@ -9,19 +9,19 @@ interface ChatTodosProps {
|
||||
const STATUS_CONFIG = {
|
||||
pending: {
|
||||
icon: Circle,
|
||||
color: 'text-muted-foreground'
|
||||
color: "text-muted-foreground"
|
||||
},
|
||||
in_progress: {
|
||||
icon: Clock,
|
||||
color: 'text-status-info'
|
||||
color: "text-status-info"
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
color: 'text-status-nominal'
|
||||
color: "text-status-nominal"
|
||||
},
|
||||
cancelled: {
|
||||
icon: XCircle,
|
||||
color: 'text-muted-foreground'
|
||||
color: "text-muted-foreground"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ export function ChatTodos({ todos }: ChatTodosProps): React.JSX.Element | null {
|
||||
if (todos.length === 0) return null
|
||||
|
||||
// Separate active and completed todos
|
||||
const activeTodos = todos.filter(t => t.status === 'in_progress' || t.status === 'pending')
|
||||
const completedCount = todos.filter(t => t.status === 'completed').length
|
||||
const activeTodos = todos.filter((t) => t.status === "in_progress" || t.status === "pending")
|
||||
const completedCount = todos.filter((t) => t.status === "completed").length
|
||||
const totalCount = todos.length
|
||||
|
||||
// Calculate progress
|
||||
@@ -62,7 +62,7 @@ export function ChatTodos({ todos }: ChatTodosProps): React.JSX.Element | null {
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<div key={todo.id} className="flex items-start gap-2 text-xs">
|
||||
<Icon className={cn('size-3.5 mt-0.5 shrink-0', config.color)} />
|
||||
<Icon className={cn("size-3.5 mt-0.5 shrink-0", config.color)} />
|
||||
<span>{todo.content}</span>
|
||||
</div>
|
||||
)
|
||||
@@ -73,7 +73,7 @@ export function ChatTodos({ todos }: ChatTodosProps): React.JSX.Element | null {
|
||||
{/* Completed summary (collapsed) */}
|
||||
{completedCount > 0 && activeTodos.length > 0 && (
|
||||
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border bg-background">
|
||||
{completedCount} task{completedCount !== 1 ? 's' : ''} completed
|
||||
{completedCount} task{completedCount !== 1 ? "s" : ""} completed
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
import { CircleGauge, Zap, ArrowDown, ArrowUp, Database } from 'lucide-react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { TokenUsage } from '@/lib/thread-context'
|
||||
import { CircleGauge, Zap, ArrowDown, ArrowUp, Database } from "lucide-react"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { TokenUsage } from "@/lib/thread-context"
|
||||
|
||||
// Context window limits by model (in tokens)
|
||||
// These are approximate and may vary
|
||||
const MODEL_CONTEXT_LIMITS: Record<string, number> = {
|
||||
// Anthropic models
|
||||
'claude-opus-4-5-20251101': 200_000,
|
||||
'claude-sonnet-4-5-20250929': 200_000,
|
||||
'claude-3-5-sonnet-20241022': 200_000,
|
||||
'claude-3-5-haiku-20241022': 200_000,
|
||||
'claude-3-opus-20240229': 200_000,
|
||||
'claude-3-sonnet-20240229': 200_000,
|
||||
'claude-3-haiku-20240307': 200_000,
|
||||
"claude-opus-4-5-20251101": 200_000,
|
||||
"claude-sonnet-4-5-20250929": 200_000,
|
||||
"claude-3-5-sonnet-20241022": 200_000,
|
||||
"claude-3-5-haiku-20241022": 200_000,
|
||||
"claude-3-opus-20240229": 200_000,
|
||||
"claude-3-sonnet-20240229": 200_000,
|
||||
"claude-3-haiku-20240307": 200_000,
|
||||
// OpenAI models
|
||||
'gpt-4o': 128_000,
|
||||
'gpt-4o-mini': 128_000,
|
||||
'gpt-4-turbo': 128_000,
|
||||
'gpt-4': 8_192,
|
||||
'o1': 200_000,
|
||||
'o1-mini': 128_000,
|
||||
'o3': 200_000,
|
||||
'o3-mini': 200_000,
|
||||
"gpt-4o": 128_000,
|
||||
"gpt-4o-mini": 128_000,
|
||||
"gpt-4-turbo": 128_000,
|
||||
"gpt-4": 8_192,
|
||||
o1: 200_000,
|
||||
"o1-mini": 128_000,
|
||||
o3: 200_000,
|
||||
"o3-mini": 200_000,
|
||||
// Google models
|
||||
'gemini-3-pro-preview': 2_000_000,
|
||||
'gemini-3-flash-preview': 1_000_000,
|
||||
'gemini-2.5-pro': 2_000_000,
|
||||
'gemini-2.5-flash': 1_000_000,
|
||||
'gemini-2.5-flash-lite': 1_000_000,
|
||||
'gemini-2.0-flash': 1_000_000,
|
||||
'gemini-1.5-pro': 2_000_000,
|
||||
'gemini-1.5-flash': 1_000_000
|
||||
"gemini-3-pro-preview": 2_000_000,
|
||||
"gemini-3-flash-preview": 1_000_000,
|
||||
"gemini-2.5-pro": 2_000_000,
|
||||
"gemini-2.5-flash": 1_000_000,
|
||||
"gemini-2.5-flash-lite": 1_000_000,
|
||||
"gemini-2.0-flash": 1_000_000,
|
||||
"gemini-1.5-pro": 2_000_000,
|
||||
"gemini-1.5-flash": 1_000_000
|
||||
}
|
||||
|
||||
// Default limit if model not found
|
||||
@@ -51,9 +51,9 @@ function getContextLimit(modelId: string): number {
|
||||
}
|
||||
|
||||
// Infer from model name patterns
|
||||
if (modelId.includes('claude')) return 200_000
|
||||
if (modelId.includes('gpt-4o') || modelId.includes('o1') || modelId.includes('o3')) return 128_000
|
||||
if (modelId.includes('gemini')) return 1_000_000
|
||||
if (modelId.includes("claude")) return 200_000
|
||||
if (modelId.includes("gpt-4o") || modelId.includes("o1") || modelId.includes("o3")) return 128_000
|
||||
if (modelId.includes("gemini")) return 1_000_000
|
||||
|
||||
return DEFAULT_CONTEXT_LIMIT
|
||||
}
|
||||
@@ -92,26 +92,26 @@ export function ContextUsageIndicator({
|
||||
const usagePercent = Math.min((usedTokens / contextLimit) * 100, 100)
|
||||
|
||||
// Determine color based on usage
|
||||
let colorClass = 'text-blue-500'
|
||||
let bgColorClass = 'bg-blue-500/20'
|
||||
let barColorClass = 'bg-blue-500'
|
||||
let statusText = 'Normal'
|
||||
let colorClass = "text-blue-500"
|
||||
let bgColorClass = "bg-blue-500/20"
|
||||
let barColorClass = "bg-blue-500"
|
||||
let statusText = "Normal"
|
||||
|
||||
if (usagePercent >= 90) {
|
||||
colorClass = 'text-red-500'
|
||||
bgColorClass = 'bg-red-500/20'
|
||||
barColorClass = 'bg-red-500'
|
||||
statusText = 'Critical'
|
||||
colorClass = "text-red-500"
|
||||
bgColorClass = "bg-red-500/20"
|
||||
barColorClass = "bg-red-500"
|
||||
statusText = "Critical"
|
||||
} else if (usagePercent >= 75) {
|
||||
colorClass = 'text-orange-500'
|
||||
bgColorClass = 'bg-orange-500/20'
|
||||
barColorClass = 'bg-orange-500'
|
||||
statusText = 'Warning'
|
||||
colorClass = "text-orange-500"
|
||||
bgColorClass = "bg-orange-500/20"
|
||||
barColorClass = "bg-orange-500"
|
||||
statusText = "Warning"
|
||||
} else if (usagePercent >= 50) {
|
||||
colorClass = 'text-yellow-500'
|
||||
bgColorClass = 'bg-yellow-500/20'
|
||||
barColorClass = 'bg-yellow-500'
|
||||
statusText = 'Moderate'
|
||||
colorClass = "text-yellow-500"
|
||||
bgColorClass = "bg-yellow-500/20"
|
||||
barColorClass = "bg-yellow-500"
|
||||
statusText = "Moderate"
|
||||
}
|
||||
|
||||
const hasCacheData = tokenUsage.cacheReadTokens || tokenUsage.cacheCreationTokens
|
||||
@@ -121,7 +121,7 @@ export function ContextUsageIndicator({
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2 py-0.5 rounded-sm text-xs transition-colors hover:opacity-80',
|
||||
"flex items-center gap-1.5 px-2 py-0.5 rounded-sm text-xs transition-colors hover:opacity-80",
|
||||
bgColorClass,
|
||||
colorClass,
|
||||
className
|
||||
@@ -131,21 +131,21 @@ export function ContextUsageIndicator({
|
||||
<span className="font-mono">
|
||||
{formatTokenCount(usedTokens)} / {formatTokenCount(contextLimit)}
|
||||
</span>
|
||||
<span className="text-[10px] opacity-70">
|
||||
({usagePercent.toFixed(0)}%)
|
||||
</span>
|
||||
<span className="text-[10px] opacity-70">({usagePercent.toFixed(0)}%)</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-72 p-0 bg-background border-border"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
>
|
||||
<PopoverContent className="w-72 p-0 bg-background border-border" align="end" sideOffset={8}>
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-foreground">Context Window</span>
|
||||
<span className={cn('text-[10px] font-medium px-1.5 py-0.5 rounded', bgColorClass, colorClass)}>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-medium px-1.5 py-0.5 rounded",
|
||||
bgColorClass,
|
||||
colorClass
|
||||
)}
|
||||
>
|
||||
{statusText}
|
||||
</span>
|
||||
</div>
|
||||
@@ -154,7 +154,7 @@ export function ContextUsageIndicator({
|
||||
<div className="space-y-1">
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all', barColorClass)}
|
||||
className={cn("h-full rounded-full transition-all", barColorClass)}
|
||||
style={{ width: `${usagePercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -221,28 +221,25 @@ export function ContextUsageIndicator({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tokenUsage.cacheCreationTokens !== undefined && tokenUsage.cacheCreationTokens > 0 && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-1.5 text-blue-500">
|
||||
<Database className="size-3" />
|
||||
<span>Cache created</span>
|
||||
{tokenUsage.cacheCreationTokens !== undefined &&
|
||||
tokenUsage.cacheCreationTokens > 0 && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-1.5 text-blue-500">
|
||||
<Database className="size-3" />
|
||||
<span>Cache created</span>
|
||||
</div>
|
||||
<span className="font-mono text-blue-500">
|
||||
{formatTokenCountFull(tokenUsage.cacheCreationTokens)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-blue-500">
|
||||
{formatTokenCountFull(tokenUsage.cacheCreationTokens)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
No cached tokens
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">No cached tokens</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Last updated */}
|
||||
<div className="pt-2 border-t border-border">
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { User, Bot } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Message, HITLRequest } from '@/types'
|
||||
import { ToolCallRenderer } from './ToolCallRenderer'
|
||||
import { StreamingMarkdown } from './StreamingMarkdown'
|
||||
import { User, Bot } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { Message, HITLRequest } from "@/types"
|
||||
import { ToolCallRenderer } from "./ToolCallRenderer"
|
||||
import { StreamingMarkdown } from "./StreamingMarkdown"
|
||||
|
||||
interface ToolResultInfo {
|
||||
content: string | unknown
|
||||
@@ -14,12 +14,18 @@ interface MessageBubbleProps {
|
||||
isStreaming?: boolean
|
||||
toolResults?: Map<string, ToolResultInfo>
|
||||
pendingApproval?: HITLRequest | null
|
||||
onApprovalDecision?: (decision: 'approve' | 'reject' | 'edit') => void
|
||||
onApprovalDecision?: (decision: "approve" | "reject" | "edit") => void
|
||||
}
|
||||
|
||||
export function MessageBubble({ message, isStreaming, toolResults, pendingApproval, onApprovalDecision }: MessageBubbleProps): React.JSX.Element | null {
|
||||
const isUser = message.role === 'user'
|
||||
const isTool = message.role === 'tool'
|
||||
export function MessageBubble({
|
||||
message,
|
||||
isStreaming,
|
||||
toolResults,
|
||||
pendingApproval,
|
||||
onApprovalDecision
|
||||
}: MessageBubbleProps): React.JSX.Element | null {
|
||||
const isUser = message.role === "user"
|
||||
const isTool = message.role === "tool"
|
||||
|
||||
// Hide tool result messages - they're shown inline with tool calls
|
||||
if (isTool) {
|
||||
@@ -32,12 +38,12 @@ export function MessageBubble({ message, isStreaming, toolResults, pendingApprov
|
||||
}
|
||||
|
||||
const getLabel = (): string => {
|
||||
if (isUser) return 'YOU'
|
||||
return 'AGENT'
|
||||
if (isUser) return "YOU"
|
||||
return "AGENT"
|
||||
}
|
||||
|
||||
const renderContent = (): React.ReactNode => {
|
||||
if (typeof message.content === 'string') {
|
||||
if (typeof message.content === "string") {
|
||||
// Empty content
|
||||
if (!message.content.trim()) {
|
||||
return null
|
||||
@@ -45,38 +51,32 @@ export function MessageBubble({ message, isStreaming, toolResults, pendingApprov
|
||||
|
||||
// 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 <div className="whitespace-pre-wrap text-sm">{message.content}</div>
|
||||
}
|
||||
return (
|
||||
<StreamingMarkdown isStreaming={isStreaming}>
|
||||
{message.content}
|
||||
</StreamingMarkdown>
|
||||
)
|
||||
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) {
|
||||
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 (
|
||||
<div key={index} className="whitespace-pre-wrap text-sm">
|
||||
<StreamingMarkdown key={index} isStreaming={isStreaming}>
|
||||
{block.text}
|
||||
</div>
|
||||
</StreamingMarkdown>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<StreamingMarkdown key={index} isStreaming={isStreaming}>
|
||||
{block.text}
|
||||
</StreamingMarkdown>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}).filter(Boolean)
|
||||
return null
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
return renderedBlocks.length > 0 ? renderedBlocks : null
|
||||
}
|
||||
@@ -102,18 +102,12 @@ export function MessageBubble({ message, isStreaming, toolResults, pendingApprov
|
||||
|
||||
{/* 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>
|
||||
<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"
|
||||
)}>
|
||||
<div
|
||||
className={cn("rounded-sm p-3 overflow-hidden", isUser ? "bg-primary/10" : "bg-card")}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
@@ -127,7 +121,7 @@ export function MessageBubble({ message, isStreaming, toolResults, pendingApprov
|
||||
const needsApproval = Boolean(pendingId && pendingId === toolCall.id)
|
||||
return (
|
||||
<ToolCallRenderer
|
||||
key={`${toolCall.id || `tc-${index}`}-${needsApproval ? 'pending' : 'done'}`}
|
||||
key={`${toolCall.id || `tc-${index}`}-${needsApproval ? "pending" : "done"}`}
|
||||
toolCall={toolCall}
|
||||
result={result?.content}
|
||||
isError={result?.is_error}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ChevronDown, Check, AlertCircle, Key } from 'lucide-react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAppStore } from '@/lib/store'
|
||||
import { useCurrentThread } from '@/lib/thread-context'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ApiKeyDialog } from './ApiKeyDialog'
|
||||
import type { Provider, ProviderId } from '@/types'
|
||||
import { useState, useEffect } from "react"
|
||||
import { ChevronDown, Check, AlertCircle, Key } from "lucide-react"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useAppStore } from "@/lib/store"
|
||||
import { useCurrentThread } from "@/lib/thread-context"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ApiKeyDialog } from "./ApiKeyDialog"
|
||||
import type { Provider, ProviderId } from "@/types"
|
||||
|
||||
// Provider icons as simple SVG components
|
||||
function AnthropicIcon({ className }: { className?: string }): React.JSX.Element {
|
||||
@@ -42,9 +42,9 @@ const PROVIDER_ICONS: Record<ProviderId, React.FC<{ className?: string }>> = {
|
||||
|
||||
// Fallback providers in case the backend hasn't loaded them yet
|
||||
const FALLBACK_PROVIDERS: Provider[] = [
|
||||
{ id: 'anthropic', name: 'Anthropic', hasApiKey: false },
|
||||
{ id: 'openai', name: 'OpenAI', hasApiKey: false },
|
||||
{ id: 'google', name: 'Google', hasApiKey: false }
|
||||
{ id: "anthropic", name: "Anthropic", hasApiKey: false },
|
||||
{ id: "openai", name: "OpenAI", hasApiKey: false },
|
||||
{ id: "google", name: "Google", hasApiKey: false }
|
||||
]
|
||||
|
||||
interface ModelSwitcherProps {
|
||||
@@ -70,15 +70,16 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps): React.JSX.Eleme
|
||||
const displayProviders = providers.length > 0 ? providers : FALLBACK_PROVIDERS
|
||||
|
||||
// Determine effective provider ID (manual selection > current model > default)
|
||||
const effectiveProviderId = selectedProviderId ||
|
||||
(currentModel ? models.find(m => m.id === currentModel)?.provider : null) ||
|
||||
const effectiveProviderId =
|
||||
selectedProviderId ||
|
||||
(currentModel ? models.find((m) => m.id === currentModel)?.provider : null) ||
|
||||
(displayProviders.length > 0 ? displayProviders[0].id : null)
|
||||
|
||||
const selectedModel = models.find(m => m.id === currentModel)
|
||||
const selectedModel = models.find((m) => m.id === currentModel)
|
||||
const filteredModels = effectiveProviderId
|
||||
? models.filter(m => m.provider === effectiveProviderId)
|
||||
? models.filter((m) => m.provider === effectiveProviderId)
|
||||
: []
|
||||
const selectedProvider = displayProviders.find(p => p.id === effectiveProviderId)
|
||||
const selectedProvider = displayProviders.find((p) => p.id === effectiveProviderId)
|
||||
|
||||
function handleProviderClick(provider: Provider): void {
|
||||
setSelectedProviderId(provider.id)
|
||||
@@ -114,7 +115,7 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps): React.JSX.Eleme
|
||||
>
|
||||
{selectedModel ? (
|
||||
<>
|
||||
{PROVIDER_ICONS[selectedModel.provider]?.({ className: 'size-3.5' })}
|
||||
{PROVIDER_ICONS[selectedModel.provider]?.({ className: "size-3.5" })}
|
||||
<span className="font-mono">{selectedModel.id}</span>
|
||||
</>
|
||||
) : (
|
||||
@@ -172,10 +173,7 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps): React.JSX.Eleme
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
API key required for {selectedProvider.name}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleConfigureApiKey(selectedProvider)}
|
||||
>
|
||||
<Button size="sm" onClick={() => handleConfigureApiKey(selectedProvider)}>
|
||||
Configure API Key
|
||||
</Button>
|
||||
</div>
|
||||
@@ -202,9 +200,7 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps): React.JSX.Eleme
|
||||
))}
|
||||
|
||||
{filteredModels.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground px-2 py-4">
|
||||
No models available
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground px-2 py-4">No models available</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { memo } from 'react'
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import remarkGfm from "remark-gfm"
|
||||
import { memo } from "react"
|
||||
|
||||
interface StreamingMarkdownProps {
|
||||
children: string
|
||||
@@ -13,9 +13,7 @@ export const StreamingMarkdown = memo(function StreamingMarkdown({
|
||||
}: StreamingMarkdownProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="streaming-markdown">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-2 h-4 ml-0.5 bg-foreground/70 animate-pulse" />
|
||||
)}
|
||||
|
||||
@@ -14,18 +14,18 @@ import {
|
||||
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'
|
||||
} 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
|
||||
needsApproval?: boolean
|
||||
onApprovalDecision?: (decision: 'approve' | 'reject' | 'edit') => void
|
||||
onApprovalDecision?: (decision: "approve" | "reject" | "edit") => void
|
||||
}
|
||||
|
||||
const TOOL_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
@@ -37,51 +37,51 @@ const TOOL_ICONS: Record<string, React.ComponentType<{ className?: string }>> =
|
||||
grep: Search,
|
||||
execute: Terminal,
|
||||
write_todos: ListTodo,
|
||||
task: GitBranch,
|
||||
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',
|
||||
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'])
|
||||
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
|
||||
return path.split("/").pop() || path
|
||||
}
|
||||
|
||||
// Render todos nicely
|
||||
function TodosDisplay({ todos }: { todos: Todo[] }): React.JSX.Element {
|
||||
const statusConfig: Record<string, { icon: typeof Circle; color: string }> = {
|
||||
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' }
|
||||
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" }
|
||||
}
|
||||
|
||||
const defaultConfig = { icon: Circle, color: 'text-muted-foreground' }
|
||||
const defaultConfig = { icon: Circle, color: "text-muted-foreground" }
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{todos.map((todo, i) => {
|
||||
const config = statusConfig[todo.status] || defaultConfig
|
||||
const Icon = config.icon
|
||||
const isDone = todo.status === 'completed' || todo.status === 'cancelled'
|
||||
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"
|
||||
)}>
|
||||
<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>
|
||||
@@ -92,15 +92,21 @@ function TodosDisplay({ todos }: { todos: Todo[] }): React.JSX.Element {
|
||||
}
|
||||
|
||||
// Render file list nicely
|
||||
function FileListDisplay({ files, isGlob }: { files: string[] | Array<{ path: string; is_dir?: boolean }>; isGlob?: boolean }): React.JSX.Element {
|
||||
function FileListDisplay({
|
||||
files,
|
||||
isGlob
|
||||
}: {
|
||||
files: string[] | Array<{ path: string; is_dir?: boolean }>
|
||||
isGlob?: boolean
|
||||
}): React.JSX.Element {
|
||||
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
|
||||
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 ? (
|
||||
@@ -113,28 +119,33 @@ function FileListDisplay({ files, isGlob }: { files: string[] | Array<{ path: st
|
||||
)
|
||||
})}
|
||||
{hasMore && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
... and {files.length - 15} more
|
||||
</div>
|
||||
<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 }> }): React.JSX.Element {
|
||||
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>)
|
||||
function GrepResultsDisplay({
|
||||
matches
|
||||
}: {
|
||||
matches: Array<{ path: string; line?: number; text?: string }>
|
||||
}): React.JSX.Element {
|
||||
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 => (
|
||||
{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" />
|
||||
@@ -164,7 +175,7 @@ function GrepResultsDisplay({ matches }: { matches: Array<{ path: string; line?:
|
||||
|
||||
// Render file content preview
|
||||
function FileContentPreview({ content }: { content: string; path?: string }): React.JSX.Element {
|
||||
const lines = content.split('\n')
|
||||
const lines = content.split("\n")
|
||||
const preview = lines.slice(0, 10)
|
||||
const hasMore = lines.length > 10
|
||||
|
||||
@@ -173,8 +184,10 @@ function FileContentPreview({ content }: { content: string; path?: string }): Re
|
||||
<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>
|
||||
<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>
|
||||
@@ -199,17 +212,21 @@ function FileEditSummary({ args }: { args: Record<string, unknown> }): React.JSX
|
||||
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>
|
||||
<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>
|
||||
<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
|
||||
const lines = content.split("\n").length
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Writing {lines} lines to {getFileName(path)}
|
||||
@@ -221,7 +238,13 @@ function FileEditSummary({ args }: { args: Record<string, unknown> }): React.JSX
|
||||
}
|
||||
|
||||
// Command display
|
||||
function CommandDisplay({ command, output }: { command: string; output?: string }): React.JSX.Element {
|
||||
function CommandDisplay({
|
||||
command,
|
||||
output
|
||||
}: {
|
||||
command: string
|
||||
output?: string
|
||||
}): React.JSX.Element {
|
||||
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">
|
||||
@@ -231,7 +254,7 @@ function CommandDisplay({ command, output }: { command: string; output?: string
|
||||
{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 && '...'}
|
||||
{output.length > 500 && "..."}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
@@ -239,7 +262,13 @@ function CommandDisplay({ command, output }: { command: string; output?: string
|
||||
}
|
||||
|
||||
// Subagent task display
|
||||
function TaskDisplay({ args, isExpanded }: { args: Record<string, unknown>; isExpanded?: boolean }): React.JSX.Element {
|
||||
function TaskDisplay({
|
||||
args,
|
||||
isExpanded
|
||||
}: {
|
||||
args: Record<string, unknown>
|
||||
isExpanded?: boolean
|
||||
}): React.JSX.Element {
|
||||
const name = args.name as string | undefined
|
||||
const description = args.description as string | undefined
|
||||
|
||||
@@ -252,10 +281,7 @@ function TaskDisplay({ args, isExpanded }: { args: Record<string, unknown>; isEx
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<p className={cn(
|
||||
"text-muted-foreground pl-5",
|
||||
!isExpanded && "line-clamp-2"
|
||||
)}>
|
||||
<p className={cn("text-muted-foreground pl-5", !isExpanded && "line-clamp-2")}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
@@ -263,7 +289,13 @@ function TaskDisplay({ args, isExpanded }: { args: Record<string, unknown>; isEx
|
||||
)
|
||||
}
|
||||
|
||||
export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onApprovalDecision }: ToolCallRendererProps): React.JSX.Element | null {
|
||||
export function ToolCallRenderer({
|
||||
toolCall,
|
||||
result,
|
||||
isError,
|
||||
needsApproval,
|
||||
onApprovalDecision
|
||||
}: ToolCallRendererProps): React.JSX.Element | null {
|
||||
// Defensive: ensure args is always an object
|
||||
const args = toolCall?.args || {}
|
||||
|
||||
@@ -280,12 +312,12 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA
|
||||
|
||||
const handleApprove = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation()
|
||||
onApprovalDecision?.('approve')
|
||||
onApprovalDecision?.("approve")
|
||||
}
|
||||
|
||||
const handleReject = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation()
|
||||
onApprovalDecision?.('reject')
|
||||
onApprovalDecision?.("reject")
|
||||
}
|
||||
|
||||
// Format the main argument for display
|
||||
@@ -307,7 +339,7 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA
|
||||
if (!args) return null
|
||||
|
||||
switch (toolCall.name) {
|
||||
case 'write_todos': {
|
||||
case "write_todos": {
|
||||
const todos = args.todos as Todo[] | undefined
|
||||
if (todos && todos.length > 0) {
|
||||
return <TodosDisplay todos={todos} />
|
||||
@@ -315,18 +347,18 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA
|
||||
return null
|
||||
}
|
||||
|
||||
case 'task': {
|
||||
case "task": {
|
||||
return <TaskDisplay args={args} isExpanded={isExpanded} />
|
||||
}
|
||||
|
||||
case 'edit_file':
|
||||
case 'write_file': {
|
||||
case "edit_file":
|
||||
case "write_file": {
|
||||
return <FileEditSummary args={args} />
|
||||
}
|
||||
|
||||
case 'execute': {
|
||||
case "execute": {
|
||||
const command = args.command as string
|
||||
const output = typeof result === 'string' ? result : undefined
|
||||
const output = typeof result === "string" ? result : undefined
|
||||
return <CommandDisplay command={command} output={isExpanded ? output : undefined} />
|
||||
}
|
||||
|
||||
@@ -344,15 +376,17 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA
|
||||
return (
|
||||
<div className="text-xs text-status-critical flex items-start gap-1.5">
|
||||
<XCircle className="size-3 mt-0.5 shrink-0" />
|
||||
<span className="break-words">{typeof result === 'string' ? result : JSON.stringify(result)}</span>
|
||||
<span className="break-words">
|
||||
{typeof result === "string" ? result : JSON.stringify(result)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
switch (toolCall.name) {
|
||||
case 'read_file': {
|
||||
const content = typeof result === 'string' ? result : JSON.stringify(result)
|
||||
const lines = content.split('\n').length
|
||||
case "read_file": {
|
||||
const content = typeof result === "string" ? result : JSON.stringify(result)
|
||||
const lines = content.split("\n").length
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-status-nominal flex items-center gap-1.5">
|
||||
@@ -364,15 +398,20 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA
|
||||
)
|
||||
}
|
||||
|
||||
case 'ls': {
|
||||
case "ls": {
|
||||
if (Array.isArray(result)) {
|
||||
const dirs = result.filter((f: { is_dir?: boolean } | string) => typeof f === 'object' && f.is_dir).length
|
||||
const dirs = result.filter(
|
||||
(f: { is_dir?: boolean } | string) => typeof f === "object" && f.is_dir
|
||||
).length
|
||||
const files = result.length - dirs
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-status-nominal flex items-center gap-1.5">
|
||||
<CheckCircle2 className="size-3" />
|
||||
<span>{files} file{files !== 1 ? 's' : ''}{dirs > 0 ? `, ${dirs} folder${dirs !== 1 ? 's' : ''}` : ''}</span>
|
||||
<span>
|
||||
{files} file{files !== 1 ? "s" : ""}
|
||||
{dirs > 0 ? `, ${dirs} folder${dirs !== 1 ? "s" : ""}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<FileListDisplay files={result} />
|
||||
</div>
|
||||
@@ -381,13 +420,15 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA
|
||||
return null
|
||||
}
|
||||
|
||||
case 'glob': {
|
||||
case "glob": {
|
||||
if (Array.isArray(result)) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-status-nominal flex items-center gap-1.5">
|
||||
<CheckCircle2 className="size-3" />
|
||||
<span>Found {result.length} match{result.length !== 1 ? 'es' : ''}</span>
|
||||
<span>
|
||||
Found {result.length} match{result.length !== 1 ? "es" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<FileListDisplay files={result} isGlob />
|
||||
</div>
|
||||
@@ -396,14 +437,17 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA
|
||||
return null
|
||||
}
|
||||
|
||||
case 'grep': {
|
||||
case "grep": {
|
||||
if (Array.isArray(result)) {
|
||||
const fileCount = new Set(result.map((m: { path: string }) => m.path)).size
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-status-nominal flex items-center gap-1.5">
|
||||
<CheckCircle2 className="size-3" />
|
||||
<span>{result.length} match{result.length !== 1 ? 'es' : ''} in {fileCount} file{fileCount !== 1 ? 's' : ''}</span>
|
||||
<span>
|
||||
{result.length} match{result.length !== 1 ? "es" : ""} in {fileCount} file
|
||||
{fileCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<GrepResultsDisplay matches={result} />
|
||||
</div>
|
||||
@@ -412,10 +456,10 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA
|
||||
return null
|
||||
}
|
||||
|
||||
case 'execute': {
|
||||
case "execute": {
|
||||
// When expanded, output is shown in CommandDisplay - just show status
|
||||
// When collapsed, show the output preview
|
||||
const output = typeof result === 'string' ? result : JSON.stringify(result)
|
||||
const output = typeof result === "string" ? result : JSON.stringify(result)
|
||||
if (isExpanded) {
|
||||
return (
|
||||
<div className="text-xs text-status-nominal flex items-center gap-1.5">
|
||||
@@ -434,7 +478,7 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA
|
||||
</div>
|
||||
<pre className="text-xs font-mono bg-background rounded-sm p-2 overflow-auto max-h-32 text-muted-foreground whitespace-pre-wrap break-all">
|
||||
{output.slice(0, 500)}
|
||||
{output.length > 500 && '...'}
|
||||
{output.length > 500 && "..."}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
@@ -447,14 +491,14 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA
|
||||
)
|
||||
}
|
||||
|
||||
case 'write_todos':
|
||||
case "write_todos":
|
||||
// Already shown in Tasks panel
|
||||
return null
|
||||
|
||||
case 'write_file':
|
||||
case 'edit_file': {
|
||||
case "write_file":
|
||||
case "edit_file": {
|
||||
// Show confirmation message for file operations
|
||||
if (typeof result === 'string' && result.trim()) {
|
||||
if (typeof result === "string" && result.trim()) {
|
||||
return (
|
||||
<div className="text-xs text-status-nominal flex items-center gap-1.5">
|
||||
<CheckCircle2 className="size-3" />
|
||||
@@ -470,9 +514,9 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA
|
||||
)
|
||||
}
|
||||
|
||||
case 'task': {
|
||||
case "task": {
|
||||
// Subagent task completion
|
||||
if (typeof result === 'string' && result.trim()) {
|
||||
if (typeof result === "string" && result.trim()) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-status-nominal flex items-center gap-1.5">
|
||||
@@ -481,7 +525,7 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground pl-5 line-clamp-3">
|
||||
{result.slice(0, 500)}
|
||||
{result.length > 500 && '...'}
|
||||
{result.length > 500 && "..."}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -496,11 +540,14 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA
|
||||
|
||||
default: {
|
||||
// Generic success for unknown tools
|
||||
if (typeof result === 'string' && result.trim()) {
|
||||
if (typeof result === "string" && result.trim()) {
|
||||
return (
|
||||
<div className="text-xs text-status-nominal flex items-center gap-1.5">
|
||||
<CheckCircle2 className="size-3" />
|
||||
<span className="truncate">{result.slice(0, 100)}{result.length > 100 ? '...' : ''}</span>
|
||||
<span className="truncate">
|
||||
{result.slice(0, 100)}
|
||||
{result.length > 100 ? "..." : ""}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -519,10 +566,14 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA
|
||||
const hasFormattedDisplay = formattedContent || formattedResult
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"rounded-sm border overflow-hidden",
|
||||
needsApproval ? "border-amber-500/50 bg-amber-500/5" : "border-border bg-background-elevated"
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-sm border overflow-hidden",
|
||||
needsApproval
|
||||
? "border-amber-500/50 bg-amber-500/5"
|
||||
: "border-border bg-background-elevated"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
@@ -534,7 +585,9 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA
|
||||
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
|
||||
<Icon className={cn("size-4 shrink-0", needsApproval ? "text-amber-500" : "text-status-info")} />
|
||||
<Icon
|
||||
className={cn("size-4 shrink-0", needsApproval ? "text-amber-500" : "text-status-info")}
|
||||
/>
|
||||
|
||||
<span className="text-xs font-medium shrink-0">{label}</span>
|
||||
|
||||
@@ -557,8 +610,8 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA
|
||||
)}
|
||||
|
||||
{result !== undefined && !needsApproval && (
|
||||
<Badge variant={isError ? 'critical' : 'nominal'} className="ml-auto shrink-0">
|
||||
{isError ? 'ERROR' : 'OK'}
|
||||
<Badge variant={isError ? "critical" : "nominal"} className="ml-auto shrink-0">
|
||||
{isError ? "ERROR" : "OK"}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@@ -628,11 +681,13 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA
|
||||
{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
|
||||
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>
|
||||
)}
|
||||
@@ -640,4 +695,4 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { selectWorkspaceFolder } from '@/lib/workspace-utils'
|
||||
import { Check, ChevronDown, Folder } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from '@/components/ui/popover'
|
||||
import { useCurrentThread } from '@/lib/thread-context'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { selectWorkspaceFolder } from "@/lib/workspace-utils"
|
||||
import { Check, ChevronDown, Folder } from "lucide-react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { useCurrentThread } from "@/lib/thread-context"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface WorkspacePickerProps {
|
||||
threadId: string
|
||||
@@ -43,7 +39,7 @@ export function WorkspacePicker({ threadId }: WorkspacePickerProps): React.JSX.E
|
||||
await selectWorkspaceFolder(threadId, setWorkspacePath, setWorkspaceFiles, setLoading, setOpen)
|
||||
}
|
||||
|
||||
const folderName = workspacePath?.split('/').pop()
|
||||
const folderName = workspacePath?.split("/").pop()
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -52,14 +48,14 @@ export function WorkspacePicker({ threadId }: WorkspacePickerProps): React.JSX.E
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 px-2 text-xs gap-1.5',
|
||||
workspacePath ? 'text-foreground' : 'text-amber-500'
|
||||
"h-7 px-2 text-xs gap-1.5",
|
||||
workspacePath ? "text-foreground" : "text-amber-500"
|
||||
)}
|
||||
disabled={!threadId}
|
||||
>
|
||||
<Folder className="size-3.5" />
|
||||
<span className="max-w-[120px] truncate">
|
||||
{workspacePath ? folderName : 'Select workspace'}
|
||||
{workspacePath ? folderName : "Select workspace"}
|
||||
</span>
|
||||
<ChevronDown className="size-3 opacity-50" />
|
||||
</Button>
|
||||
@@ -94,7 +90,8 @@ export function WorkspacePicker({ threadId }: WorkspacePickerProps): React.JSX.E
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
Select a folder for the agent to work in. The agent will read and write files directly to this location.
|
||||
Select a folder for the agent to work in. The agent will read and write files
|
||||
directly to this location.
|
||||
</p>
|
||||
<Button
|
||||
variant="default"
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Folder, File, ChevronRight, ChevronDown, FolderOpen, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAppStore } from '@/lib/store'
|
||||
import { useThreadState } from '@/lib/thread-context'
|
||||
import type { FileInfo } from '@/types'
|
||||
import { useState, useEffect } from "react"
|
||||
import {
|
||||
Folder,
|
||||
File,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
RefreshCw
|
||||
} from "lucide-react"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAppStore } from "@/lib/store"
|
||||
import { useThreadState } from "@/lib/thread-context"
|
||||
import type { FileInfo } from "@/types"
|
||||
|
||||
export function FilesystemPanel() {
|
||||
const { currentThreadId } = useAppStore()
|
||||
@@ -16,7 +24,7 @@ export function FilesystemPanel() {
|
||||
const setWorkspaceFiles = threadState?.setWorkspaceFiles
|
||||
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set())
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
|
||||
// Load workspace path for current thread
|
||||
useEffect(() => {
|
||||
async function loadWorkspacePath() {
|
||||
@@ -28,14 +36,14 @@ export function FilesystemPanel() {
|
||||
loadWorkspacePath()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentThreadId])
|
||||
|
||||
|
||||
// Auto-expand root when workspace path changes
|
||||
useEffect(() => {
|
||||
if (workspacePath) {
|
||||
setExpandedDirs(new Set([workspacePath]))
|
||||
}
|
||||
}, [workspacePath])
|
||||
|
||||
|
||||
// Listen for file changes from the main process
|
||||
useEffect(() => {
|
||||
if (!setWorkspaceFiles) return
|
||||
@@ -43,26 +51,26 @@ export function FilesystemPanel() {
|
||||
const cleanup = window.api.workspace.onFilesChanged(async (data) => {
|
||||
// Only refresh if this is the current thread
|
||||
if (data.threadId === currentThreadId) {
|
||||
console.log('[FilesystemPanel] Files changed, refreshing...')
|
||||
console.log("[FilesystemPanel] Files changed, refreshing...")
|
||||
try {
|
||||
const result = await window.api.workspace.loadFromDisk(data.threadId)
|
||||
if (result.success) {
|
||||
setWorkspaceFiles(result.files)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[FilesystemPanel] Error refreshing files:', e)
|
||||
console.error("[FilesystemPanel] Error refreshing files:", e)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
return cleanup
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentThreadId])
|
||||
|
||||
|
||||
// Handle selecting a workspace folder
|
||||
async function handleSelectFolder() {
|
||||
if (!currentThreadId || !setWorkspacePath || !setWorkspaceFiles) return
|
||||
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const path = await window.api.workspace.select(currentThreadId)
|
||||
@@ -75,16 +83,16 @@ export function FilesystemPanel() {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[FilesystemPanel] Select folder error:', e)
|
||||
console.error("[FilesystemPanel] Select folder error:", e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Handle refreshing files from disk
|
||||
async function handleRefresh() {
|
||||
if (!currentThreadId || !setWorkspaceFiles) return
|
||||
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await window.api.workspace.loadFromDisk(currentThreadId)
|
||||
@@ -92,20 +100,20 @@ export function FilesystemPanel() {
|
||||
setWorkspaceFiles(result.files)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[FilesystemPanel] Refresh error:', e)
|
||||
console.error("[FilesystemPanel] Refresh error:", e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize path to always start with /
|
||||
const normalizePath = (p: string) => p.startsWith('/') ? p : '/' + p
|
||||
const normalizePath = (p: string) => (p.startsWith("/") ? p : "/" + p)
|
||||
|
||||
// Get parent path, always returns / for root-level items
|
||||
const getParentPath = (p: string) => {
|
||||
const normalized = normalizePath(p)
|
||||
const lastSlash = normalized.lastIndexOf('/')
|
||||
if (lastSlash <= 0) return '/'
|
||||
const lastSlash = normalized.lastIndexOf("/")
|
||||
if (lastSlash <= 0) return "/"
|
||||
return normalized.substring(0, lastSlash)
|
||||
}
|
||||
|
||||
@@ -113,48 +121,48 @@ export function FilesystemPanel() {
|
||||
const buildTree = (files: FileInfo[]) => {
|
||||
const tree: Map<string, FileInfo[]> = new Map()
|
||||
const allDirs = new Set<string>()
|
||||
|
||||
|
||||
// First pass: collect all directories (both explicit and implicit)
|
||||
files.forEach(file => {
|
||||
files.forEach((file) => {
|
||||
const normalized = normalizePath(file.path)
|
||||
|
||||
|
||||
// Walk up the path to collect all parent directories
|
||||
let current = getParentPath(normalized)
|
||||
while (current !== '/') {
|
||||
while (current !== "/") {
|
||||
allDirs.add(current)
|
||||
current = getParentPath(current)
|
||||
}
|
||||
|
||||
|
||||
// If this is an explicit directory entry, add it
|
||||
if (file.is_dir) {
|
||||
const dirPath = normalized.endsWith('/') ? normalized.slice(0, -1) : normalized
|
||||
const dirPath = normalized.endsWith("/") ? normalized.slice(0, -1) : normalized
|
||||
allDirs.add(dirPath)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// Second pass: add files and directories to their parent's children list
|
||||
files.forEach(file => {
|
||||
const normalized = normalizePath(file.path.endsWith('/') ? file.path.slice(0, -1) : file.path)
|
||||
files.forEach((file) => {
|
||||
const normalized = normalizePath(file.path.endsWith("/") ? file.path.slice(0, -1) : file.path)
|
||||
const parentPath = getParentPath(normalized)
|
||||
|
||||
|
||||
if (!tree.has(parentPath)) {
|
||||
tree.set(parentPath, [])
|
||||
}
|
||||
|
||||
|
||||
// Use normalized path in the file info for consistent tree lookups
|
||||
tree.get(parentPath)!.push({
|
||||
...file,
|
||||
path: normalized
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
// Third pass: add implicit directories as entries
|
||||
allDirs.forEach(dir => {
|
||||
allDirs.forEach((dir) => {
|
||||
const parentPath = getParentPath(dir)
|
||||
|
||||
|
||||
// Check if this directory is already in parent's children
|
||||
const siblings = tree.get(parentPath) || []
|
||||
if (!siblings.some(f => f.path === dir)) {
|
||||
if (!siblings.some((f) => f.path === dir)) {
|
||||
if (!tree.has(parentPath)) {
|
||||
tree.set(parentPath, [])
|
||||
}
|
||||
@@ -164,7 +172,7 @@ export function FilesystemPanel() {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// Sort children: directories first, then alphabetically
|
||||
tree.forEach((children) => {
|
||||
children.sort((a, b) => {
|
||||
@@ -173,14 +181,14 @@ export function FilesystemPanel() {
|
||||
return a.path.localeCompare(b.path)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
return tree
|
||||
}
|
||||
|
||||
const tree = buildTree(workspaceFiles)
|
||||
|
||||
const toggleDir = (path: string) => {
|
||||
setExpandedDirs(prev => {
|
||||
setExpandedDirs((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(path)) {
|
||||
next.delete(path)
|
||||
@@ -192,7 +200,7 @@ export function FilesystemPanel() {
|
||||
}
|
||||
|
||||
const renderNode = (file: FileInfo, depth: number = 0) => {
|
||||
const name = file.path.split('/').pop() || file.path
|
||||
const name = file.path.split("/").pop() || file.path
|
||||
const isExpanded = expandedDirs.has(file.path)
|
||||
const children = tree.get(file.path) || []
|
||||
|
||||
@@ -201,7 +209,7 @@ export function FilesystemPanel() {
|
||||
<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",
|
||||
"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` }}
|
||||
>
|
||||
@@ -227,14 +235,14 @@ export function FilesystemPanel() {
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{file.is_dir && isExpanded && children.map(child => renderNode(child, depth + 1))}
|
||||
|
||||
{file.is_dir && isExpanded && children.map((child) => renderNode(child, depth + 1))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Get root level items (all paths are normalized to start with /)
|
||||
const rootItems = tree.get('/') || []
|
||||
const rootItems = tree.get("/") || []
|
||||
|
||||
// If no workspace is selected, show selection prompt
|
||||
if (!workspacePath) {
|
||||
@@ -274,8 +282,11 @@ export function FilesystemPanel() {
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-section-header">WORKSPACE</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-muted-foreground truncate max-w-[80px]" title={workspacePath}>
|
||||
{workspacePath.split('/').pop()}
|
||||
<span
|
||||
className="text-[10px] text-muted-foreground truncate max-w-[80px]"
|
||||
title={workspacePath}
|
||||
>
|
||||
{workspacePath.split("/").pop()}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -304,7 +315,7 @@ export function FilesystemPanel() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="py-2">
|
||||
{rootItems.length === 0 ? (
|
||||
@@ -316,7 +327,7 @@ export function FilesystemPanel() {
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
rootItems.map(file => renderNode(file))
|
||||
rootItems.map((file) => renderNode(file))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from 'react'
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
|
||||
import {
|
||||
ListTodo,
|
||||
FolderTree,
|
||||
@@ -22,13 +22,13 @@ import {
|
||||
FileJson,
|
||||
Image,
|
||||
FileType
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAppStore } from '@/lib/store'
|
||||
import { useThreadState } from '@/lib/thread-context'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { Todo } from '@/types'
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAppStore } from "@/lib/store"
|
||||
import { useThreadState } from "@/lib/thread-context"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { Todo } from "@/types"
|
||||
|
||||
const HEADER_HEIGHT = 40 // px
|
||||
const HANDLE_HEIGHT = 6 // px
|
||||
@@ -58,8 +58,8 @@ function SectionHeader({
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-3.5 text-muted-foreground transition-transform duration-200',
|
||||
isOpen && 'rotate-90'
|
||||
"size-3.5 text-muted-foreground transition-transform duration-200",
|
||||
isOpen && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
<Icon className="size-4" />
|
||||
@@ -90,16 +90,16 @@ function ResizeHandle({ onDrag }: ResizeHandleProps): React.JSX.Element {
|
||||
}
|
||||
|
||||
const handleMouseUp = (): void => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
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'
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
document.body.style.cursor = "row-resize"
|
||||
document.body.style.userSelect = "none"
|
||||
},
|
||||
[onDrag]
|
||||
)
|
||||
@@ -291,8 +291,8 @@ export function RightPanel(): React.JSX.Element {
|
||||
const handleMouseUp = (): void => {
|
||||
dragStartHeights.current = null
|
||||
}
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
return () => document.removeEventListener('mouseup', handleMouseUp)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
return () => document.removeEventListener("mouseup", handleMouseUp)
|
||||
}, [])
|
||||
|
||||
// Reset heights when panels open/close to redistribute
|
||||
@@ -375,27 +375,27 @@ export function RightPanel(): React.JSX.Element {
|
||||
const STATUS_CONFIG = {
|
||||
pending: {
|
||||
icon: Circle,
|
||||
badge: 'outline' as const,
|
||||
label: 'PENDING',
|
||||
color: 'text-muted-foreground'
|
||||
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'
|
||||
badge: "info" as const,
|
||||
label: "IN PROGRESS",
|
||||
color: "text-status-info"
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
badge: 'nominal' as const,
|
||||
label: 'DONE',
|
||||
color: 'text-status-nominal'
|
||||
badge: "nominal" as const,
|
||||
label: "DONE",
|
||||
color: "text-status-nominal"
|
||||
},
|
||||
cancelled: {
|
||||
icon: XCircle,
|
||||
badge: 'critical' as const,
|
||||
label: 'CANCELLED',
|
||||
color: 'text-muted-foreground'
|
||||
badge: "critical" as const,
|
||||
label: "CANCELLED",
|
||||
color: "text-muted-foreground"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,10 +415,10 @@ function TasksContent(): React.JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
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 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")
|
||||
|
||||
// Completed section includes both completed and cancelled
|
||||
const doneItems = [...completed, ...cancelled]
|
||||
@@ -490,17 +490,17 @@ function TasksContent(): React.JSX.Element {
|
||||
function TaskItem({ todo }: { todo: Todo }): React.JSX.Element {
|
||||
const config = STATUS_CONFIG[todo.status]
|
||||
const Icon = config.icon
|
||||
const isDone = todo.status === 'completed' || todo.status === 'cancelled'
|
||||
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'
|
||||
"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>
|
||||
<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>
|
||||
@@ -545,7 +545,7 @@ function FilesContent(): React.JSX.Element {
|
||||
const cleanup = window.api.workspace.onFilesChanged(async (data) => {
|
||||
// Only reload if the event is for the current thread
|
||||
if (data.threadId === currentThreadId) {
|
||||
console.log('[FilesContent] Files changed, reloading...', data)
|
||||
console.log("[FilesContent] Files changed, reloading...", data)
|
||||
const result = await window.api.workspace.loadFromDisk(currentThreadId)
|
||||
if (result.success && result.files) {
|
||||
setWorkspaceFiles(result.files)
|
||||
@@ -572,7 +572,7 @@ function FilesContent(): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[FilesContent] Select folder error:', e)
|
||||
console.error("[FilesContent] Select folder error:", e)
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
@@ -590,7 +590,7 @@ function FilesContent(): React.JSX.Element {
|
||||
}
|
||||
|
||||
// syncToDisk is not yet implemented
|
||||
console.warn('[FilesContent] syncToDisk is not yet implemented')
|
||||
console.warn("[FilesContent] syncToDisk is not yet implemented")
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -601,7 +601,7 @@ function FilesContent(): React.JSX.Element {
|
||||
className="text-[10px] text-muted-foreground truncate flex-1"
|
||||
title={workspacePath || undefined}
|
||||
>
|
||||
{workspacePath ? workspacePath.split('/').pop() : 'No folder linked'}
|
||||
{workspacePath ? workspacePath.split("/").pop() : "No folder linked"}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -613,10 +613,10 @@ function FilesContent(): React.JSX.Element {
|
||||
workspaceFiles.length > 0
|
||||
? workspacePath
|
||||
? `Sync to ${workspacePath}`
|
||||
: 'Sync files to disk'
|
||||
: "Sync files to disk"
|
||||
: workspacePath
|
||||
? `Change folder`
|
||||
: 'Link sync folder'
|
||||
: "Link sync folder"
|
||||
}
|
||||
>
|
||||
{syncing ? (
|
||||
@@ -629,7 +629,7 @@ function FilesContent(): React.JSX.Element {
|
||||
<FolderSync className="size-3" />
|
||||
)}
|
||||
<span className="ml-1">
|
||||
{workspaceFiles.length > 0 ? 'Sync' : workspacePath ? 'Change' : 'Link'}
|
||||
{workspaceFiles.length > 0 ? "Sync" : workspacePath ? "Change" : "Link"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -641,7 +641,7 @@ function FilesContent(): React.JSX.Element {
|
||||
<span>No workspace files</span>
|
||||
<span className="text-xs mt-1">
|
||||
{workspacePath
|
||||
? `Linked to ${workspacePath.split('/').pop()}`
|
||||
? `Linked to ${workspacePath.split("/").pop()}`
|
||||
: 'Click "Link" to set a sync folder'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -686,8 +686,8 @@ function buildFileTree(files: FileInfo[]): TreeNode[] {
|
||||
|
||||
for (const file of sortedFiles) {
|
||||
// Normalize path - remove leading slash
|
||||
const normalizedPath = file.path.startsWith('/') ? file.path.slice(1) : file.path
|
||||
const parts = normalizedPath.split('/')
|
||||
const normalizedPath = file.path.startsWith("/") ? file.path.slice(1) : file.path
|
||||
const parts = normalizedPath.split("/")
|
||||
const fileName = parts[parts.length - 1]
|
||||
|
||||
const node: TreeNode = {
|
||||
@@ -704,7 +704,7 @@ function buildFileTree(files: FileInfo[]): TreeNode[] {
|
||||
nodeMap.set(normalizedPath, node)
|
||||
} else {
|
||||
// Nested item - find or create parent directories
|
||||
let currentPath = ''
|
||||
let currentPath = ""
|
||||
let parentChildren = root
|
||||
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
@@ -715,7 +715,7 @@ function buildFileTree(files: FileInfo[]): TreeNode[] {
|
||||
// Create implicit directory node
|
||||
parentNode = {
|
||||
name: parts[i],
|
||||
path: '/' + currentPath,
|
||||
path: "/" + currentPath,
|
||||
is_dir: true,
|
||||
children: []
|
||||
}
|
||||
@@ -815,7 +815,7 @@ function FileTreeNode({
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 py-1 pr-3 text-xs hover:bg-background-interactive cursor-pointer'
|
||||
"flex items-center gap-1.5 py-1 pr-3 text-xs hover:bg-background-interactive cursor-pointer"
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
>
|
||||
@@ -881,38 +881,38 @@ function FileIcon({
|
||||
}
|
||||
|
||||
// Get file extension
|
||||
const ext = name.includes('.') ? name.split('.').pop()?.toLowerCase() : ''
|
||||
const ext = name.includes(".") ? name.split(".").pop()?.toLowerCase() : ""
|
||||
|
||||
// Map extensions to icons and colors
|
||||
switch (ext) {
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
case "ts":
|
||||
case "tsx":
|
||||
return <FileCode className="size-3.5 text-blue-400 shrink-0" />
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
case "js":
|
||||
case "jsx":
|
||||
return <FileCode className="size-3.5 text-yellow-400 shrink-0" />
|
||||
case 'json':
|
||||
case "json":
|
||||
return <FileJson className="size-3.5 text-yellow-600 shrink-0" />
|
||||
case 'md':
|
||||
case 'mdx':
|
||||
case "md":
|
||||
case "mdx":
|
||||
return <FileText className="size-3.5 text-muted-foreground shrink-0" />
|
||||
case 'py':
|
||||
case "py":
|
||||
return <FileCode className="size-3.5 text-green-400 shrink-0" />
|
||||
case 'css':
|
||||
case 'scss':
|
||||
case 'sass':
|
||||
case "css":
|
||||
case "scss":
|
||||
case "sass":
|
||||
return <FileCode className="size-3.5 text-pink-400 shrink-0" />
|
||||
case 'html':
|
||||
case "html":
|
||||
return <FileCode className="size-3.5 text-orange-400 shrink-0" />
|
||||
case 'svg':
|
||||
case 'png':
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'gif':
|
||||
case 'webp':
|
||||
case "svg":
|
||||
case "png":
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
case "gif":
|
||||
case "webp":
|
||||
return <Image className="size-3.5 text-purple-400 shrink-0" />
|
||||
case 'yml':
|
||||
case 'yaml':
|
||||
case "yml":
|
||||
case "yaml":
|
||||
return <FileType className="size-3.5 text-red-400 shrink-0" />
|
||||
default:
|
||||
return <File className="size-3.5 text-muted-foreground shrink-0" />
|
||||
@@ -943,11 +943,11 @@ function AgentsContent(): React.JSX.Element {
|
||||
<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'
|
||||
"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()}
|
||||
|
||||
@@ -7,14 +7,14 @@ import {
|
||||
Sparkles,
|
||||
Search,
|
||||
FileCheck
|
||||
} 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 { useThreadState } from '@/lib/thread-context'
|
||||
import type { Subagent } from '@/types'
|
||||
} 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 { useThreadState } from "@/lib/thread-context"
|
||||
import type { Subagent } from "@/types"
|
||||
|
||||
// Icon component for subagent type (avoid creating components during render)
|
||||
function SubagentTypeIcon({
|
||||
@@ -25,11 +25,11 @@ function SubagentTypeIcon({
|
||||
className?: string
|
||||
}): React.JSX.Element {
|
||||
switch (subagentType) {
|
||||
case 'correctness-checker':
|
||||
case "correctness-checker":
|
||||
return <FileCheck className={className} />
|
||||
case 'final-reviewer':
|
||||
case "final-reviewer":
|
||||
return <Search className={className} />
|
||||
case 'research':
|
||||
case "research":
|
||||
return <Search className={className} />
|
||||
default:
|
||||
return <Sparkles className={className} />
|
||||
@@ -39,16 +39,16 @@ function SubagentTypeIcon({
|
||||
// Get badge variant for subagent type
|
||||
function getSubagentTypeBadge(subagentType?: string): string {
|
||||
switch (subagentType) {
|
||||
case 'correctness-checker':
|
||||
return 'CHECKER'
|
||||
case 'final-reviewer':
|
||||
return 'REVIEWER'
|
||||
case 'research':
|
||||
return 'RESEARCH'
|
||||
case 'general-purpose':
|
||||
return 'GENERAL'
|
||||
case "correctness-checker":
|
||||
return "CHECKER"
|
||||
case "final-reviewer":
|
||||
return "REVIEWER"
|
||||
case "research":
|
||||
return "RESEARCH"
|
||||
case "general-purpose":
|
||||
return "GENERAL"
|
||||
default:
|
||||
return subagentType?.toUpperCase() || 'TASK'
|
||||
return subagentType?.toUpperCase() || "TASK"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,8 +58,8 @@ export function SubagentPanel(): React.JSX.Element {
|
||||
const subagents = threadState?.subagents ?? []
|
||||
|
||||
// Count by status
|
||||
const runningCount = subagents.filter((s) => s.status === 'running').length
|
||||
const completedCount = subagents.filter((s) => s.status === 'completed').length
|
||||
const runningCount = subagents.filter((s) => s.status === "running").length
|
||||
const completedCount = subagents.filter((s) => s.status === "completed").length
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -110,18 +110,18 @@ export function SubagentPanel(): React.JSX.Element {
|
||||
function SubagentCard({ subagent }: { subagent: Subagent }): React.JSX.Element {
|
||||
const getStatusConfig = (): {
|
||||
icon: React.ElementType
|
||||
badge: 'outline' | 'info' | 'nominal' | 'critical'
|
||||
badge: "outline" | "info" | "nominal" | "critical"
|
||||
label: string
|
||||
} => {
|
||||
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' }
|
||||
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" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,22 +142,22 @@ function SubagentCard({ subagent }: { subagent: Subagent }): React.JSX.Element {
|
||||
const duration = getDuration()
|
||||
|
||||
return (
|
||||
<Card className={cn(subagent.status === 'running' && 'border-status-info/50')}>
|
||||
<Card className={cn(subagent.status === "running" && "border-status-info/50")}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium truncate">
|
||||
<SubagentTypeIcon
|
||||
subagentType={subagent.subagentType}
|
||||
className={cn(
|
||||
'size-4 shrink-0',
|
||||
subagent.status === 'running' ? 'text-status-info' : 'text-muted-foreground'
|
||||
"size-4 shrink-0",
|
||||
subagent.status === "running" ? "text-status-info" : "text-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{subagent.name}</span>
|
||||
</CardTitle>
|
||||
<Badge variant={config.badge} className="shrink-0">
|
||||
<StatusIcon
|
||||
className={cn('size-3 mr-1', subagent.status === 'running' && 'animate-spin')}
|
||||
className={cn("size-3 mr-1", subagent.status === "running" && "animate-spin")}
|
||||
/>
|
||||
{config.label}
|
||||
</Badge>
|
||||
@@ -179,7 +179,7 @@ function SubagentCard({ subagent }: { subagent: Subagent }): React.JSX.Element {
|
||||
)}
|
||||
{duration && (
|
||||
<span className="flex items-center gap-1">
|
||||
{subagent.status === 'running' ? (
|
||||
{subagent.status === "running" ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="size-3" />
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
import { useState } from 'react'
|
||||
import { CheckCircle2, Circle, Clock, XCircle, ChevronRight, ChevronDown } from 'lucide-react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useAppStore } from '@/lib/store'
|
||||
import { useThreadState } from '@/lib/thread-context'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Todo } from '@/types'
|
||||
import { useState } from "react"
|
||||
import { CheckCircle2, Circle, Clock, XCircle, ChevronRight, ChevronDown } from "lucide-react"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useAppStore } from "@/lib/store"
|
||||
import { useThreadState } from "@/lib/thread-context"
|
||||
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'
|
||||
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'
|
||||
badge: "info" as const,
|
||||
label: "IN PROGRESS",
|
||||
color: "text-status-info"
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
badge: 'nominal' as const,
|
||||
label: 'DONE',
|
||||
color: 'text-status-nominal'
|
||||
badge: "nominal" as const,
|
||||
label: "DONE",
|
||||
color: "text-status-nominal"
|
||||
},
|
||||
cancelled: {
|
||||
icon: XCircle,
|
||||
badge: 'critical' as const,
|
||||
label: 'CANCELLED',
|
||||
color: 'text-muted-foreground'
|
||||
badge: "critical" as const,
|
||||
label: "CANCELLED",
|
||||
color: "text-muted-foreground"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,10 +41,10 @@ export function TodoPanel(): React.JSX.Element {
|
||||
const [completedExpanded, setCompletedExpanded] = useState(false)
|
||||
|
||||
// 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 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")
|
||||
|
||||
// Completed section includes both completed and cancelled
|
||||
const doneItems = [...completed, ...cancelled]
|
||||
@@ -139,17 +139,17 @@ function TodoItem({ todo }: { todo: Todo }): React.JSX.Element {
|
||||
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'
|
||||
"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)} />
|
||||
<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'
|
||||
"text-sm",
|
||||
(todo.status === "completed" || todo.status === "cancelled") && "line-through"
|
||||
)}
|
||||
>
|
||||
{todo.content}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, MessageSquare, Trash2, Pencil, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { useAppStore } from '@/lib/store'
|
||||
import { useThreadStream } from '@/lib/thread-context'
|
||||
import { cn, formatRelativeTime, truncate } from '@/lib/utils'
|
||||
import { useState } from "react"
|
||||
import { Plus, MessageSquare, Trash2, Pencil, Loader2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { useAppStore } from "@/lib/store"
|
||||
import { useThreadStream } from "@/lib/thread-context"
|
||||
import { cn, formatRelativeTime, truncate } from "@/lib/utils"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import type { Thread } from '@/types'
|
||||
} from "@/components/ui/context-menu"
|
||||
import type { Thread } from "@/types"
|
||||
|
||||
// Thread loading indicator that subscribes to the stream context
|
||||
function ThreadLoadingIcon({ threadId }: { threadId: string }): React.JSX.Element {
|
||||
@@ -53,10 +53,10 @@ function ThreadListItem({
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center gap-2 rounded-sm px-3 py-2 cursor-pointer transition-colors overflow-hidden',
|
||||
"group flex items-center gap-2 rounded-sm px-3 py-2 cursor-pointer transition-colors overflow-hidden",
|
||||
isSelected
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
||||
: 'hover:bg-sidebar-accent/50'
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "hover:bg-sidebar-accent/50"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isEditing) {
|
||||
@@ -73,8 +73,8 @@ function ThreadListItem({
|
||||
onChange={(e) => onEditingTitleChange(e.target.value)}
|
||||
onBlur={onSaveTitle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onSaveTitle()
|
||||
if (e.key === 'Escape') onCancelEditing()
|
||||
if (e.key === "Enter") onSaveTitle()
|
||||
if (e.key === "Escape") onCancelEditing()
|
||||
}}
|
||||
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
|
||||
@@ -110,10 +110,7 @@ function ThreadListItem({
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<ContextMenuItem variant="destructive" onClick={onDelete}>
|
||||
<Trash2 className="size-4 mr-2" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
@@ -123,21 +120,15 @@ function ThreadListItem({
|
||||
}
|
||||
|
||||
export function ThreadSidebar(): React.JSX.Element {
|
||||
const {
|
||||
threads,
|
||||
currentThreadId,
|
||||
createThread,
|
||||
selectThread,
|
||||
deleteThread,
|
||||
updateThread
|
||||
} = useAppStore()
|
||||
const { threads, currentThreadId, createThread, selectThread, deleteThread, updateThread } =
|
||||
useAppStore()
|
||||
|
||||
const [editingThreadId, setEditingThreadId] = useState<string | null>(null)
|
||||
const [editingTitle, setEditingTitle] = useState('')
|
||||
const [editingTitle, setEditingTitle] = useState("")
|
||||
|
||||
const startEditing = (threadId: string, currentTitle: string): void => {
|
||||
setEditingThreadId(threadId)
|
||||
setEditingTitle(currentTitle || '')
|
||||
setEditingTitle(currentTitle || "")
|
||||
}
|
||||
|
||||
const saveTitle = async (): Promise<void> => {
|
||||
@@ -145,12 +136,12 @@ export function ThreadSidebar(): React.JSX.Element {
|
||||
await updateThread(editingThreadId, { title: editingTitle.trim() })
|
||||
}
|
||||
setEditingThreadId(null)
|
||||
setEditingTitle('')
|
||||
setEditingTitle("")
|
||||
}
|
||||
|
||||
const cancelEditing = (): void => {
|
||||
setEditingThreadId(null)
|
||||
setEditingTitle('')
|
||||
setEditingTitle("")
|
||||
}
|
||||
|
||||
const handleNewThread = async (): Promise<void> => {
|
||||
@@ -160,8 +151,13 @@ export function ThreadSidebar(): React.JSX.Element {
|
||||
return (
|
||||
<aside className="flex h-full w-full flex-col border-r border-border bg-sidebar overflow-hidden">
|
||||
{/* New Thread Button - with dynamic safe area padding when zoomed out */}
|
||||
<div className="p-2" style={{ paddingTop: 'calc(8px + var(--sidebar-safe-padding, 0px))' }}>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start gap-2" onClick={handleNewThread}>
|
||||
<div className="p-2" style={{ paddingTop: "calc(8px + var(--sidebar-safe-padding, 0px))" }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={handleNewThread}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New Thread
|
||||
</Button>
|
||||
@@ -179,7 +175,7 @@ export function ThreadSidebar(): React.JSX.Element {
|
||||
editingTitle={editingTitle}
|
||||
onSelect={() => selectThread(thread.thread_id)}
|
||||
onDelete={() => deleteThread(thread.thread_id)}
|
||||
onStartEditing={() => startEditing(thread.thread_id, thread.title || '')}
|
||||
onStartEditing={() => startEditing(thread.thread_id, thread.title || "")}
|
||||
onSaveTitle={saveTitle}
|
||||
onCancelEditing={cancelEditing}
|
||||
onEditingTitleChange={setEditingTitle}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { File, Download } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { File, Download } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface BinaryFileViewerProps {
|
||||
filePath: string
|
||||
@@ -7,11 +7,11 @@ interface BinaryFileViewerProps {
|
||||
}
|
||||
|
||||
export function BinaryFileViewer({ filePath, size }: BinaryFileViewerProps): React.JSX.Element {
|
||||
const fileName = filePath.split('/').pop() || filePath
|
||||
const ext = fileName.includes('.') ? fileName.split('.').pop()?.toUpperCase() : 'FILE'
|
||||
const fileName = filePath.split("/").pop() || filePath
|
||||
const ext = fileName.includes(".") ? fileName.split(".").pop()?.toUpperCase() : "FILE"
|
||||
|
||||
const formatSize = (bytes?: number): string => {
|
||||
if (!bytes) return 'Unknown size'
|
||||
if (!bytes) return "Unknown size"
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
@@ -45,7 +45,8 @@ export function BinaryFileViewer({ filePath, size }: BinaryFileViewerProps): Rea
|
||||
{ext} file • {formatSize(size)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground max-w-md">
|
||||
This file type cannot be previewed in the viewer. You can open it with an external application.
|
||||
This file type cannot be previewed in the viewer. You can open it with an external
|
||||
application.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { createHighlighterCore, type HighlighterCore } from 'shiki/core'
|
||||
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
|
||||
import { useEffect, useState, useMemo } from "react"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { createHighlighterCore, type HighlighterCore } from "shiki/core"
|
||||
import { createJavaScriptRegexEngine } from "shiki/engine/javascript"
|
||||
|
||||
// Import bundled themes and languages
|
||||
import githubDarkDefault from 'shiki/themes/github-dark-default.mjs'
|
||||
import langTypescript from 'shiki/langs/typescript.mjs'
|
||||
import langTsx from 'shiki/langs/tsx.mjs'
|
||||
import langJavascript from 'shiki/langs/javascript.mjs'
|
||||
import langJsx from 'shiki/langs/jsx.mjs'
|
||||
import langPython from 'shiki/langs/python.mjs'
|
||||
import langJson from 'shiki/langs/json.mjs'
|
||||
import langCss from 'shiki/langs/css.mjs'
|
||||
import langHtml from 'shiki/langs/html.mjs'
|
||||
import langMarkdown from 'shiki/langs/markdown.mjs'
|
||||
import langYaml from 'shiki/langs/yaml.mjs'
|
||||
import langBash from 'shiki/langs/bash.mjs'
|
||||
import langSql from 'shiki/langs/sql.mjs'
|
||||
import githubDarkDefault from "shiki/themes/github-dark-default.mjs"
|
||||
import langTypescript from "shiki/langs/typescript.mjs"
|
||||
import langTsx from "shiki/langs/tsx.mjs"
|
||||
import langJavascript from "shiki/langs/javascript.mjs"
|
||||
import langJsx from "shiki/langs/jsx.mjs"
|
||||
import langPython from "shiki/langs/python.mjs"
|
||||
import langJson from "shiki/langs/json.mjs"
|
||||
import langCss from "shiki/langs/css.mjs"
|
||||
import langHtml from "shiki/langs/html.mjs"
|
||||
import langMarkdown from "shiki/langs/markdown.mjs"
|
||||
import langYaml from "shiki/langs/yaml.mjs"
|
||||
import langBash from "shiki/langs/bash.mjs"
|
||||
import langSql from "shiki/langs/sql.mjs"
|
||||
|
||||
// Singleton highlighter instance (using JS engine - no WASM needed)
|
||||
let highlighterPromise: Promise<HighlighterCore> | null = null
|
||||
@@ -26,9 +26,18 @@ async function getHighlighter(): Promise<HighlighterCore> {
|
||||
highlighterPromise = createHighlighterCore({
|
||||
themes: [githubDarkDefault],
|
||||
langs: [
|
||||
langTypescript, langTsx, langJavascript, langJsx,
|
||||
langPython, langJson, langCss, langHtml,
|
||||
langMarkdown, langYaml, langBash, langSql
|
||||
langTypescript,
|
||||
langTsx,
|
||||
langJavascript,
|
||||
langJsx,
|
||||
langPython,
|
||||
langJson,
|
||||
langCss,
|
||||
langHtml,
|
||||
langMarkdown,
|
||||
langYaml,
|
||||
langBash,
|
||||
langSql
|
||||
],
|
||||
engine: createJavaScriptRegexEngine()
|
||||
})
|
||||
@@ -43,33 +52,43 @@ interface CodeViewerProps {
|
||||
|
||||
// Map file extensions to Shiki language identifiers (only languages we've loaded)
|
||||
const SUPPORTED_LANGS = new Set([
|
||||
'typescript', 'tsx', 'javascript', 'jsx', 'python', 'json',
|
||||
'css', 'html', 'markdown', 'yaml', 'bash', 'sql'
|
||||
"typescript",
|
||||
"tsx",
|
||||
"javascript",
|
||||
"jsx",
|
||||
"python",
|
||||
"json",
|
||||
"css",
|
||||
"html",
|
||||
"markdown",
|
||||
"yaml",
|
||||
"bash",
|
||||
"sql"
|
||||
])
|
||||
|
||||
function getLanguage(ext: string | undefined): string | null {
|
||||
const langMap: Record<string, string> = {
|
||||
'ts': 'typescript',
|
||||
'tsx': 'tsx',
|
||||
'js': 'javascript',
|
||||
'jsx': 'jsx',
|
||||
'mjs': 'javascript',
|
||||
'cjs': 'javascript',
|
||||
'py': 'python',
|
||||
'json': 'json',
|
||||
'css': 'css',
|
||||
'html': 'html',
|
||||
'htm': 'html',
|
||||
'md': 'markdown',
|
||||
'mdx': 'markdown',
|
||||
'yaml': 'yaml',
|
||||
'yml': 'yaml',
|
||||
'sh': 'bash',
|
||||
'bash': 'bash',
|
||||
'zsh': 'bash',
|
||||
'sql': 'sql'
|
||||
ts: "typescript",
|
||||
tsx: "tsx",
|
||||
js: "javascript",
|
||||
jsx: "jsx",
|
||||
mjs: "javascript",
|
||||
cjs: "javascript",
|
||||
py: "python",
|
||||
json: "json",
|
||||
css: "css",
|
||||
html: "html",
|
||||
htm: "html",
|
||||
md: "markdown",
|
||||
mdx: "markdown",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
sh: "bash",
|
||||
bash: "bash",
|
||||
zsh: "bash",
|
||||
sql: "sql"
|
||||
}
|
||||
|
||||
|
||||
const lang = ext ? langMap[ext] : null
|
||||
return lang && SUPPORTED_LANGS.has(lang) ? lang : null
|
||||
}
|
||||
@@ -78,8 +97,8 @@ export function CodeViewer({ filePath, content }: CodeViewerProps) {
|
||||
const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null)
|
||||
|
||||
// Get file extension for syntax highlighting
|
||||
const fileName = filePath.split('/').pop() || filePath
|
||||
const ext = fileName.includes('.') ? fileName.split('.').pop()?.toLowerCase() : undefined
|
||||
const fileName = filePath.split("/").pop() || filePath
|
||||
const ext = fileName.includes(".") ? fileName.split(".").pop()?.toLowerCase() : undefined
|
||||
const language = useMemo(() => getLanguage(ext), [ext])
|
||||
|
||||
// Highlight code with Shiki
|
||||
@@ -93,22 +112,22 @@ export function CodeViewer({ filePath, content }: CodeViewerProps) {
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[CodeViewer] Starting highlight for', language)
|
||||
console.log("[CodeViewer] Starting highlight for", language)
|
||||
const highlighter = await getHighlighter()
|
||||
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
|
||||
const html = highlighter.codeToHtml(content, {
|
||||
lang: language,
|
||||
theme: 'github-dark-default'
|
||||
theme: "github-dark-default"
|
||||
})
|
||||
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
console.log('[CodeViewer] Highlighting complete, html length:', html.length)
|
||||
|
||||
console.log("[CodeViewer] Highlighting complete, html length:", html.length)
|
||||
setHighlightedHtml(html)
|
||||
} catch (e) {
|
||||
console.error('[CodeViewer] Shiki highlighting failed:', e)
|
||||
console.error("[CodeViewer] Shiki highlighting failed:", e)
|
||||
setHighlightedHtml(null)
|
||||
}
|
||||
}
|
||||
@@ -120,7 +139,7 @@ export function CodeViewer({ filePath, content }: CodeViewerProps) {
|
||||
}
|
||||
}, [content, language])
|
||||
|
||||
const lineCount = content?.split('\n').length ?? 0
|
||||
const lineCount = content?.split("\n").length ?? 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||
@@ -130,17 +149,14 @@ export function CodeViewer({ filePath, content }: CodeViewerProps) {
|
||||
<span className="text-muted-foreground/50">•</span>
|
||||
<span>{lineCount} lines</span>
|
||||
<span className="text-muted-foreground/50">•</span>
|
||||
<span className="text-muted-foreground/70">{language || 'plain text'}</span>
|
||||
<span className="text-muted-foreground/70">{language || "plain text"}</span>
|
||||
</div>
|
||||
|
||||
{/* File content with syntax highlighting */}
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="shiki-wrapper">
|
||||
{highlightedHtml ? (
|
||||
<div
|
||||
className="shiki-content"
|
||||
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
||||
/>
|
||||
<div className="shiki-content" dangerouslySetInnerHTML={{ __html: highlightedHtml }} />
|
||||
) : (
|
||||
// Fallback plain text rendering
|
||||
<pre className="p-4 text-sm font-mono leading-relaxed whitespace-pre-wrap break-all">
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { Loader2, AlertCircle, FileCode } from 'lucide-react'
|
||||
import { useCurrentThread } from '@/lib/thread-context'
|
||||
import { getFileType, isBinaryFile } from '@/lib/file-types'
|
||||
import { CodeViewer } from './CodeViewer'
|
||||
import { ImageViewer } from './ImageViewer'
|
||||
import { MediaViewer } from './MediaViewer'
|
||||
import { PDFViewer } from './PDFViewer'
|
||||
import { BinaryFileViewer } from './BinaryFileViewer'
|
||||
import { useEffect, useState, useMemo } from "react"
|
||||
import { Loader2, AlertCircle, FileCode } from "lucide-react"
|
||||
import { useCurrentThread } from "@/lib/thread-context"
|
||||
import { getFileType, isBinaryFile } from "@/lib/file-types"
|
||||
import { CodeViewer } from "./CodeViewer"
|
||||
import { ImageViewer } from "./ImageViewer"
|
||||
import { MediaViewer } from "./MediaViewer"
|
||||
import { PDFViewer } from "./PDFViewer"
|
||||
import { BinaryFileViewer } from "./BinaryFileViewer"
|
||||
|
||||
interface FileViewerProps {
|
||||
filePath: string
|
||||
@@ -21,7 +21,7 @@ export function FileViewer({ filePath, threadId }: FileViewerProps): React.JSX.E
|
||||
const [fileSize, setFileSize] = useState<number | undefined>()
|
||||
|
||||
// Get file type info
|
||||
const fileName = filePath.split('/').pop() || filePath
|
||||
const fileName = filePath.split("/").pop() || filePath
|
||||
const fileTypeInfo = useMemo(() => getFileType(fileName), [fileName])
|
||||
const isBinary = useMemo(() => isBinaryFile(fileName), [fileName])
|
||||
|
||||
@@ -54,7 +54,7 @@ export function FileViewer({ filePath, threadId }: FileViewerProps): React.JSX.E
|
||||
setBinaryContent(result.content)
|
||||
setFileSize(result.size)
|
||||
} else {
|
||||
setError(result.error || 'Failed to read file')
|
||||
setError(result.error || "Failed to read file")
|
||||
}
|
||||
} else {
|
||||
// Read as text file
|
||||
@@ -63,11 +63,11 @@ export function FileViewer({ filePath, threadId }: FileViewerProps): React.JSX.E
|
||||
setFileContents(filePath, result.content)
|
||||
setFileSize(result.size)
|
||||
} else {
|
||||
setError(result.error || 'Failed to read file')
|
||||
setError(result.error || "Failed to read file")
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to read file')
|
||||
setError(e instanceof Error ? e.message : "Failed to read file")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -107,43 +107,43 @@ export function FileViewer({ filePath, threadId }: FileViewerProps): React.JSX.E
|
||||
}
|
||||
|
||||
// Route to appropriate viewer based on file type
|
||||
if (fileTypeInfo.type === 'image' && binaryContent) {
|
||||
if (fileTypeInfo.type === "image" && binaryContent) {
|
||||
return (
|
||||
<ImageViewer
|
||||
filePath={filePath}
|
||||
base64Content={binaryContent}
|
||||
mimeType={fileTypeInfo.mimeType || 'image/png'}
|
||||
mimeType={fileTypeInfo.mimeType || "image/png"}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (fileTypeInfo.type === 'video' && binaryContent) {
|
||||
if (fileTypeInfo.type === "video" && binaryContent) {
|
||||
return (
|
||||
<MediaViewer
|
||||
filePath={filePath}
|
||||
base64Content={binaryContent}
|
||||
mimeType={fileTypeInfo.mimeType || 'video/mp4'}
|
||||
mimeType={fileTypeInfo.mimeType || "video/mp4"}
|
||||
mediaType="video"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (fileTypeInfo.type === 'audio' && binaryContent) {
|
||||
if (fileTypeInfo.type === "audio" && binaryContent) {
|
||||
return (
|
||||
<MediaViewer
|
||||
filePath={filePath}
|
||||
base64Content={binaryContent}
|
||||
mimeType={fileTypeInfo.mimeType || 'audio/mpeg'}
|
||||
mimeType={fileTypeInfo.mimeType || "audio/mpeg"}
|
||||
mediaType="audio"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (fileTypeInfo.type === 'pdf' && binaryContent) {
|
||||
if (fileTypeInfo.type === "pdf" && binaryContent) {
|
||||
return <PDFViewer filePath={filePath} base64Content={binaryContent} />
|
||||
}
|
||||
|
||||
if (fileTypeInfo.type === 'binary') {
|
||||
if (fileTypeInfo.type === "binary") {
|
||||
return <BinaryFileViewer filePath={filePath} size={fileSize} />
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { ZoomIn, ZoomOut, Maximize2, RotateCw, Hand } from 'lucide-react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useState, useRef } from "react"
|
||||
import { ZoomIn, ZoomOut, Maximize2, RotateCw, Hand } from "lucide-react"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface ImageViewerProps {
|
||||
filePath: string
|
||||
@@ -9,7 +9,11 @@ interface ImageViewerProps {
|
||||
mimeType: string
|
||||
}
|
||||
|
||||
export function ImageViewer({ filePath, base64Content, mimeType }: ImageViewerProps): React.JSX.Element {
|
||||
export function ImageViewer({
|
||||
filePath,
|
||||
base64Content,
|
||||
mimeType
|
||||
}: ImageViewerProps): React.JSX.Element {
|
||||
const [zoom, setZoom] = useState(100)
|
||||
const [rotation, setRotation] = useState(0)
|
||||
const [isPanning, setIsPanning] = useState(false)
|
||||
@@ -17,7 +21,7 @@ export function ImageViewer({ filePath, base64Content, mimeType }: ImageViewerPr
|
||||
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 })
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const fileName = filePath.split('/').pop() || filePath
|
||||
const fileName = filePath.split("/").pop() || filePath
|
||||
const imageUrl = `data:${mimeType};base64,${base64Content}`
|
||||
|
||||
const handleZoomIn = (): void => {
|
||||
@@ -73,7 +77,6 @@ export function ImageViewer({ filePath, base64Content, mimeType }: ImageViewerPr
|
||||
|
||||
// Reset pan when zoom changes to 100 or less
|
||||
|
||||
|
||||
const canPan = zoom > 100
|
||||
|
||||
return (
|
||||
@@ -106,9 +109,7 @@ export function ImageViewer({ filePath, base64Content, mimeType }: ImageViewerPr
|
||||
<ZoomOut className="size-4" />
|
||||
</Button>
|
||||
|
||||
<span className="text-xs text-muted-foreground min-w-[3rem] text-center">
|
||||
{zoom}%
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground min-w-[3rem] text-center">{zoom}%</span>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -120,21 +121,11 @@ export function ImageViewer({ filePath, base64Content, mimeType }: ImageViewerPr
|
||||
<ZoomIn className="size-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRotate}
|
||||
className="h-7 px-2"
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={handleRotate} className="h-7 px-2">
|
||||
<RotateCw className="size-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleResetZoom}
|
||||
className="h-7 px-2"
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={handleResetZoom} className="h-7 px-2">
|
||||
<Maximize2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -150,8 +141,8 @@ export function ImageViewer({ filePath, base64Content, mimeType }: ImageViewerPr
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
style={{
|
||||
cursor: canPan ? (isPanning ? 'grabbing' : 'grab') : 'default',
|
||||
userSelect: 'none'
|
||||
cursor: canPan ? (isPanning ? "grabbing" : "grab") : "default",
|
||||
userSelect: "none"
|
||||
}}
|
||||
>
|
||||
<img
|
||||
@@ -160,7 +151,7 @@ export function ImageViewer({ filePath, base64Content, mimeType }: ImageViewerPr
|
||||
className="max-w-full h-auto transition-transform duration-200"
|
||||
style={{
|
||||
transform: `translate(${panOffset.x}px, ${panOffset.y}px) scale(${zoom / 100}) rotate(${rotation}deg)`,
|
||||
imageRendering: zoom > 100 ? 'pixelated' : 'auto'
|
||||
imageRendering: zoom > 100 ? "pixelated" : "auto"
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { Music, Video } from 'lucide-react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Music, Video } from "lucide-react"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
|
||||
interface MediaViewerProps {
|
||||
filePath: string
|
||||
base64Content: string
|
||||
mimeType: string
|
||||
mediaType: 'video' | 'audio'
|
||||
mediaType: "video" | "audio"
|
||||
}
|
||||
|
||||
export function MediaViewer({ filePath, base64Content, mimeType, mediaType }: MediaViewerProps): React.JSX.Element {
|
||||
const fileName = filePath.split('/').pop() || filePath
|
||||
export function MediaViewer({
|
||||
filePath,
|
||||
base64Content,
|
||||
mimeType,
|
||||
mediaType
|
||||
}: MediaViewerProps): React.JSX.Element {
|
||||
const fileName = filePath.split("/").pop() || filePath
|
||||
const mediaUrl = `data:${mimeType};base64,${base64Content}`
|
||||
|
||||
return (
|
||||
@@ -24,7 +29,7 @@ export function MediaViewer({ filePath, base64Content, mimeType, mediaType }: Me
|
||||
{/* Media player */}
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="flex flex-col items-center justify-center min-h-full p-8 gap-6">
|
||||
{mediaType === 'video' ? (
|
||||
{mediaType === "video" ? (
|
||||
<>
|
||||
<Video className="size-16 text-muted-foreground/30" />
|
||||
<video
|
||||
@@ -47,11 +52,7 @@ export function MediaViewer({ filePath, base64Content, mimeType, mediaType }: Me
|
||||
<div className="text-sm text-muted-foreground">Audio File</div>
|
||||
</div>
|
||||
</div>
|
||||
<audio
|
||||
controls
|
||||
className="w-full max-w-md"
|
||||
preload="metadata"
|
||||
>
|
||||
<audio controls className="w-full max-w-md" preload="metadata">
|
||||
<source src={mediaUrl} type={mimeType} />
|
||||
Your browser does not support the audio tag.
|
||||
</audio>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FileText, ExternalLink } from 'lucide-react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FileText, ExternalLink } from "lucide-react"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface PDFViewerProps {
|
||||
filePath: string
|
||||
@@ -8,12 +8,12 @@ interface PDFViewerProps {
|
||||
}
|
||||
|
||||
export function PDFViewer({ filePath, base64Content }: PDFViewerProps): React.JSX.Element {
|
||||
const fileName = filePath.split('/').pop() || filePath
|
||||
const fileName = filePath.split("/").pop() || filePath
|
||||
const pdfUrl = `data:application/pdf;base64,${base64Content}`
|
||||
|
||||
const handleOpenExternal = (): void => {
|
||||
// Open in system default PDF viewer
|
||||
const link = document.createElement('a')
|
||||
const link = document.createElement("a")
|
||||
link.href = pdfUrl
|
||||
link.download = fileName
|
||||
link.click()
|
||||
@@ -29,12 +29,7 @@ export function PDFViewer({ filePath, base64Content }: PDFViewerProps): React.JS
|
||||
<span>PDF Document</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleOpenExternal}
|
||||
className="h-7 px-2 gap-1"
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={handleOpenExternal} className="h-7 px-2 gap-1">
|
||||
<ExternalLink className="size-3" />
|
||||
<span className="text-xs">Download</span>
|
||||
</Button>
|
||||
@@ -43,11 +38,7 @@ export function PDFViewer({ filePath, base64Content }: PDFViewerProps): React.JS
|
||||
{/* PDF embed */}
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="flex flex-col items-center min-h-full bg-muted/30">
|
||||
<object
|
||||
data={pdfUrl}
|
||||
type="application/pdf"
|
||||
className="w-full h-full min-h-[600px]"
|
||||
>
|
||||
<object data={pdfUrl} type="application/pdf" className="w-full h-full min-h-[600px]">
|
||||
{/* Fallback if PDF can't be displayed inline */}
|
||||
<div className="flex flex-col items-center justify-center min-h-[600px] gap-4 p-8">
|
||||
<FileText className="size-16 text-muted-foreground/50" />
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { Bot, X, FileCode, FileText, FileJson, File } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAppStore } from '@/lib/store'
|
||||
import { useThreadState, type OpenFile } from '@/lib/thread-context'
|
||||
import { Bot, X, FileCode, FileText, FileJson, File } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAppStore } from "@/lib/store"
|
||||
import { useThreadState, type OpenFile } from "@/lib/thread-context"
|
||||
|
||||
interface TabBarProps {
|
||||
className?: string
|
||||
threadId?: string
|
||||
}
|
||||
|
||||
export function TabBar({ className, threadId: propThreadId }: TabBarProps): React.JSX.Element | null {
|
||||
export function TabBar({
|
||||
className,
|
||||
threadId: propThreadId
|
||||
}: TabBarProps): React.JSX.Element | null {
|
||||
const { currentThreadId } = useAppStore()
|
||||
const threadId = propThreadId ?? currentThreadId
|
||||
const threadState = useThreadState(threadId)
|
||||
@@ -20,16 +23,18 @@ export function TabBar({ className, threadId: propThreadId }: TabBarProps): Reac
|
||||
const { openFiles, activeTab, setActiveTab, closeFile } = threadState
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex items-center h-9 border-b border-border bg-sidebar overflow-x-auto scrollbar-hide",
|
||||
className
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center h-9 border-b border-border bg-sidebar overflow-x-auto scrollbar-hide",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Agent Tab - Always first and prominent */}
|
||||
<button
|
||||
onClick={() => setActiveTab('agent')}
|
||||
onClick={() => setActiveTab("agent")}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 h-full text-sm font-medium transition-colors shrink-0 border-r border-border",
|
||||
activeTab === 'agent'
|
||||
activeTab === "agent"
|
||||
? "bg-primary/15 text-primary border-b-2 border-b-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background-interactive"
|
||||
)}
|
||||
@@ -104,23 +109,23 @@ function FileTab({ file, isActive, onSelect, onClose }: FileTabProps): React.JSX
|
||||
}
|
||||
|
||||
function FileIcon({ name }: { name: string }): React.JSX.Element {
|
||||
const ext = name.includes('.') ? name.split('.').pop()?.toLowerCase() : ''
|
||||
const ext = name.includes(".") ? name.split(".").pop()?.toLowerCase() : ""
|
||||
|
||||
switch (ext) {
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
case 'py':
|
||||
case 'css':
|
||||
case 'scss':
|
||||
case 'html':
|
||||
case "ts":
|
||||
case "tsx":
|
||||
case "js":
|
||||
case "jsx":
|
||||
case "py":
|
||||
case "css":
|
||||
case "scss":
|
||||
case "html":
|
||||
return <FileCode className="size-3.5 text-blue-400 shrink-0" />
|
||||
case 'json':
|
||||
case "json":
|
||||
return <FileJson className="size-3.5 text-yellow-500 shrink-0" />
|
||||
case 'md':
|
||||
case 'mdx':
|
||||
case 'txt':
|
||||
case "md":
|
||||
case "mdx":
|
||||
case "txt":
|
||||
return <FileText className="size-3.5 text-muted-foreground shrink-0" />
|
||||
default:
|
||||
return <File className="size-3.5 text-muted-foreground shrink-0" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCurrentThread } from '@/lib/thread-context'
|
||||
import { TabBar } from './TabBar'
|
||||
import { FileViewer } from './FileViewer'
|
||||
import { ChatContainer } from '@/components/chat/ChatContainer'
|
||||
import { useCurrentThread } from "@/lib/thread-context"
|
||||
import { TabBar } from "./TabBar"
|
||||
import { FileViewer } from "./FileViewer"
|
||||
import { ChatContainer } from "@/components/chat/ChatContainer"
|
||||
|
||||
interface TabbedPanelProps {
|
||||
threadId: string
|
||||
@@ -12,7 +12,7 @@ export function TabbedPanel({ threadId, showTabBar = true }: TabbedPanelProps):
|
||||
const { activeTab, openFiles } = useCurrentThread(threadId)
|
||||
|
||||
// Determine what to render based on active tab
|
||||
const isAgentTab = activeTab === 'agent'
|
||||
const isAgentTab = activeTab === "agent"
|
||||
const activeFile = openFiles.find((f) => f.path === activeTab)
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export { TabBar } from './TabBar'
|
||||
export { FileViewer } from './FileViewer'
|
||||
export { TabbedPanel } from './TabbedPanel'
|
||||
export { CodeViewer } from './CodeViewer'
|
||||
export { ImageViewer } from './ImageViewer'
|
||||
export { MediaViewer } from './MediaViewer'
|
||||
export { PDFViewer } from './PDFViewer'
|
||||
export { BinaryFileViewer } from './BinaryFileViewer'
|
||||
export { TabBar } from "./TabBar"
|
||||
export { FileViewer } from "./FileViewer"
|
||||
export { TabbedPanel } from "./TabbedPanel"
|
||||
export { CodeViewer } from "./CodeViewer"
|
||||
export { ImageViewer } from "./ImageViewer"
|
||||
export { MediaViewer } from "./MediaViewer"
|
||||
export { PDFViewer } from "./PDFViewer"
|
||||
export { BinaryFileViewer } from "./BinaryFileViewer"
|
||||
|
||||
@@ -15,23 +15,20 @@ const badgeVariants = cva(
|
||||
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",
|
||||
},
|
||||
info: "border-status-info/30 bg-status-info/15 text-status-info"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
variant: "default"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> { }
|
||||
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
|
||||
@@ -18,26 +18,25 @@ const buttonVariants = cva(
|
||||
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",
|
||||
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",
|
||||
},
|
||||
"icon-sm": "size-8"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
size: "default"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
@@ -45,11 +44,7 @@ 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}
|
||||
/>
|
||||
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,78 +1,51 @@
|
||||
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}
|
||||
/>
|
||||
))
|
||||
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}
|
||||
/>
|
||||
))
|
||||
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}
|
||||
/>
|
||||
))
|
||||
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}
|
||||
/>
|
||||
<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} />
|
||||
))
|
||||
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}
|
||||
/>
|
||||
))
|
||||
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 }
|
||||
|
||||
@@ -6,51 +6,32 @@ import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ContextMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
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} />
|
||||
)
|
||||
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 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 ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
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}
|
||||
/>
|
||||
)
|
||||
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
@@ -195,10 +176,7 @@ function ContextMenuLabel({
|
||||
<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
|
||||
)}
|
||||
className={cn("text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -217,17 +195,11 @@ function ContextMenuSeparator({
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
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
|
||||
)}
|
||||
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -248,5 +220,5 @@ export {
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
ContextMenuRadioGroup
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
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
|
||||
|
||||
@@ -18,7 +18,7 @@ const DialogOverlay = React.forwardRef<
|
||||
<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',
|
||||
"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}
|
||||
@@ -35,7 +35,7 @@ const DialogContent = React.forwardRef<
|
||||
<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',
|
||||
"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}
|
||||
@@ -50,27 +50,18 @@ const DialogContent = React.forwardRef<
|
||||
))
|
||||
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}
|
||||
/>
|
||||
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'
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
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)}
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
@@ -78,7 +69,7 @@ const DialogTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@@ -90,7 +81,7 @@ const DialogDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
|
||||
@@ -3,15 +3,11 @@ import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
@@ -37,9 +33,7 @@ function PopoverContent({
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
|
||||
@@ -9,29 +9,32 @@ interface ResizeHandleProps {
|
||||
|
||||
export function ResizeHandle({ onDrag }: ResizeHandleProps) {
|
||||
const startXRef = useRef<number>(0)
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
startXRef.current = e.clientX
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
// Calculate total delta from drag start
|
||||
const totalDelta = e.clientX - startXRef.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 = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
}, [onDrag])
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
startXRef.current = e.clientX
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
// Calculate total delta from drag start
|
||||
const totalDelta = e.clientX - startXRef.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 = "col-resize"
|
||||
document.body.style.userSelect = "none"
|
||||
},
|
||||
[onDrag]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -29,10 +29,8 @@ const ScrollBar = React.forwardRef<
|
||||
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]",
|
||||
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}
|
||||
|
||||
@@ -5,24 +5,19 @@ 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}
|
||||
/>
|
||||
)
|
||||
)
|
||||
>(({ 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 }
|
||||
|
||||
+46
-37
@@ -64,50 +64,50 @@
|
||||
:root {
|
||||
/* Foundation */
|
||||
--radius: 3px;
|
||||
--background: #0D0D0F;
|
||||
--background: #0d0d0f;
|
||||
--background-elevated: #141418;
|
||||
--background-interactive: #1C1C22;
|
||||
--foreground: #E8E8EC;
|
||||
--background-interactive: #1c1c22;
|
||||
--foreground: #e8e8ec;
|
||||
|
||||
/* Borders */
|
||||
--border: #2A2A32;
|
||||
--border-emphasis: #3A3A45;
|
||||
--input: #2A2A32;
|
||||
--ring: #3B82F6;
|
||||
--border: #2a2a32;
|
||||
--border-emphasis: #3a3a45;
|
||||
--input: #2a2a32;
|
||||
--ring: #3b82f6;
|
||||
|
||||
/* Text hierarchy */
|
||||
--muted: #141418;
|
||||
--muted-foreground: #8A8A96;
|
||||
--tertiary-foreground: #5A5A66;
|
||||
--muted-foreground: #8a8a96;
|
||||
--tertiary-foreground: #5a5a66;
|
||||
|
||||
/* Semantic mapping for shadcn compatibility */
|
||||
--card: #141418;
|
||||
--card-foreground: #E8E8EC;
|
||||
--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;
|
||||
--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;
|
||||
--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;
|
||||
--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 {
|
||||
@@ -116,7 +116,7 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', ui-monospace, monospace;
|
||||
font-family: "JetBrains Mono", "Fira Code", "SF Mono", ui-monospace, monospace;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +188,8 @@
|
||||
|
||||
/* Pulse animation for live indicators */
|
||||
@keyframes tactical-pulse {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
@@ -253,12 +254,20 @@
|
||||
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 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 h6 {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.streaming-markdown ul,
|
||||
.streaming-markdown ol {
|
||||
@@ -387,7 +396,7 @@
|
||||
|
||||
.app-badge-version {
|
||||
font-size: 11px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
color: color-mix(in srgb, var(--primary) 70%, transparent);
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -406,7 +415,7 @@
|
||||
}
|
||||
|
||||
.shiki-content code {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', ui-monospace, monospace;
|
||||
font-family: "JetBrains Mono", "Fira Code", "SF Mono", ui-monospace, monospace;
|
||||
font-size: inherit;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { UseStreamTransport } from '@langchain/langgraph-sdk/react'
|
||||
import type { ToolCall, ToolCallChunk } from '@langchain/core/messages'
|
||||
import type { StreamPayload, StreamEvent, IPCEvent, IPCStreamEvent } from '../../../types'
|
||||
import type { Subagent } from '../types'
|
||||
import type { UseStreamTransport } from "@langchain/langgraph-sdk/react"
|
||||
import type { ToolCall, ToolCallChunk } from "@langchain/core/messages"
|
||||
import type { StreamPayload, StreamEvent, IPCEvent, IPCStreamEvent } from "../../../types"
|
||||
import type { Subagent } from "../types"
|
||||
|
||||
/**
|
||||
* Usage metadata from LangChain model responses.
|
||||
@@ -102,7 +102,7 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
const threadId = payload.config?.configurable?.thread_id
|
||||
const modelId = payload.config?.configurable?.model_id as string | undefined
|
||||
if (!threadId) {
|
||||
return this.createErrorGenerator('MISSING_THREAD_ID', 'Thread ID is required')
|
||||
return this.createErrorGenerator("MISSING_THREAD_ID", "Thread ID is required")
|
||||
}
|
||||
|
||||
// Check if this is a resume command (no message needed)
|
||||
@@ -114,21 +114,27 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
| null
|
||||
| undefined
|
||||
const messages = input?.messages ?? []
|
||||
const lastHumanMessage = messages.find((m) => m.type === 'human')
|
||||
const messageContent = lastHumanMessage?.content ?? ''
|
||||
const lastHumanMessage = messages.find((m) => m.type === "human")
|
||||
const messageContent = lastHumanMessage?.content ?? ""
|
||||
|
||||
// Only require message content if not resuming
|
||||
if (!messageContent && !hasResumeCommand) {
|
||||
return this.createErrorGenerator('MISSING_MESSAGE', 'Message content is required')
|
||||
return this.createErrorGenerator("MISSING_MESSAGE", "Message content is required")
|
||||
}
|
||||
|
||||
// Create an async generator that bridges IPC events
|
||||
return this.createStreamGenerator(threadId, messageContent, payload.command, payload.signal, modelId)
|
||||
return this.createStreamGenerator(
|
||||
threadId,
|
||||
messageContent,
|
||||
payload.command,
|
||||
payload.signal,
|
||||
modelId
|
||||
)
|
||||
}
|
||||
|
||||
private async *createErrorGenerator(code: string, message: string): AsyncGenerator<StreamEvent> {
|
||||
yield {
|
||||
event: 'error',
|
||||
event: "error",
|
||||
data: { error: code, message }
|
||||
}
|
||||
}
|
||||
@@ -151,7 +157,7 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
|
||||
// Emit metadata event first to establish run context
|
||||
yield {
|
||||
event: 'metadata',
|
||||
event: "metadata",
|
||||
data: {
|
||||
run_id: runId,
|
||||
thread_id: threadId
|
||||
@@ -159,31 +165,37 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
}
|
||||
|
||||
// Start the stream via IPC (pass modelId to use the selected model)
|
||||
const cleanup = window.api.agent.streamAgent(threadId, message, command, (ipcEvent) => {
|
||||
// Convert IPC events to SDK format
|
||||
const sdkEvents = this.convertToSDKEvents(ipcEvent as IPCEvent, threadId)
|
||||
const cleanup = window.api.agent.streamAgent(
|
||||
threadId,
|
||||
message,
|
||||
command,
|
||||
(ipcEvent) => {
|
||||
// Convert IPC events to SDK format
|
||||
const sdkEvents = this.convertToSDKEvents(ipcEvent as IPCEvent, threadId)
|
||||
|
||||
for (const sdkEvent of sdkEvents) {
|
||||
if (sdkEvent.event === 'done' || sdkEvent.event === 'error') {
|
||||
isDone = true
|
||||
hasError = sdkEvent.event === 'error'
|
||||
}
|
||||
for (const sdkEvent of sdkEvents) {
|
||||
if (sdkEvent.event === "done" || sdkEvent.event === "error") {
|
||||
isDone = true
|
||||
hasError = sdkEvent.event === "error"
|
||||
}
|
||||
|
||||
// If someone is waiting for the next event, resolve immediately
|
||||
if (resolveNext) {
|
||||
const resolve = resolveNext
|
||||
resolveNext = null
|
||||
resolve(sdkEvent)
|
||||
} else {
|
||||
// Otherwise queue the event
|
||||
eventQueue.push(sdkEvent)
|
||||
// If someone is waiting for the next event, resolve immediately
|
||||
if (resolveNext) {
|
||||
const resolve = resolveNext
|
||||
resolveNext = null
|
||||
resolve(sdkEvent)
|
||||
} else {
|
||||
// Otherwise queue the event
|
||||
eventQueue.push(sdkEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, modelId)
|
||||
},
|
||||
modelId
|
||||
)
|
||||
|
||||
// Handle abort signal
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', () => {
|
||||
signal.addEventListener("abort", () => {
|
||||
cleanup()
|
||||
isDone = true
|
||||
if (resolveNext) {
|
||||
@@ -199,10 +211,10 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
// Check for queued events first
|
||||
if (eventQueue.length > 0) {
|
||||
const event = eventQueue.shift()!
|
||||
if (event.event === 'done') {
|
||||
if (event.event === "done") {
|
||||
break
|
||||
}
|
||||
if (event.event !== 'error' || hasError) {
|
||||
if (event.event !== "error" || hasError) {
|
||||
yield event
|
||||
}
|
||||
if (hasError) {
|
||||
@@ -220,13 +232,13 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
break
|
||||
}
|
||||
|
||||
if (event.event === 'done') {
|
||||
if (event.event === "done") {
|
||||
break
|
||||
}
|
||||
|
||||
yield event
|
||||
|
||||
if (event.event === 'error') {
|
||||
if (event.event === "error") {
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -241,29 +253,29 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
|
||||
switch (event.type) {
|
||||
// Raw stream events from LangGraph - parse and convert
|
||||
case 'stream': {
|
||||
case "stream": {
|
||||
const streamEvents = this.processStreamEvent(event)
|
||||
events.push(...streamEvents)
|
||||
break
|
||||
}
|
||||
|
||||
// Legacy: Token streaming for real-time typing effect
|
||||
case 'token':
|
||||
case "token":
|
||||
events.push({
|
||||
event: 'messages',
|
||||
event: "messages",
|
||||
data: [
|
||||
{ id: event.messageId, type: 'ai', content: event.token },
|
||||
{ langgraph_node: 'agent' }
|
||||
{ id: event.messageId, type: "ai", content: event.token },
|
||||
{ langgraph_node: "agent" }
|
||||
]
|
||||
})
|
||||
break
|
||||
|
||||
// Legacy: Tool call chunks
|
||||
case 'tool_call':
|
||||
case "tool_call":
|
||||
events.push({
|
||||
event: 'custom',
|
||||
event: "custom",
|
||||
data: {
|
||||
type: 'tool_call',
|
||||
type: "tool_call",
|
||||
messageId: event.messageId,
|
||||
tool_calls: event.tool_calls
|
||||
}
|
||||
@@ -271,14 +283,14 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
break
|
||||
|
||||
// Legacy: Full state values
|
||||
case 'values': {
|
||||
case "values": {
|
||||
const { todos, files, workspacePath, subagents, interrupt } = event.data
|
||||
|
||||
// Only emit values event if todos is defined
|
||||
// Avoid emitting { todos: [] } when undefined, which would wipe out existing todos
|
||||
if (todos !== undefined) {
|
||||
events.push({
|
||||
event: 'values',
|
||||
event: "values",
|
||||
data: { todos }
|
||||
})
|
||||
}
|
||||
@@ -291,15 +303,15 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
path,
|
||||
is_dir: false,
|
||||
size:
|
||||
typeof (data as { content?: string })?.content === 'string'
|
||||
typeof (data as { content?: string })?.content === "string"
|
||||
? (data as { content: string }).content.length
|
||||
: undefined
|
||||
}))
|
||||
|
||||
if (filesList.length) {
|
||||
events.push({
|
||||
event: 'custom',
|
||||
data: { type: 'workspace', files: filesList, path: workspacePath || '/' }
|
||||
event: "custom",
|
||||
data: { type: "workspace", files: filesList, path: workspacePath || "/" }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -307,8 +319,8 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
// Emit subagents
|
||||
if (subagents?.length) {
|
||||
events.push({
|
||||
event: 'custom',
|
||||
data: { type: 'subagents', subagents }
|
||||
event: "custom",
|
||||
data: { type: "subagents", subagents }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -327,9 +339,9 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
)
|
||||
|
||||
events.push({
|
||||
event: 'custom',
|
||||
event: "custom",
|
||||
data: {
|
||||
type: 'interrupt',
|
||||
type: "interrupt",
|
||||
request: {
|
||||
id: firstAction.id || crypto.randomUUID(),
|
||||
tool_call: {
|
||||
@@ -338,9 +350,9 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
args: firstAction.args || {}
|
||||
},
|
||||
allowed_decisions: reviewConfig?.allowedDecisions || [
|
||||
'approve',
|
||||
'reject',
|
||||
'edit'
|
||||
"approve",
|
||||
"reject",
|
||||
"edit"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -349,13 +361,13 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
} else if (interrupt.tool_call) {
|
||||
// Legacy format with direct tool_call property
|
||||
events.push({
|
||||
event: 'custom',
|
||||
event: "custom",
|
||||
data: {
|
||||
type: 'interrupt',
|
||||
type: "interrupt",
|
||||
request: {
|
||||
id: interrupt.id || crypto.randomUUID(),
|
||||
tool_call: interrupt.tool_call,
|
||||
allowed_decisions: ['approve', 'reject', 'edit']
|
||||
allowed_decisions: ["approve", "reject", "edit"]
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -364,25 +376,25 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
break
|
||||
}
|
||||
|
||||
case 'error':
|
||||
case "error":
|
||||
events.push({
|
||||
event: 'error',
|
||||
data: { error: 'STREAM_ERROR', message: event.error }
|
||||
event: "error",
|
||||
data: { error: "STREAM_ERROR", message: event.error }
|
||||
})
|
||||
break
|
||||
|
||||
case 'done':
|
||||
case "done":
|
||||
events.push({
|
||||
event: 'done',
|
||||
event: "done",
|
||||
data: { thread_id: threadId }
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[Transport] convertToSDKEvents total:',
|
||||
"[Transport] convertToSDKEvents total:",
|
||||
events.length,
|
||||
'events',
|
||||
"events",
|
||||
events.map((e) => e.event)
|
||||
)
|
||||
return events
|
||||
@@ -395,20 +407,20 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
const events: StreamEvent[] = []
|
||||
const { mode, data } = event
|
||||
|
||||
if (mode === 'messages') {
|
||||
if (mode === "messages") {
|
||||
// Messages mode returns [message, metadata] tuples
|
||||
const [msgChunk, metadata] = data as [SerializedMessageChunk, MessageMetadata]
|
||||
|
||||
// LangChain serialization: actual data is in kwargs
|
||||
const kwargs = msgChunk?.kwargs || {}
|
||||
const classId = Array.isArray(msgChunk?.id) ? msgChunk.id : []
|
||||
const className = classId[classId.length - 1] || ''
|
||||
const className = classId[classId.length - 1] || ""
|
||||
|
||||
// Check if this is a ToolMessage (class name contains 'ToolMessage')
|
||||
const isToolMessage = className.includes('ToolMessage') && !!kwargs.tool_call_id
|
||||
const isToolMessage = className.includes("ToolMessage") && !!kwargs.tool_call_id
|
||||
|
||||
// Check if this is an AI message (class name contains 'AI')
|
||||
const isAIMessage = className.includes('AI') || className.includes('AIMessageChunk')
|
||||
const isAIMessage = className.includes("AI") || className.includes("AIMessageChunk")
|
||||
|
||||
if (isAIMessage) {
|
||||
const content = this.extractContent(kwargs.content)
|
||||
@@ -417,16 +429,16 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
|
||||
if (content || kwargs.tool_calls?.length) {
|
||||
events.push({
|
||||
event: 'messages',
|
||||
event: "messages",
|
||||
data: [
|
||||
{
|
||||
id: msgId,
|
||||
type: 'ai',
|
||||
content: content || '',
|
||||
type: "ai",
|
||||
content: content || "",
|
||||
// Include tool_calls if present
|
||||
...(kwargs.tool_calls?.length && { tool_calls: kwargs.tool_calls })
|
||||
},
|
||||
{ langgraph_node: metadata?.langgraph_node || 'agent' }
|
||||
{ langgraph_node: metadata?.langgraph_node || "agent" }
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -437,9 +449,9 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
events.push(...subagentEvents)
|
||||
|
||||
events.push({
|
||||
event: 'custom',
|
||||
event: "custom",
|
||||
data: {
|
||||
type: 'tool_call',
|
||||
type: "tool_call",
|
||||
messageId: this.currentMessageId,
|
||||
tool_calls: kwargs.tool_call_chunks
|
||||
}
|
||||
@@ -465,7 +477,7 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
// Usage metadata is present on completed AI messages (not streaming chunks)
|
||||
const usageMetadata = kwargs.usage_metadata || kwargs.response_metadata?.usage
|
||||
if (usageMetadata) {
|
||||
console.log('[ElectronTransport] Found usage_metadata:', {
|
||||
console.log("[ElectronTransport] Found usage_metadata:", {
|
||||
input_tokens: usageMetadata.input_tokens,
|
||||
output_tokens: usageMetadata.output_tokens,
|
||||
total_tokens: usageMetadata.total_tokens,
|
||||
@@ -475,9 +487,9 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
// Only emit if we have actual token counts (not on every chunk)
|
||||
if (usageMetadata.input_tokens !== undefined && usageMetadata.input_tokens > 0) {
|
||||
events.push({
|
||||
event: 'custom',
|
||||
event: "custom",
|
||||
data: {
|
||||
type: 'token_usage',
|
||||
type: "token_usage",
|
||||
usage: {
|
||||
inputTokens: usageMetadata.input_tokens,
|
||||
outputTokens: usageMetadata.output_tokens,
|
||||
@@ -498,26 +510,26 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
|
||||
// Emit tool message to the stream
|
||||
events.push({
|
||||
event: 'messages',
|
||||
event: "messages",
|
||||
data: [
|
||||
{
|
||||
id: msgId,
|
||||
type: 'tool',
|
||||
type: "tool",
|
||||
content,
|
||||
tool_call_id: kwargs.tool_call_id,
|
||||
name: kwargs.name
|
||||
},
|
||||
{ langgraph_node: metadata?.langgraph_node || 'tools' }
|
||||
{ langgraph_node: metadata?.langgraph_node || "tools" }
|
||||
]
|
||||
})
|
||||
|
||||
// Handle subagent task completion
|
||||
if (kwargs.name === 'task') {
|
||||
if (kwargs.name === "task") {
|
||||
const completionEvents = this.processToolMessage(kwargs.tool_call_id)
|
||||
events.push(...completionEvents)
|
||||
}
|
||||
}
|
||||
} else if (mode === 'values') {
|
||||
} else if (mode === "values") {
|
||||
// Values mode returns full state with serialized LangChain messages
|
||||
const state = data as {
|
||||
messages?: SerializedMessageChunk[]
|
||||
@@ -538,13 +550,13 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
for (const msg of state.messages) {
|
||||
const kwargs = msg.kwargs || {}
|
||||
const classId = Array.isArray(msg.id) ? msg.id : []
|
||||
const className = classId[classId.length - 1] || ''
|
||||
const className = classId[classId.length - 1] || ""
|
||||
|
||||
// Check for task tool calls in AI messages
|
||||
if (kwargs.tool_calls?.length) {
|
||||
for (const toolCall of kwargs.tool_calls) {
|
||||
if (
|
||||
toolCall.name === 'task' &&
|
||||
toolCall.name === "task" &&
|
||||
toolCall.id &&
|
||||
!this.activeSubagents.has(toolCall.id)
|
||||
) {
|
||||
@@ -558,10 +570,10 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
}
|
||||
|
||||
// Check for ToolMessage (subagent completion)
|
||||
if (className.includes('ToolMessage') && kwargs.tool_call_id && kwargs.name === 'task') {
|
||||
if (className.includes("ToolMessage") && kwargs.tool_call_id && kwargs.name === "task") {
|
||||
const subagent = this.activeSubagents.get(kwargs.tool_call_id)
|
||||
if (subagent && subagent.status === 'running') {
|
||||
subagent.status = 'completed'
|
||||
if (subagent && subagent.status === "running") {
|
||||
subagent.status = "completed"
|
||||
subagent.completedAt = new Date()
|
||||
}
|
||||
}
|
||||
@@ -578,17 +590,17 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
const transformedMessages = state.messages
|
||||
?.filter((msg) => {
|
||||
const classId = Array.isArray(msg.id) ? msg.id : []
|
||||
const className = classId[classId.length - 1] || ''
|
||||
const className = classId[classId.length - 1] || ""
|
||||
// Filter out HumanMessage
|
||||
return !className.includes('Human')
|
||||
return !className.includes("Human")
|
||||
})
|
||||
.map((msg) => {
|
||||
const kwargs = msg.kwargs || {}
|
||||
const classId = Array.isArray(msg.id) ? msg.id : []
|
||||
const className = classId[classId.length - 1] || ''
|
||||
const className = classId[classId.length - 1] || ""
|
||||
|
||||
// Determine message type from class name
|
||||
const type: 'ai' | 'tool' = className.includes('Tool') ? 'tool' : 'ai'
|
||||
const type: "ai" | "tool" = className.includes("Tool") ? "tool" : "ai"
|
||||
const content = this.extractContent(kwargs.content)
|
||||
|
||||
return {
|
||||
@@ -596,10 +608,10 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
type,
|
||||
content,
|
||||
// Include tool_calls for AI messages
|
||||
...(type === 'ai' && kwargs.tool_calls && { tool_calls: kwargs.tool_calls }),
|
||||
...(type === "ai" && kwargs.tool_calls && { tool_calls: kwargs.tool_calls }),
|
||||
// Include tool_call_id and name for tool messages
|
||||
...(type === 'tool' && kwargs.tool_call_id && { tool_call_id: kwargs.tool_call_id }),
|
||||
...(type === 'tool' && kwargs.name && { name: kwargs.name })
|
||||
...(type === "tool" && kwargs.tool_call_id && { tool_call_id: kwargs.tool_call_id }),
|
||||
...(type === "tool" && kwargs.name && { name: kwargs.name })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -619,7 +631,7 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
// Only emit if we have something to update
|
||||
if (Object.keys(valuesData).length > 0) {
|
||||
events.push({
|
||||
event: 'values',
|
||||
event: "values",
|
||||
data: valuesData
|
||||
})
|
||||
}
|
||||
@@ -632,15 +644,15 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
path,
|
||||
is_dir: false,
|
||||
size:
|
||||
typeof (fileData as { content?: string })?.content === 'string'
|
||||
typeof (fileData as { content?: string })?.content === "string"
|
||||
? (fileData as { content: string }).content.length
|
||||
: undefined
|
||||
}))
|
||||
|
||||
if (filesList.length) {
|
||||
events.push({
|
||||
event: 'custom',
|
||||
data: { type: 'workspace', files: filesList, path: state.workspacePath || '/' }
|
||||
event: "custom",
|
||||
data: { type: "workspace", files: filesList, path: state.workspacePath || "/" }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -670,9 +682,9 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
}
|
||||
|
||||
events.push({
|
||||
event: 'custom',
|
||||
event: "custom",
|
||||
data: {
|
||||
type: 'interrupt',
|
||||
type: "interrupt",
|
||||
request: {
|
||||
id: toolCallId || crypto.randomUUID(),
|
||||
tool_call: {
|
||||
@@ -680,7 +692,7 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
name: firstAction.name,
|
||||
args: firstAction.args || {}
|
||||
},
|
||||
allowed_decisions: reviewConfig?.allowedDecisions || ['approve', 'reject', 'edit']
|
||||
allowed_decisions: reviewConfig?.allowedDecisions || ["approve", "reject", "edit"]
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -697,16 +709,16 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
private extractContent(
|
||||
content: string | Array<{ type: string; text?: string }> | undefined
|
||||
): string {
|
||||
if (typeof content === 'string') {
|
||||
if (typeof content === "string") {
|
||||
return content
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter((block): block is { type: 'text'; text: string } => block.type === 'text')
|
||||
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join('')
|
||||
.join("")
|
||||
}
|
||||
return ''
|
||||
return ""
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -724,7 +736,7 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
// Get or create accumulated tool call
|
||||
let accumulated = this.accumulatedToolCalls.get(chunk.id)
|
||||
if (!accumulated) {
|
||||
accumulated = { id: chunk.id, name: chunk.name || '', args: '' }
|
||||
accumulated = { id: chunk.id, name: chunk.name || "", args: "" }
|
||||
this.accumulatedToolCalls.set(chunk.id, accumulated)
|
||||
}
|
||||
|
||||
@@ -739,7 +751,7 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
}
|
||||
|
||||
// Check if this is a "task" tool call and try to parse args
|
||||
if (accumulated.name === 'task') {
|
||||
if (accumulated.name === "task") {
|
||||
try {
|
||||
const args = JSON.parse(accumulated.args)
|
||||
// Only process if we haven't already created a subagent for this tool call
|
||||
@@ -769,7 +781,7 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
if (!toolCall.id || !toolCall.name) continue
|
||||
|
||||
// Check if this is a "task" tool call
|
||||
if (toolCall.name === 'task' && !this.activeSubagents.has(toolCall.id)) {
|
||||
if (toolCall.name === "task" && !this.activeSubagents.has(toolCall.id)) {
|
||||
const args = toolCall.args || {}
|
||||
if (args.subagent_type || args.description) {
|
||||
const subagent = this.createSubagentFromTask(toolCall.id, args)
|
||||
@@ -791,7 +803,7 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
// Check if this tool_call_id corresponds to an active subagent
|
||||
const subagent = this.activeSubagents.get(toolCallId)
|
||||
if (subagent) {
|
||||
subagent.status = 'completed'
|
||||
subagent.status = "completed"
|
||||
subagent.completedAt = new Date()
|
||||
events.push(this.createSubagentEvent())
|
||||
}
|
||||
@@ -803,16 +815,16 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
* Create a Subagent object from task tool call args
|
||||
*/
|
||||
private createSubagentFromTask(toolCallId: string, args: Record<string, unknown>): Subagent {
|
||||
const subagentType = (args.subagent_type as string) || 'general-purpose'
|
||||
const description = (args.description as string) || 'Executing task...'
|
||||
const subagentType = (args.subagent_type as string) || "general-purpose"
|
||||
const description = (args.description as string) || "Executing task..."
|
||||
|
||||
// Generate a friendly name from the subagent type
|
||||
const nameMap: Record<string, string> = {
|
||||
'general-purpose': 'General Purpose Agent',
|
||||
'correctness-checker': 'Correctness Checker',
|
||||
'final-reviewer': 'Final Reviewer',
|
||||
'code-reviewer': 'Code Reviewer',
|
||||
research: 'Research Agent'
|
||||
"general-purpose": "General Purpose Agent",
|
||||
"correctness-checker": "Correctness Checker",
|
||||
"final-reviewer": "Final Reviewer",
|
||||
"code-reviewer": "Code Reviewer",
|
||||
research: "Research Agent"
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -820,7 +832,7 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
toolCallId,
|
||||
name: nameMap[subagentType] || this.formatSubagentName(subagentType),
|
||||
description,
|
||||
status: 'running',
|
||||
status: "running",
|
||||
startedAt: new Date(),
|
||||
subagentType
|
||||
}
|
||||
@@ -831,9 +843,9 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
*/
|
||||
private formatSubagentName(subagentType: string): string {
|
||||
return subagentType
|
||||
.split('-')
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -841,9 +853,9 @@ export class ElectronIPCTransport implements UseStreamTransport {
|
||||
*/
|
||||
private createSubagentEvent(): StreamEvent {
|
||||
return {
|
||||
event: 'custom',
|
||||
event: "custom",
|
||||
data: {
|
||||
type: 'subagents',
|
||||
type: "subagents",
|
||||
subagents: Array.from(this.activeSubagents.values())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type FileType = 'image' | 'video' | 'audio' | 'pdf' | 'code' | 'text' | 'binary'
|
||||
export type FileType = "image" | "video" | "audio" | "pdf" | "code" | "text" | "binary"
|
||||
|
||||
interface FileTypeInfo {
|
||||
type: FileType
|
||||
@@ -7,50 +7,93 @@ interface FileTypeInfo {
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico', 'tiff', 'tif'
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"svg",
|
||||
"webp",
|
||||
"bmp",
|
||||
"ico",
|
||||
"tiff",
|
||||
"tif"
|
||||
])
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set([
|
||||
'mp4', 'webm', 'ogg', 'ogv', 'mov', 'avi', 'wmv', 'flv', 'mkv'
|
||||
])
|
||||
const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "ogg", "ogv", "mov", "avi", "wmv", "flv", "mkv"])
|
||||
|
||||
const AUDIO_EXTENSIONS = new Set([
|
||||
'mp3', 'wav', 'ogg', 'oga', 'm4a', 'flac', 'aac', 'weba'
|
||||
])
|
||||
const AUDIO_EXTENSIONS = new Set(["mp3", "wav", "ogg", "oga", "m4a", "flac", "aac", "weba"])
|
||||
|
||||
const PDF_EXTENSIONS = new Set(['pdf'])
|
||||
const PDF_EXTENSIONS = new Set(["pdf"])
|
||||
|
||||
const CODE_EXTENSIONS = new Set([
|
||||
'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs',
|
||||
'py', 'java', 'c', 'cpp', 'h', 'hpp',
|
||||
'cs', 'go', 'rs', 'rb', 'php',
|
||||
'json', 'xml', 'yaml', 'yml', 'toml',
|
||||
'css', 'scss', 'sass', 'less',
|
||||
'html', 'htm', 'vue', 'svelte',
|
||||
'md', 'mdx', 'markdown',
|
||||
'sh', 'bash', 'zsh', 'fish',
|
||||
'sql', 'graphql', 'proto',
|
||||
'dockerfile', 'makefile'
|
||||
"ts",
|
||||
"tsx",
|
||||
"js",
|
||||
"jsx",
|
||||
"mjs",
|
||||
"cjs",
|
||||
"py",
|
||||
"java",
|
||||
"c",
|
||||
"cpp",
|
||||
"h",
|
||||
"hpp",
|
||||
"cs",
|
||||
"go",
|
||||
"rs",
|
||||
"rb",
|
||||
"php",
|
||||
"json",
|
||||
"xml",
|
||||
"yaml",
|
||||
"yml",
|
||||
"toml",
|
||||
"css",
|
||||
"scss",
|
||||
"sass",
|
||||
"less",
|
||||
"html",
|
||||
"htm",
|
||||
"vue",
|
||||
"svelte",
|
||||
"md",
|
||||
"mdx",
|
||||
"markdown",
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"fish",
|
||||
"sql",
|
||||
"graphql",
|
||||
"proto",
|
||||
"dockerfile",
|
||||
"makefile"
|
||||
])
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
'txt', 'log', 'csv', 'tsv',
|
||||
'env', 'gitignore', 'editorconfig',
|
||||
'conf', 'config', 'ini', 'cfg'
|
||||
"txt",
|
||||
"log",
|
||||
"csv",
|
||||
"tsv",
|
||||
"env",
|
||||
"gitignore",
|
||||
"editorconfig",
|
||||
"conf",
|
||||
"config",
|
||||
"ini",
|
||||
"cfg"
|
||||
])
|
||||
|
||||
export function getFileType(fileName: string): FileTypeInfo {
|
||||
const ext = fileName.includes('.')
|
||||
? fileName.split('.').pop()?.toLowerCase()
|
||||
: undefined
|
||||
const ext = fileName.includes(".") ? fileName.split(".").pop()?.toLowerCase() : undefined
|
||||
|
||||
if (!ext) {
|
||||
return { type: 'text', canPreview: true }
|
||||
return { type: "text", canPreview: true }
|
||||
}
|
||||
|
||||
if (IMAGE_EXTENSIONS.has(ext)) {
|
||||
return {
|
||||
type: 'image',
|
||||
type: "image",
|
||||
mimeType: getMimeType(ext),
|
||||
canPreview: true
|
||||
}
|
||||
@@ -58,7 +101,7 @@ export function getFileType(fileName: string): FileTypeInfo {
|
||||
|
||||
if (VIDEO_EXTENSIONS.has(ext)) {
|
||||
return {
|
||||
type: 'video',
|
||||
type: "video",
|
||||
mimeType: getMimeType(ext),
|
||||
canPreview: true
|
||||
}
|
||||
@@ -66,7 +109,7 @@ export function getFileType(fileName: string): FileTypeInfo {
|
||||
|
||||
if (AUDIO_EXTENSIONS.has(ext)) {
|
||||
return {
|
||||
type: 'audio',
|
||||
type: "audio",
|
||||
mimeType: getMimeType(ext),
|
||||
canPreview: true
|
||||
}
|
||||
@@ -74,28 +117,28 @@ export function getFileType(fileName: string): FileTypeInfo {
|
||||
|
||||
if (PDF_EXTENSIONS.has(ext)) {
|
||||
return {
|
||||
type: 'pdf',
|
||||
mimeType: 'application/pdf',
|
||||
type: "pdf",
|
||||
mimeType: "application/pdf",
|
||||
canPreview: true
|
||||
}
|
||||
}
|
||||
|
||||
if (CODE_EXTENSIONS.has(ext)) {
|
||||
return {
|
||||
type: 'code',
|
||||
type: "code",
|
||||
canPreview: true
|
||||
}
|
||||
}
|
||||
|
||||
if (TEXT_EXTENSIONS.has(ext)) {
|
||||
return {
|
||||
type: 'text',
|
||||
type: "text",
|
||||
canPreview: true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'binary',
|
||||
type: "binary",
|
||||
canPreview: false
|
||||
}
|
||||
}
|
||||
@@ -103,45 +146,47 @@ export function getFileType(fileName: string): FileTypeInfo {
|
||||
function getMimeType(ext: string): string {
|
||||
const mimeTypes: Record<string, string> = {
|
||||
// Images
|
||||
'png': 'image/png',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'gif': 'image/gif',
|
||||
'svg': 'image/svg+xml',
|
||||
'webp': 'image/webp',
|
||||
'bmp': 'image/bmp',
|
||||
'ico': 'image/x-icon',
|
||||
'tiff': 'image/tiff',
|
||||
'tif': 'image/tiff',
|
||||
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
svg: "image/svg+xml",
|
||||
webp: "image/webp",
|
||||
bmp: "image/bmp",
|
||||
ico: "image/x-icon",
|
||||
tiff: "image/tiff",
|
||||
tif: "image/tiff",
|
||||
|
||||
// Video
|
||||
'mp4': 'video/mp4',
|
||||
'webm': 'video/webm',
|
||||
'ogg': 'video/ogg',
|
||||
'ogv': 'video/ogg',
|
||||
'mov': 'video/quicktime',
|
||||
'avi': 'video/x-msvideo',
|
||||
'wmv': 'video/x-ms-wmv',
|
||||
'flv': 'video/x-flv',
|
||||
'mkv': 'video/x-matroska',
|
||||
|
||||
mp4: "video/mp4",
|
||||
webm: "video/webm",
|
||||
ogg: "video/ogg",
|
||||
ogv: "video/ogg",
|
||||
mov: "video/quicktime",
|
||||
avi: "video/x-msvideo",
|
||||
wmv: "video/x-ms-wmv",
|
||||
flv: "video/x-flv",
|
||||
mkv: "video/x-matroska",
|
||||
|
||||
// Audio
|
||||
'mp3': 'audio/mpeg',
|
||||
'wav': 'audio/wav',
|
||||
'oga': 'audio/ogg',
|
||||
'm4a': 'audio/mp4',
|
||||
'flac': 'audio/flac',
|
||||
'aac': 'audio/aac',
|
||||
'weba': 'audio/webm',
|
||||
|
||||
mp3: "audio/mpeg",
|
||||
wav: "audio/wav",
|
||||
oga: "audio/ogg",
|
||||
m4a: "audio/mp4",
|
||||
flac: "audio/flac",
|
||||
aac: "audio/aac",
|
||||
weba: "audio/webm",
|
||||
|
||||
// PDF
|
||||
'pdf': 'application/pdf'
|
||||
pdf: "application/pdf"
|
||||
}
|
||||
|
||||
return mimeTypes[ext] || 'application/octet-stream'
|
||||
return mimeTypes[ext] || "application/octet-stream"
|
||||
}
|
||||
|
||||
export function isBinaryFile(fileName: string): boolean {
|
||||
const { type } = getFileType(fileName)
|
||||
return type === 'image' || type === 'video' || type === 'audio' || type === 'pdf' || type === 'binary'
|
||||
return (
|
||||
type === "image" || type === "video" || type === "audio" || type === "pdf" || type === "binary"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand'
|
||||
import type { Thread, ModelConfig, Provider } from '@/types'
|
||||
import { create } from "zustand"
|
||||
import type { Thread, ModelConfig, Provider } from "@/types"
|
||||
|
||||
interface AppState {
|
||||
// Threads
|
||||
@@ -11,7 +11,7 @@ interface AppState {
|
||||
providers: Provider[]
|
||||
|
||||
// Right panel state (UI state, not thread data)
|
||||
rightPanelTab: 'todos' | 'files' | 'subagents'
|
||||
rightPanelTab: "todos" | "files" | "subagents"
|
||||
|
||||
// Settings dialog state
|
||||
settingsOpen: boolean
|
||||
@@ -34,7 +34,7 @@ interface AppState {
|
||||
deleteApiKey: (providerId: string) => Promise<void>
|
||||
|
||||
// Panel actions
|
||||
setRightPanelTab: (tab: 'todos' | 'files' | 'subagents') => void
|
||||
setRightPanelTab: (tab: "todos" | "files" | "subagents") => void
|
||||
|
||||
// Settings actions
|
||||
setSettingsOpen: (open: boolean) => void
|
||||
@@ -50,7 +50,7 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
currentThreadId: null,
|
||||
models: [],
|
||||
providers: [],
|
||||
rightPanelTab: 'todos',
|
||||
rightPanelTab: "todos",
|
||||
settingsOpen: false,
|
||||
sidebarCollapsed: false,
|
||||
|
||||
@@ -80,10 +80,10 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
},
|
||||
|
||||
deleteThread: async (threadId: string) => {
|
||||
console.log('[Store] Deleting thread:', threadId)
|
||||
console.log("[Store] Deleting thread:", threadId)
|
||||
try {
|
||||
await window.api.threads.delete(threadId)
|
||||
console.log('[Store] Thread deleted from backend')
|
||||
console.log("[Store] Thread deleted from backend")
|
||||
|
||||
set((state) => {
|
||||
const threads = state.threads.filter((t) => t.thread_id !== threadId)
|
||||
@@ -98,7 +98,7 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Store] Failed to delete thread:', error)
|
||||
console.error("[Store] Failed to delete thread:", error)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -114,7 +114,7 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
const generatedTitle = await window.api.threads.generateTitle(content)
|
||||
await get().updateThread(threadId, { title: generatedTitle })
|
||||
} catch (error) {
|
||||
console.error('[Store] Failed to generate title:', error)
|
||||
console.error("[Store] Failed to generate title:", error)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -130,16 +130,16 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
},
|
||||
|
||||
setApiKey: async (providerId: string, apiKey: string) => {
|
||||
console.log('[Store] setApiKey called:', { providerId, keyLength: apiKey.length })
|
||||
console.log("[Store] setApiKey called:", { providerId, keyLength: apiKey.length })
|
||||
try {
|
||||
await window.api.models.setApiKey(providerId, apiKey)
|
||||
console.log('[Store] API key saved via IPC')
|
||||
console.log("[Store] API key saved via IPC")
|
||||
// Reload providers and models to update availability
|
||||
await get().loadProviders()
|
||||
await get().loadModels()
|
||||
console.log('[Store] Providers and models reloaded')
|
||||
console.log("[Store] Providers and models reloaded")
|
||||
} catch (e) {
|
||||
console.error('[Store] Failed to set API key:', e)
|
||||
console.error("[Store] Failed to set API key:", e)
|
||||
throw e
|
||||
}
|
||||
},
|
||||
@@ -152,7 +152,7 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
},
|
||||
|
||||
// Panel actions
|
||||
setRightPanelTab: (tab: 'todos' | 'files' | 'subagents') => {
|
||||
setRightPanelTab: (tab: "todos" | "files" | "subagents") => {
|
||||
set({ rightPanelTab: tab })
|
||||
},
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@ import {
|
||||
useEffect,
|
||||
useSyncExternalStore,
|
||||
type ReactNode
|
||||
} from 'react'
|
||||
} from "react"
|
||||
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useStream } from '@langchain/langgraph-sdk/react'
|
||||
import { ElectronIPCTransport } from './electron-transport'
|
||||
import type { Message, Todo, FileInfo, Subagent, HITLRequest } from '@/types'
|
||||
import type { DeepAgent } from '../../../main/agent/types'
|
||||
import { useStream } from "@langchain/langgraph-sdk/react"
|
||||
import { ElectronIPCTransport } from "./electron-transport"
|
||||
import type { Message, Todo, FileInfo, Subagent, HITLRequest } from "@/types"
|
||||
import type { DeepAgent } from "../../../main/agent/types"
|
||||
|
||||
// Open file tab type
|
||||
export interface OpenFile {
|
||||
@@ -43,7 +43,7 @@ export interface ThreadState {
|
||||
error: string | null
|
||||
currentModel: string
|
||||
openFiles: OpenFile[]
|
||||
activeTab: 'agent' | string
|
||||
activeTab: "agent" | string
|
||||
fileContents: Record<string, string>
|
||||
tokenUsage: TokenUsage | null
|
||||
}
|
||||
@@ -53,7 +53,7 @@ type StreamInstance = ReturnType<typeof useStream<DeepAgent>>
|
||||
|
||||
// Stream data that we want to be reactive
|
||||
interface StreamData {
|
||||
messages: StreamInstance['messages']
|
||||
messages: StreamInstance["messages"]
|
||||
isLoading: boolean
|
||||
stream: StreamInstance | null
|
||||
}
|
||||
@@ -72,7 +72,7 @@ export interface ThreadActions {
|
||||
setCurrentModel: (modelId: string) => void
|
||||
openFile: (path: string, name: string) => void
|
||||
closeFile: (path: string) => void
|
||||
setActiveTab: (tab: 'agent' | string) => void
|
||||
setActiveTab: (tab: "agent" | string) => void
|
||||
setFileContents: (path: string, content: string) => void
|
||||
}
|
||||
|
||||
@@ -96,9 +96,9 @@ const createDefaultThreadState = (): ThreadState => ({
|
||||
subagents: [],
|
||||
pendingApproval: null,
|
||||
error: null,
|
||||
currentModel: 'claude-sonnet-4-5-20250929',
|
||||
currentModel: "claude-sonnet-4-5-20250929",
|
||||
openFiles: [],
|
||||
activeTab: 'agent',
|
||||
activeTab: "agent",
|
||||
fileContents: {},
|
||||
tokenUsage: null
|
||||
})
|
||||
@@ -162,7 +162,7 @@ function ThreadStreamHolder({
|
||||
const stream = useStream<DeepAgent>({
|
||||
transport,
|
||||
threadId,
|
||||
messagesKey: 'messages',
|
||||
messagesKey: "messages",
|
||||
onCustomEvent: (data) => {
|
||||
onCustomEventRef.current(data as CustomEventData)
|
||||
},
|
||||
@@ -211,7 +211,7 @@ function ThreadStreamHolder({
|
||||
return null
|
||||
}
|
||||
|
||||
export function ThreadProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
||||
export function ThreadProvider({ children }: { children: ReactNode }) {
|
||||
const [threadStates, setThreadStates] = useState<Record<string, ThreadState>>({})
|
||||
const [activeThreadIds, setActiveThreadIds] = useState<Set<string>>(new Set())
|
||||
const initializedThreadsRef = useRef<Set<string>>(new Set())
|
||||
@@ -259,7 +259,11 @@ export function ThreadProvider({ children }: { children: ReactNode }): React.JSX
|
||||
(threadId: string): ThreadState => {
|
||||
const state = threadStates[threadId] || createDefaultThreadState()
|
||||
if (state.pendingApproval) {
|
||||
console.log('[ThreadContext] getThreadState returning pendingApproval for:', threadId, state.pendingApproval)
|
||||
console.log(
|
||||
"[ThreadContext] getThreadState returning pendingApproval for:",
|
||||
threadId,
|
||||
state.pendingApproval
|
||||
)
|
||||
}
|
||||
return state
|
||||
},
|
||||
@@ -282,10 +286,12 @@ export function ThreadProvider({ children }: { children: ReactNode }): React.JSX
|
||||
|
||||
// Parse error messages into user-friendly format
|
||||
const parseErrorMessage = useCallback((error: Error | string): string => {
|
||||
const errorMessage = typeof error === 'string' ? error : error.message
|
||||
const errorMessage = typeof error === "string" ? error : error.message
|
||||
|
||||
// Check for context window exceeded errors
|
||||
const contextWindowMatch = errorMessage.match(/prompt is too long: (\d+) tokens > (\d+) maximum/i)
|
||||
const contextWindowMatch = errorMessage.match(
|
||||
/prompt is too long: (\d+) tokens > (\d+) maximum/i
|
||||
)
|
||||
if (contextWindowMatch) {
|
||||
const [, usedTokens, maxTokens] = contextWindowMatch
|
||||
const usedK = Math.round(parseInt(usedTokens) / 1000)
|
||||
@@ -294,13 +300,17 @@ export function ThreadProvider({ children }: { children: ReactNode }): React.JSX
|
||||
}
|
||||
|
||||
// Check for rate limit errors
|
||||
if (errorMessage.includes('rate_limit') || errorMessage.includes('429')) {
|
||||
return 'Rate limit exceeded. Please wait a moment before sending another message.'
|
||||
if (errorMessage.includes("rate_limit") || errorMessage.includes("429")) {
|
||||
return "Rate limit exceeded. Please wait a moment before sending another message."
|
||||
}
|
||||
|
||||
// Check for authentication errors
|
||||
if (errorMessage.includes('401') || errorMessage.includes('invalid_api_key') || errorMessage.includes('authentication')) {
|
||||
return 'Authentication failed. Please check your API key in settings.'
|
||||
if (
|
||||
errorMessage.includes("401") ||
|
||||
errorMessage.includes("invalid_api_key") ||
|
||||
errorMessage.includes("authentication")
|
||||
) {
|
||||
return "Authentication failed. Please check your API key in settings."
|
||||
}
|
||||
|
||||
// Return the original message for other errors
|
||||
@@ -310,7 +320,7 @@ export function ThreadProvider({ children }: { children: ReactNode }): React.JSX
|
||||
// Handle errors from ThreadStreamHolder
|
||||
const handleError = useCallback(
|
||||
(threadId: string, error: Error) => {
|
||||
console.error('[ThreadContext] Stream error:', { threadId, error })
|
||||
console.error("[ThreadContext] Stream error:", { threadId, error })
|
||||
const userFriendlyMessage = parseErrorMessage(error)
|
||||
updateThreadState(threadId, () => ({ error: userFriendlyMessage }))
|
||||
},
|
||||
@@ -320,15 +330,19 @@ export function ThreadProvider({ children }: { children: ReactNode }): React.JSX
|
||||
// Handle custom events from ThreadStreamHolder (interrupts, workspace updates, etc.)
|
||||
const handleCustomEvent = useCallback(
|
||||
(threadId: string, data: CustomEventData) => {
|
||||
console.log('[ThreadContext] Custom event received:', { threadId, type: data.type, data })
|
||||
console.log("[ThreadContext] Custom event received:", { threadId, type: data.type, data })
|
||||
switch (data.type) {
|
||||
case 'interrupt':
|
||||
case "interrupt":
|
||||
if (data.request) {
|
||||
console.log('[ThreadContext] Setting pendingApproval for thread:', threadId, data.request)
|
||||
console.log(
|
||||
"[ThreadContext] Setting pendingApproval for thread:",
|
||||
threadId,
|
||||
data.request
|
||||
)
|
||||
updateThreadState(threadId, () => ({ pendingApproval: data.request }))
|
||||
}
|
||||
break
|
||||
case 'workspace':
|
||||
case "workspace":
|
||||
if (Array.isArray(data.files)) {
|
||||
updateThreadState(threadId, (state) => {
|
||||
const fileMap = new Map(state.workspaceFiles.map((f) => [f.path, f]))
|
||||
@@ -342,25 +356,25 @@ export function ThreadProvider({ children }: { children: ReactNode }): React.JSX
|
||||
updateThreadState(threadId, () => ({ workspacePath: data.path }))
|
||||
}
|
||||
break
|
||||
case 'subagents':
|
||||
case "subagents":
|
||||
if (Array.isArray(data.subagents)) {
|
||||
updateThreadState(threadId, () => ({
|
||||
subagents: data.subagents!.map((s) => ({
|
||||
id: s.id || crypto.randomUUID(),
|
||||
name: s.name || 'Subagent',
|
||||
description: s.description || '',
|
||||
status: (s.status || 'pending') as 'pending' | 'running' | 'completed' | 'failed',
|
||||
name: s.name || "Subagent",
|
||||
description: s.description || "",
|
||||
status: (s.status || "pending") as "pending" | "running" | "completed" | "failed",
|
||||
startedAt: s.startedAt,
|
||||
completedAt: s.completedAt
|
||||
}))
|
||||
}))
|
||||
}
|
||||
break
|
||||
case 'token_usage':
|
||||
case "token_usage":
|
||||
// Only update if we have meaningful token values (> 0)
|
||||
// This prevents resetting the usage when streaming ends
|
||||
if (data.usage && data.usage.inputTokens !== undefined && data.usage.inputTokens > 0) {
|
||||
console.log('[ThreadContext] Token usage update:', {
|
||||
console.log("[ThreadContext] Token usage update:", {
|
||||
threadId,
|
||||
inputTokens: data.usage.inputTokens,
|
||||
outputTokens: data.usage.outputTokens,
|
||||
@@ -419,7 +433,7 @@ export function ThreadProvider({ children }: { children: ReactNode }): React.JSX
|
||||
},
|
||||
setWorkspaceFiles: (files: FileInfo[] | ((prev: FileInfo[]) => FileInfo[])) => {
|
||||
updateThreadState(threadId, (state) => ({
|
||||
workspaceFiles: typeof files === 'function' ? files(state.workspaceFiles) : files
|
||||
workspaceFiles: typeof files === "function" ? files(state.workspaceFiles) : files
|
||||
}))
|
||||
},
|
||||
setWorkspacePath: (path: string | null) => {
|
||||
@@ -465,14 +479,18 @@ export function ThreadProvider({ children }: { children: ReactNode }): React.JSX
|
||||
let newActiveTab = state.activeTab
|
||||
if (state.activeTab === path) {
|
||||
const closedIndex = state.openFiles.findIndex((f) => f.path === path)
|
||||
if (newOpenFiles.length === 0) newActiveTab = 'agent'
|
||||
if (newOpenFiles.length === 0) newActiveTab = "agent"
|
||||
else if (closedIndex > 0) newActiveTab = newOpenFiles[closedIndex - 1].path
|
||||
else newActiveTab = newOpenFiles[0].path
|
||||
}
|
||||
return { openFiles: newOpenFiles, activeTab: newActiveTab, fileContents: newFileContents }
|
||||
return {
|
||||
openFiles: newOpenFiles,
|
||||
activeTab: newActiveTab,
|
||||
fileContents: newFileContents
|
||||
}
|
||||
})
|
||||
},
|
||||
setActiveTab: (tab: 'agent' | string) => {
|
||||
setActiveTab: (tab: "agent" | string) => {
|
||||
updateThreadState(threadId, () => ({ activeTab: tab }))
|
||||
},
|
||||
setFileContents: (path: string, content: string) => {
|
||||
@@ -510,7 +528,7 @@ export function ThreadProvider({ children }: { children: ReactNode }): React.JSX
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ThreadContext] Failed to load thread details:', error)
|
||||
console.error("[ThreadContext] Failed to load thread details:", error)
|
||||
}
|
||||
|
||||
// Load thread history from checkpoints
|
||||
@@ -551,31 +569,31 @@ export function ThreadProvider({ children }: { children: ReactNode }): React.JSX
|
||||
|
||||
if (channelValues?.messages && Array.isArray(channelValues.messages)) {
|
||||
const messages: Message[] = channelValues.messages.map((msg, index) => {
|
||||
let role: 'user' | 'assistant' | 'system' | 'tool' = 'assistant'
|
||||
if (typeof msg._getType === 'function') {
|
||||
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'
|
||||
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'
|
||||
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"
|
||||
}
|
||||
|
||||
let content: Message['content'] = ''
|
||||
if (typeof msg.content === 'string') content = msg.content
|
||||
else if (Array.isArray(msg.content)) content = msg.content as Message['content']
|
||||
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'],
|
||||
...(role === 'tool' && msg.tool_call_id && { tool_call_id: msg.tool_call_id }),
|
||||
...(role === 'tool' && msg.name && { name: msg.name }),
|
||||
tool_calls: msg.tool_calls as Message["tool_calls"],
|
||||
...(role === "tool" && msg.tool_call_id && { tool_call_id: msg.tool_call_id }),
|
||||
...(role === "tool" && msg.name && { name: msg.name }),
|
||||
created_at: new Date()
|
||||
}
|
||||
})
|
||||
@@ -585,8 +603,8 @@ export function ThreadProvider({ children }: { children: ReactNode }): React.JSX
|
||||
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'
|
||||
content: todo.content || "",
|
||||
status: (todo.status as Todo["status"]) || "pending"
|
||||
}))
|
||||
actions.setTodos(todos)
|
||||
}
|
||||
@@ -608,7 +626,7 @@ export function ThreadProvider({ children }: { children: ReactNode }): React.JSX
|
||||
name: req.action,
|
||||
args: req.args
|
||||
},
|
||||
allowed_decisions: ['approve', 'reject', 'edit']
|
||||
allowed_decisions: ["approve", "reject", "edit"]
|
||||
}
|
||||
actions.setPendingApproval(hitlRequest)
|
||||
} else if (reviewConfigs && reviewConfigs.length > 0) {
|
||||
@@ -621,17 +639,17 @@ export function ThreadProvider({ children }: { children: ReactNode }): React.JSX
|
||||
name: config.toolName,
|
||||
args: config.toolArgs
|
||||
},
|
||||
allowed_decisions: ['approve', 'reject', 'edit']
|
||||
allowed_decisions: ["approve", "reject", "edit"]
|
||||
}
|
||||
actions.setPendingApproval(hitlRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ThreadContext] Failed to load thread history:', error)
|
||||
console.error("[ThreadContext] Failed to load thread history:", error)
|
||||
}
|
||||
},
|
||||
[getThreadActions]
|
||||
[getThreadActions, updateThreadState]
|
||||
)
|
||||
|
||||
const initializeThread = useCallback(
|
||||
@@ -663,7 +681,8 @@ export function ThreadProvider({ children }: { children: ReactNode }): React.JSX
|
||||
return next
|
||||
})
|
||||
setThreadStates((prev) => {
|
||||
const { [threadId]: _, ...rest } = prev
|
||||
const { [threadId]: _removed, ...rest } = prev
|
||||
void _removed // Explicitly mark as intentionally unused
|
||||
return rest
|
||||
})
|
||||
}, [])
|
||||
@@ -677,7 +696,14 @@ export function ThreadProvider({ children }: { children: ReactNode }): React.JSX
|
||||
subscribeToStream,
|
||||
getStreamData
|
||||
}),
|
||||
[getThreadState, getThreadActions, initializeThread, cleanupThread, subscribeToStream, getStreamData]
|
||||
[
|
||||
getThreadState,
|
||||
getThreadActions,
|
||||
initializeThread,
|
||||
cleanupThread,
|
||||
subscribeToStream,
|
||||
getStreamData
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -697,10 +723,9 @@ export function ThreadProvider({ children }: { children: ReactNode }): React.JSX
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useThreadContext(): ThreadContextValue {
|
||||
const context = useContext(ThreadContext)
|
||||
if (!context) throw new Error('useThreadContext must be used within a ThreadProvider')
|
||||
if (!context) throw new Error("useThreadContext must be used within a ThreadProvider")
|
||||
return context
|
||||
}
|
||||
|
||||
|
||||
@@ -6,36 +6,36 @@ export function cn(...inputs: ClassValue[]) {
|
||||
}
|
||||
|
||||
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'
|
||||
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 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 (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) + '...'
|
||||
return str.slice(0, length) + "..."
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export async function selectWorkspaceFolder(
|
||||
currentThreadId: string | null,
|
||||
setWorkspacePath: (path: string | null) => void,
|
||||
setWorkspaceFiles: (files: any[]) => void,
|
||||
setWorkspaceFiles: (files: Array<{ path: string; is_dir?: boolean; size?: number }>) => void,
|
||||
setLoading: (loading: boolean) => void,
|
||||
setOpen?: (open: boolean) => void
|
||||
): Promise<void> {
|
||||
@@ -18,7 +18,7 @@ export async function selectWorkspaceFolder(
|
||||
}
|
||||
if (setOpen) setOpen(false)
|
||||
} catch (e) {
|
||||
console.error('[WorkspacePicker] Select folder error:', e)
|
||||
console.error("[WorkspacePicker] Select folder error:", e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
import React from "react"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import App from "./App"
|
||||
import "./index.css"
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
|
||||
+19
-19
@@ -1,5 +1,5 @@
|
||||
// Re-export types from electron for use in renderer
|
||||
export type ThreadStatus = 'idle' | 'busy' | 'interrupted' | 'error'
|
||||
export type ThreadStatus = "idle" | "busy" | "interrupted" | "error"
|
||||
|
||||
export interface Thread {
|
||||
thread_id: string
|
||||
@@ -11,7 +11,7 @@ export interface Thread {
|
||||
title?: string
|
||||
}
|
||||
|
||||
export type RunStatus = 'pending' | 'running' | 'error' | 'success' | 'interrupted'
|
||||
export type RunStatus = "pending" | "running" | "error" | "success" | "interrupted"
|
||||
|
||||
export interface Run {
|
||||
run_id: string
|
||||
@@ -24,7 +24,7 @@ export interface Run {
|
||||
}
|
||||
|
||||
// Provider configuration
|
||||
export type ProviderId = 'anthropic' | 'openai' | 'google' | 'ollama'
|
||||
export type ProviderId = "anthropic" | "openai" | "google" | "ollama"
|
||||
|
||||
export interface Provider {
|
||||
id: ProviderId
|
||||
@@ -46,7 +46,7 @@ export interface Subagent {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
status: 'pending' | 'running' | 'completed' | 'failed'
|
||||
status: "pending" | "running" | "completed" | "failed"
|
||||
startedAt?: Date
|
||||
completedAt?: Date
|
||||
// Used to correlate task tool calls with their responses
|
||||
@@ -56,20 +56,20 @@ export interface Subagent {
|
||||
}
|
||||
|
||||
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 }
|
||||
| { 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'
|
||||
role: "user" | "assistant" | "system" | "tool"
|
||||
content: string | ContentBlock[]
|
||||
tool_calls?: ToolCall[]
|
||||
// For tool messages - links result to its tool call
|
||||
@@ -80,7 +80,7 @@ export interface Message {
|
||||
}
|
||||
|
||||
export interface ContentBlock {
|
||||
type: 'text' | 'image' | 'tool_use' | 'tool_result'
|
||||
type: "text" | "image" | "tool_use" | "tool_result"
|
||||
text?: string
|
||||
tool_use_id?: string
|
||||
name?: string
|
||||
@@ -103,11 +103,11 @@ export interface ToolResult {
|
||||
export interface HITLRequest {
|
||||
id: string
|
||||
tool_call: ToolCall
|
||||
allowed_decisions: HITLDecision['type'][]
|
||||
allowed_decisions: HITLDecision["type"][]
|
||||
}
|
||||
|
||||
export interface HITLDecision {
|
||||
type: 'approve' | 'reject' | 'edit'
|
||||
type: "approve" | "reject" | "edit"
|
||||
tool_call_id: string
|
||||
edited_args?: Record<string, unknown>
|
||||
feedback?: string
|
||||
@@ -116,7 +116,7 @@ export interface HITLDecision {
|
||||
export interface Todo {
|
||||
id: string
|
||||
content: string
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'cancelled'
|
||||
status: "pending" | "in_progress" | "completed" | "cancelled"
|
||||
}
|
||||
|
||||
export interface FileInfo {
|
||||
|
||||
+10
-10
@@ -1,6 +1,6 @@
|
||||
import type { UseStreamTransport } from '@langchain/langgraph-sdk/react'
|
||||
import type { UseStreamTransport } from "@langchain/langgraph-sdk/react"
|
||||
|
||||
export type StreamPayload = Parameters<UseStreamTransport['stream']>[0]
|
||||
export type StreamPayload = Parameters<UseStreamTransport["stream"]>[0]
|
||||
|
||||
export type StreamEvent = {
|
||||
id?: string
|
||||
@@ -11,13 +11,13 @@ export type StreamEvent = {
|
||||
// Types for the IPC events from main process
|
||||
export interface IPCMessage {
|
||||
id: string
|
||||
type: 'human' | 'ai' | 'tool' | 'system'
|
||||
type: "human" | "ai" | "tool" | "system"
|
||||
content: string
|
||||
tool_calls?: { id: string; name: string; args: Record<string, unknown> }[]
|
||||
}
|
||||
|
||||
export interface IPCValuesEvent {
|
||||
type: 'values'
|
||||
type: "values"
|
||||
data: {
|
||||
messages?: IPCMessage[]
|
||||
todos?: { id?: string; content?: string; status?: string }[]
|
||||
@@ -36,30 +36,30 @@ export interface IPCValuesEvent {
|
||||
}
|
||||
|
||||
export interface IPCTokenEvent {
|
||||
type: 'token'
|
||||
type: "token"
|
||||
messageId: string
|
||||
token: string
|
||||
}
|
||||
|
||||
export interface IPCToolCallEvent {
|
||||
type: 'tool_call'
|
||||
type: "tool_call"
|
||||
messageId: string | null
|
||||
tool_calls: Array<{ id?: string; name?: string; args?: string }>
|
||||
}
|
||||
|
||||
// Raw stream event - forwards LangGraph stream chunks directly
|
||||
export interface IPCStreamEvent {
|
||||
type: 'stream'
|
||||
mode: 'messages' | 'values'
|
||||
type: "stream"
|
||||
mode: "messages" | "values"
|
||||
data: unknown
|
||||
}
|
||||
|
||||
export interface IPCDoneEvent {
|
||||
type: 'done'
|
||||
type: "done"
|
||||
}
|
||||
|
||||
export interface IPCErrorEvent {
|
||||
type: 'error'
|
||||
type: "error"
|
||||
error: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import json
|
||||
import random
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Lists for generating realistic gaming data
|
||||
ADJECTIVES = ["Shadow", "Dark", "Cyber", "Neon", "Toxic", "Epic", "Legendary", "Swift", "Silent", "Deadly",
|
||||
"Mystic", "Frozen", "Blazing", "Storm", "Thunder", "Iron", "Steel", "Crystal", "Void", "Chaos",
|
||||
"Alpha", "Omega", "Prime", "Ultra", "Hyper", "Mega", "Super", "Quantum", "Cosmic", "Astral",
|
||||
"Savage", "Brutal", "Fierce", "Wild", "Rogue", "Ghost", "Phantom", "Stealth", "Ninja", "Samurai"]
|
||||
|
||||
NOUNS = ["Wolf", "Dragon", "Phoenix", "Hawk", "Tiger", "Viper", "Cobra", "Panther", "Falcon", "Raven",
|
||||
"Knight", "Warrior", "Hunter", "Slayer", "Sniper", "Assassin", "Ninja", "Samurai", "Gladiator", "Spartan",
|
||||
"Gamer", "Player", "Master", "Lord", "King", "Queen", "Prince", "Champion", "Legend", "Hero",
|
||||
"Wizard", "Mage", "Sorcerer", "Demon", "Angel", "Reaper", "Titan", "Giant", "Beast", "Monster"]
|
||||
|
||||
CLAN_NAMES = ["Elite Squad", "Dark Legion", "Phoenix Rising", "Shadow Warriors", "Cyber Knights",
|
||||
"Neon Ninjas", "Thunder Gods", "Ice Dragons", "Fire Hawks", "Storm Riders",
|
||||
"Void Walkers", "Chaos Syndicate", "Alpha Pack", "Omega Force", "Prime Division",
|
||||
"Quantum Collective", "Astral Guardians", "Savage Beasts", "Ghost Protocol", "Stealth Ops",
|
||||
"Night Stalkers", "Day Breakers", "Moon Runners", "Sun Chasers", "Star Lords",
|
||||
"Diamond Dogs", "Golden Eagles", "Silver Wolves", "Bronze Bulls", "Platinum Panthers",
|
||||
"Crimson Tide", "Azure Knights", "Emerald Empire", "Obsidian Order", "Jade Dragons",
|
||||
None, None, None, None, None] # Some users not in clans
|
||||
|
||||
ACHIEVEMENTS = [
|
||||
"First Blood", "Double Kill", "Triple Kill", "Quad Kill", "Pentakill",
|
||||
"Unstoppable", "Godlike", "Legendary", "Dominating", "Rampage",
|
||||
"Sharpshooter", "Headhunter", "Sniper Elite", "Quick Draw", "Dead Eye",
|
||||
"Survivor", "Last One Standing", "Victory Royale", "Champion", "Undefeated",
|
||||
"Speed Demon", "Marathon Runner", "Night Owl", "Early Bird", "Dedicated",
|
||||
"Collector", "Completionist", "Explorer", "Adventurer", "Pioneer",
|
||||
"Team Player", "MVP", "Clutch Master", "Support Hero", "Tank Commander",
|
||||
"First Win", "10 Wins", "50 Wins", "100 Wins", "500 Wins",
|
||||
"Level 10", "Level 25", "Level 50", "Level 100", "Max Level",
|
||||
"Social Butterfly", "Lone Wolf", "Clan Leader", "Veteran", "Newcomer",
|
||||
"Premium Member", "Beta Tester", "Alpha Tester", "Founder", "VIP",
|
||||
"Holiday Hero 2023", "Summer Slayer", "Winter Warrior", "Spring Striker", "Fall Fighter"
|
||||
]
|
||||
|
||||
RANKS = ["Bronze I", "Bronze II", "Bronze III", "Bronze IV",
|
||||
"Silver I", "Silver II", "Silver III", "Silver IV",
|
||||
"Gold I", "Gold II", "Gold III", "Gold IV",
|
||||
"Platinum I", "Platinum II", "Platinum III", "Platinum IV",
|
||||
"Diamond I", "Diamond II", "Diamond III", "Diamond IV",
|
||||
"Master", "Grandmaster", "Challenger", "Legend", "Immortal"]
|
||||
|
||||
EMAIL_DOMAINS = ["gmail.com", "yahoo.com", "hotmail.com", "outlook.com", "protonmail.com",
|
||||
"icloud.com", "aol.com", "mail.com", "zoho.com", "fastmail.com"]
|
||||
|
||||
def generate_gamertag():
|
||||
style = random.choice(['adj_noun', 'adj_noun_num', 'word_num', 'xxx_style', 'name_style'])
|
||||
|
||||
if style == 'adj_noun':
|
||||
return f"{random.choice(ADJECTIVES)}{random.choice(NOUNS)}"
|
||||
elif style == 'adj_noun_num':
|
||||
return f"{random.choice(ADJECTIVES)}{random.choice(NOUNS)}{random.randint(1, 9999)}"
|
||||
elif style == 'word_num':
|
||||
return f"{random.choice(NOUNS)}{random.randint(1, 9999)}"
|
||||
elif style == 'xxx_style':
|
||||
sep = random.choice(['_', 'x', 'X', '-', ''])
|
||||
return f"x{sep}{random.choice(NOUNS)}{sep}x{random.randint(1, 99)}"
|
||||
else:
|
||||
first_names = ["Alex", "Jordan", "Taylor", "Morgan", "Casey", "Riley", "Quinn", "Avery",
|
||||
"Max", "Sam", "Charlie", "Jamie", "Drew", "Blake", "Skyler", "Dakota"]
|
||||
return f"{random.choice(first_names)}{random.choice(['_', '', 'x', 'X'])}{random.choice(NOUNS)}{random.randint(1, 999)}"
|
||||
|
||||
def generate_email(gamertag):
|
||||
clean_tag = ''.join(c.lower() for c in gamertag if c.isalnum())
|
||||
variation = random.choice([
|
||||
clean_tag,
|
||||
f"{clean_tag}{random.randint(1, 999)}",
|
||||
f"{clean_tag}_{random.randint(1, 99)}",
|
||||
f"gaming_{clean_tag}",
|
||||
f"{clean_tag}_gaming"
|
||||
])
|
||||
return f"{variation}@{random.choice(EMAIL_DOMAINS)}"
|
||||
|
||||
def generate_user(user_id):
|
||||
gamertag = generate_gamertag()
|
||||
|
||||
# Generate correlated stats
|
||||
level = random.randint(1, 100)
|
||||
xp = level * random.randint(800, 1200) + random.randint(0, 999)
|
||||
|
||||
games_played = random.randint(10, 5000)
|
||||
|
||||
# Win rate varies by skill level (higher levels tend to have better win rates)
|
||||
base_win_rate = 0.3 + (level / 100) * 0.3 + random.uniform(-0.15, 0.15)
|
||||
base_win_rate = max(0.1, min(0.9, base_win_rate))
|
||||
|
||||
wins = int(games_played * base_win_rate)
|
||||
losses = games_played - wins
|
||||
|
||||
# Rank correlates with level
|
||||
rank_index = min(len(RANKS) - 1, int((level / 100) * len(RANKS) * 0.8) + random.randint(-3, 3))
|
||||
rank_index = max(0, min(len(RANKS) - 1, rank_index))
|
||||
|
||||
# Achievements based on level and games played
|
||||
num_achievements = min(len(ACHIEVEMENTS), random.randint(1, 5) + level // 10 + games_played // 500)
|
||||
achievements = random.sample(ACHIEVEMENTS, num_achievements)
|
||||
|
||||
# Friends count
|
||||
friends_count = random.randint(0, 500)
|
||||
|
||||
# Account dates
|
||||
days_since_created = random.randint(1, 2000)
|
||||
account_created = datetime.now() - timedelta(days=days_since_created)
|
||||
|
||||
days_since_online = random.choices(
|
||||
[0, random.randint(1, 7), random.randint(8, 30), random.randint(31, 365)],
|
||||
weights=[0.4, 0.3, 0.2, 0.1]
|
||||
)[0]
|
||||
last_online = datetime.now() - timedelta(days=days_since_online, hours=random.randint(0, 23), minutes=random.randint(0, 59))
|
||||
|
||||
# Playtime correlates with games played and account age
|
||||
avg_game_length = random.uniform(0.2, 1.5) # hours per game
|
||||
total_playtime_hours = round(games_played * avg_game_length + random.uniform(-50, 200), 1)
|
||||
total_playtime_hours = max(1, total_playtime_hours)
|
||||
|
||||
# Premium status
|
||||
premium_subscriber = random.random() < 0.25 # 25% are premium
|
||||
|
||||
return {
|
||||
"id": user_id,
|
||||
"gamertag": gamertag,
|
||||
"email": generate_email(gamertag),
|
||||
"level": level,
|
||||
"xp": xp,
|
||||
"rank": RANKS[rank_index],
|
||||
"games_played": games_played,
|
||||
"wins": wins,
|
||||
"losses": losses,
|
||||
"achievements": achievements,
|
||||
"friends_count": friends_count,
|
||||
"clan_name": random.choice(CLAN_NAMES),
|
||||
"account_created": account_created.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"last_online": last_online.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"premium_subscriber": premium_subscriber,
|
||||
"total_playtime_hours": total_playtime_hours
|
||||
}
|
||||
|
||||
def main():
|
||||
num_users = 1050
|
||||
users = [generate_user(i + 1) for i in range(num_users)]
|
||||
|
||||
with open('/Users/hunter/Projects/github/langchain-ai/openwork/test_data/users_9.json', 'w') as f:
|
||||
json.dump(users, f, indent=2)
|
||||
|
||||
print(f"Generated {num_users} users successfully!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user