lints and formats

This commit is contained in:
Hunter Lovell
2026-01-18 23:18:10 -08:00
parent 40c5bb0132
commit 5a910ed1ba
71 changed files with 2285 additions and 2324 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ name: Bug Report
about: Report a bug or unexpected behavior
title: "[Bug]: "
labels: bug
assignees: ''
assignees: ""
---
## Description
+1 -1
View File
@@ -3,7 +3,7 @@ name: Feature Request
about: Suggest a new feature or improvement
title: "[Feature]: "
labels: enhancement
assignees: ''
assignees: ""
---
## Problem Statement
+4 -4
View File
@@ -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
View File
@@ -1,4 +1,4 @@
singleQuote: true
singleQuote: false
semi: false
printWidth: 100
trailingComma: none
+21 -18
View File
@@ -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?
+5 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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

-16
View File
@@ -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.
+20 -20
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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>>
+36 -28
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
})
}
+14 -14
View File
@@ -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
}
+6 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+9 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)
}
+11 -11
View File
@@ -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>
+74 -58
View File
@@ -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">
+21 -21
View File
@@ -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>
+7 -16
View File
@@ -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" />
+29 -24
View File
@@ -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 (
+8 -8
View File
@@ -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"
+6 -9
View File
@@ -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
+7 -12
View File
@@ -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} />
)
}
)
+30 -57
View File
@@ -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 }
+12 -40
View File
@@ -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
}
+14 -23
View File
@@ -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 -2
View File
@@ -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 -9
View File
@@ -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} />
}
+26 -23
View File
@@ -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}
+13 -18
View File
@@ -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
View File
@@ -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;
}
+133 -121
View File
@@ -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())
}
}
+111 -66
View File
@@ -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"
)
}
+14 -14
View File
@@ -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 })
},
+86 -61
View File
@@ -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
}
+11 -11
View File
@@ -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 {
+2 -2
View File
@@ -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)
}
+5 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
-153
View File
@@ -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()