diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 4dbff16..7fa37dc 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -3,7 +3,7 @@ name: Bug Report about: Report a bug or unexpected behavior title: "[Bug]: " labels: bug -assignees: '' +assignees: "" --- ## Description diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index aebe037..2a3bb34 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -3,7 +3,7 @@ name: Feature Request about: Suggest a new feature or improvement title: "[Feature]: " labels: enhancement -assignees: '' +assignees: "" --- ## Problem Statement diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3542c87..dda7037 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.prettierrc.yaml b/.prettierrc.yaml index 35893b3..f99263a 100644 --- a/.prettierrc.yaml +++ b/.prettierrc.yaml @@ -1,4 +1,4 @@ -singleQuote: true +singleQuote: false semi: false printWidth: 100 trailingComma: none diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 38e988f..bc2cf17 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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? diff --git a/README.md b/README.md index 4d36f2e..7057622 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bin/cli.js b/bin/cli.js index 92e02ef..67f7120 100644 --- a/bin/cli.js +++ b/bin/cli.js @@ -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) }) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index c8b17f1..0ef95dc 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -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()] diff --git a/eslint.config.mjs b/eslint.config.mjs index 70c950e..c3e9af1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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 -); +) diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index 6a41099..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/resources/README.md b/resources/README.md deleted file mode 100644 index 29d22ed..0000000 --- a/resources/README.md +++ /dev/null @@ -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. diff --git a/src/main/agent/local-sandbox.ts b/src/main/agent/local-sandbox.ts index 774e8fd..9b9f956 100644 --- a/src/main/agent/local-sandbox.ts +++ b/src/main/agent/local-sandbox.ts @@ -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 { - 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 = '' + 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) diff --git a/src/main/agent/runtime.ts b/src/main/agent/runtime.ts index caf2959..9d997dd 100644 --- a/src/main/agent/runtime.ts +++ b/src/main/agent/runtime.ts @@ -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 -// 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[0]) - console.log('[Runtime] Deep agent created with LocalSandbox at:', workspacePath) + console.log("[Runtime] Deep agent created with LocalSandbox at:", workspacePath) return agent } diff --git a/src/main/agent/types.ts b/src/main/agent/types.ts index fab6771..dfa9e22 100644 --- a/src/main/agent/types.ts +++ b/src/main/agent/types.ts @@ -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> diff --git a/src/main/checkpointer/sqljs-saver.ts b/src/main/checkpointer/sqljs-saver.ts index 856c2f6..bfba73a 100644 --- a/src/main/checkpointer/sqljs-saver.ts +++ b/src/main/checkpointer/sqljs-saver.ts @@ -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 { 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 { 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 { 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 { 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 { 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]) diff --git a/src/main/db/index.ts b/src/main/db/index.ts index 68780cc..a068462 100644 --- a/src/main/db/index.ts +++ b/src/main/db/index.ts @@ -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 | null = null @@ -45,14 +45,14 @@ export async function flush(): Promise { 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 { 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 { 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): Thread { +export function createThread(threadId: string, metadata?: Record): 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> -): Thread | null { + updates: Partial> +): 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() } diff --git a/src/main/index.ts b/src/main/index.ts index ce7b355..55944a7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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() } }) diff --git a/src/main/ipc/agent.ts b/src/main/ipc/agent.ts index 22c5222..0e47136 100644 --- a/src/main/ipc/agent.ts +++ b/src/main/ipc/agent.ts @@ -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() 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() diff --git a/src/main/ipc/models.ts b/src/main/ipc/models.ts index c4df27d..e56866b 100644 --- a/src/main/ipc/models.ts +++ b/src/main/ipc/models.ts @@ -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[] = [ - { id: 'anthropic', name: 'Anthropic' }, - { id: 'openai', name: 'OpenAI' }, - { id: 'google', name: 'Google' } +const PROVIDERS: Omit[] = [ + { 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 { + async function readDir(dirPath: string, relativePath: string = ""): Promise { 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 } diff --git a/src/main/ipc/threads.ts b/src/main/ipc/threads.ts index 6d9fb0d..66a5606 100644 --- a/src/main/ipc/threads.ts +++ b/src/main/ipc/threads.ts @@ -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) => { + ipcMain.handle("threads:create", async (_event, metadata?: Record) => { 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 }) => { - const updateData: Parameters[1] = {} + ipcMain.handle("threads:update", async (_event, { threadId, updates }: ThreadUpdateParams) => { + const updateData: Parameters[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) }) } diff --git a/src/main/services/title-generator.ts b/src/main/services/title-generator.ts index 7bdaf66..badec92 100644 --- a/src/main/services/title-generator.ts +++ b/src/main/services/title-generator.ts @@ -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 } diff --git a/src/main/services/workspace-watcher.ts b/src/main/services/workspace-watcher.ts index 6ebe125..86fa8bd 100644 --- a/src/main/services/workspace-watcher.ts +++ b/src/main/services/workspace-watcher.ts @@ -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() @@ -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 }) diff --git a/src/main/storage.ts b/src/main/storage.ts index 5027814..d09686c 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -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 = { - anthropic: 'ANTHROPIC_API_KEY', - openai: 'OPENAI_API_KEY', - google: 'GOOGLE_API_KEY' +const ENV_VAR_NAMES: Record = { + 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 { const envPath = getEnvFilePath() if (!existsSync(envPath)) return {} - const content = readFileSync(envPath, 'utf-8') + const content = readFileSync(envPath, "utf-8") const result: Record = {} - 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): 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 diff --git a/src/main/types.ts b/src/main/types.ts index 79fb097..e0ebab3 100644 --- a/src/main/types.ts +++ b/src/main/types.ts @@ -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 +} + +// 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 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) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index d0aeabd..51e74c4 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -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 diff --git a/src/preload/index.ts b/src/preload/index.ts index 40e66cc..ffb2b36 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => { - return ipcRenderer.invoke('agent:cancel', { threadId }) + return ipcRenderer.invoke("agent:cancel", { threadId }) } }, threads: { list: (): Promise => { - return ipcRenderer.invoke('threads:list') + return ipcRenderer.invoke("threads:list") }, get: (threadId: string): Promise => { - return ipcRenderer.invoke('threads:get', threadId) + return ipcRenderer.invoke("threads:get", threadId) }, create: (metadata?: Record): Promise => { - return ipcRenderer.invoke('threads:create', metadata) + return ipcRenderer.invoke("threads:create", metadata) }, update: (threadId: string, updates: Partial): Promise => { - return ipcRenderer.invoke('threads:update', { threadId, updates }) + return ipcRenderer.invoke("threads:update", { threadId, updates }) }, delete: (threadId: string): Promise => { - return ipcRenderer.invoke('threads:delete', threadId) + return ipcRenderer.invoke("threads:delete", threadId) }, getHistory: (threadId: string): Promise => { - return ipcRenderer.invoke('threads:history', threadId) + return ipcRenderer.invoke("threads:history", threadId) }, generateTitle: (message: string): Promise => { - return ipcRenderer.invoke('threads:generateTitle', message) + return ipcRenderer.invoke("threads:generateTitle", message) } }, models: { list: (): Promise => { - return ipcRenderer.invoke('models:list') + return ipcRenderer.invoke("models:list") }, listProviders: (): Promise => { - return ipcRenderer.invoke('models:listProviders') + return ipcRenderer.invoke("models:listProviders") }, getDefault: (): Promise => { - return ipcRenderer.invoke('models:getDefault') + return ipcRenderer.invoke("models:getDefault") }, setDefault: (modelId: string): Promise => { - return ipcRenderer.invoke('models:setDefault', modelId) + return ipcRenderer.invoke("models:setDefault", modelId) }, setApiKey: (provider: string, apiKey: string): Promise => { - return ipcRenderer.invoke('models:setApiKey', { provider, apiKey }) + return ipcRenderer.invoke("models:setApiKey", { provider, apiKey }) }, getApiKey: (provider: string): Promise => { - return ipcRenderer.invoke('models:getApiKey', provider) + return ipcRenderer.invoke("models:getApiKey", provider) }, deleteApiKey: (provider: string): Promise => { - return ipcRenderer.invoke('models:deleteApiKey', provider) + return ipcRenderer.invoke("models:deleteApiKey", provider) } }, workspace: { get: (threadId?: string): Promise => { - return ipcRenderer.invoke('workspace:get', threadId) + return ipcRenderer.invoke("workspace:get", threadId) }, set: (threadId: string | undefined, path: string | null): Promise => { - return ipcRenderer.invoke('workspace:set', { threadId, path }) + return ipcRenderer.invoke("workspace:set", { threadId, path }) }, select: (threadId?: string): Promise => { - 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) } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 92d4bb1..6376330 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 ( -
- {/* Fixed app badge - zoom independent position and size */} -
- OPENWORK - {__APP_VERSION__} -
+
+ {/* Fixed app badge - zoom independent position and size */} +
+ OPENWORK + {__APP_VERSION__} +
- {/* Left + Center column */} -
- {/* Titlebar row with tabs integrated */} -
- {/* Left section - spacer for traffic lights + badge (matches left sidebar width) */} -
+ {/* Left + Center column */} +
+ {/* Titlebar row with tabs integrated */} +
+ {/* Left section - spacer for traffic lights + badge (matches left sidebar width) */} +
- {/* Resize handle spacer */} -
+ {/* Resize handle spacer */} +
- {/* Center section - Tab bar */} -
- {currentThreadId && } + {/* Center section - Tab bar */} +
+ {currentThreadId && } +
+
+ + {/* Main content area */} +
+ {/* Left Sidebar - Thread List */} +
+ +
+ + + + {/* Center - Content Panel (Agent Chat + File Viewer) */} +
+ {currentThreadId ? ( + + ) : ( +
+ Select or create a thread to begin +
+ )} +
- {/* Main content area */} -
- {/* Left Sidebar - Thread List */} -
- -
+ - - - {/* Center - Content Panel (Agent Chat + File Viewer) */} -
- {currentThreadId ? ( - - ) : ( -
- Select or create a thread to begin -
- )} -
+ {/* Right Panel - Status Panels (full height) */} +
+
- - - - {/* Right Panel - Status Panels (full height) */} -
- -
-
) } diff --git a/src/renderer/src/components/chat/ApiKeyDialog.tsx b/src/renderer/src/components/chat/ApiKeyDialog.tsx index 4faa981..95b5981 100644 --- a/src/renderer/src/components/chat/ApiKeyDialog.tsx +++ b/src/renderer/src/components/chat/ApiKeyDialog.tsx @@ -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 = { - 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 { 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 {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.`} @@ -95,10 +98,10 @@ export function ApiKeyDialog({ open, onOpenChange, provider }: ApiKeyDialogProps
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 -
diff --git a/src/renderer/src/components/chat/ChatContainer.tsx b/src/renderer/src/components/chat/ChatContainer.tsx index ed31ce4..546e8e8 100644 --- a/src/renderer/src/components/chat/ChatContainer.tsx +++ b/src/renderer/src/components/chat/ChatContainer.tsx @@ -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(null) const scrollRef = useRef(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 => { + async (decision: "approve" | "reject" | "edit"): Promise => { 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() 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 => { - 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" }} />
{isLoading ? ( @@ -391,7 +392,13 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme ) : ( - )} @@ -410,7 +417,6 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme
-
) } diff --git a/src/renderer/src/components/chat/ChatTodos.tsx b/src/renderer/src/components/chat/ChatTodos.tsx index 1fee68f..d23b2fe 100644 --- a/src/renderer/src/components/chat/ChatTodos.tsx +++ b/src/renderer/src/components/chat/ChatTodos.tsx @@ -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 (
- + {todo.content}
) @@ -73,7 +73,7 @@ export function ChatTodos({ todos }: ChatTodosProps): React.JSX.Element | null { {/* Completed summary (collapsed) */} {completedCount > 0 && activeTodos.length > 0 && (
- {completedCount} task{completedCount !== 1 ? 's' : ''} completed + {completedCount} task{completedCount !== 1 ? "s" : ""} completed
)}
diff --git a/src/renderer/src/components/chat/ContextUsageIndicator.tsx b/src/renderer/src/components/chat/ContextUsageIndicator.tsx index 3f58e99..1215521 100644 --- a/src/renderer/src/components/chat/ContextUsageIndicator.tsx +++ b/src/renderer/src/components/chat/ContextUsageIndicator.tsx @@ -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 = { // 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({ - +
{/* Header */}
Context Window - + {statusText}
@@ -154,7 +154,7 @@ export function ContextUsageIndicator({
@@ -221,28 +221,25 @@ export function ContextUsageIndicator({
)} - {tokenUsage.cacheCreationTokens !== undefined && tokenUsage.cacheCreationTokens > 0 && ( -
-
- - Cache created + {tokenUsage.cacheCreationTokens !== undefined && + tokenUsage.cacheCreationTokens > 0 && ( +
+
+ + Cache created +
+ + {formatTokenCountFull(tokenUsage.cacheCreationTokens)} +
- - {formatTokenCountFull(tokenUsage.cacheCreationTokens)} - -
- )} + )} ) : ( -
- No cached tokens -
+
No cached tokens
)}
- - {/* Last updated */}
diff --git a/src/renderer/src/components/chat/MessageBubble.tsx b/src/renderer/src/components/chat/MessageBubble.tsx index 9a71ec8..c8620f9 100644 --- a/src/renderer/src/components/chat/MessageBubble.tsx +++ b/src/renderer/src/components/chat/MessageBubble.tsx @@ -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 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 ( -
- {message.content} -
- ) + return
{message.content}
} - return ( - - {message.content} - - ) + return {message.content} } // 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 ( +
+ {block.text} +
+ ) + } return ( -
+ {block.text} -
+ ) } - return ( - - {block.text} - - ) - } - 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 */}
-
- {getLabel()} -
+
{getLabel()}
{content && ( -
+
{content}
)} @@ -127,7 +121,7 @@ export function MessageBubble({ message, isStreaming, toolResults, pendingApprov const needsApproval = Boolean(pendingId && pendingId === toolCall.id) return ( > = { // 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" })} {selectedModel.id} ) : ( @@ -172,10 +173,7 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps): React.JSX.Eleme

API key required for {selectedProvider.name}

-
@@ -202,9 +200,7 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps): React.JSX.Eleme ))} {filteredModels.length === 0 && ( -

- No models available -

+

No models available

)}
diff --git a/src/renderer/src/components/chat/StreamingMarkdown.tsx b/src/renderer/src/components/chat/StreamingMarkdown.tsx index 669c119..c3fb72f 100644 --- a/src/renderer/src/components/chat/StreamingMarkdown.tsx +++ b/src/renderer/src/components/chat/StreamingMarkdown.tsx @@ -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 (
- - {children} - + {children} {isStreaming && ( )} diff --git a/src/renderer/src/components/chat/ToolCallRenderer.tsx b/src/renderer/src/components/chat/ToolCallRenderer.tsx index 0538be8..ec99061 100644 --- a/src/renderer/src/components/chat/ToolCallRenderer.tsx +++ b/src/renderer/src/components/chat/ToolCallRenderer.tsx @@ -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> = { @@ -37,51 +37,51 @@ const TOOL_ICONS: Record> = grep: Search, execute: Terminal, write_todos: ListTodo, - task: GitBranch, + task: GitBranch } const TOOL_LABELS: Record = { - 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 = { - 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 (
{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 ( -
+
{todo.content}
@@ -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 (
{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 (
{isDir ? ( @@ -113,28 +119,33 @@ function FileListDisplay({ files, isGlob }: { files: string[] | Array<{ path: st ) })} {hasMore && ( -
- ... and {files.length - 15} more -
+
... and {files.length - 15} more
)}
) } // 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) +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 + ) const files = Object.keys(grouped).slice(0, 5) const hasMore = Object.keys(grouped).length > 5 return (
- {files.map(path => ( + {files.map((path) => (
@@ -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
         {preview.map((line, i) => (
           
- {i + 1} - {line || ' '} + + {i + 1} + + {line || " "}
))}
@@ -199,17 +212,21 @@ function FileEditSummary({ args }: { args: Record }): React.JSX return (
- - {oldStr.split('\n').length} lines + + - {oldStr.split("\n").length} lines +
- + {newStr.split('\n').length} lines + + + {newStr.split("\n").length} lines +
) } if (content) { - const lines = content.split('\n').length + const lines = content.split("\n").length return (
Writing {lines} lines to {getFileName(path)} @@ -221,7 +238,13 @@ function FileEditSummary({ args }: { args: Record }): 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 (
@@ -231,7 +254,7 @@ function CommandDisplay({ command, output }: { command: string; output?: string {output && (
           {output.slice(0, 500)}
-          {output.length > 500 && '...'}
+          {output.length > 500 && "..."}
         
)}
@@ -239,7 +262,13 @@ function CommandDisplay({ command, output }: { command: string; output?: string } // Subagent task display -function TaskDisplay({ args, isExpanded }: { args: Record; isExpanded?: boolean }): React.JSX.Element { +function TaskDisplay({ + args, + isExpanded +}: { + args: Record + 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; isEx
)} {description && ( -

+

{description}

)} @@ -263,7 +289,13 @@ function TaskDisplay({ args, isExpanded }: { args: Record; 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 @@ -315,18 +347,18 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA return null } - case 'task': { + case "task": { return } - case 'edit_file': - case 'write_file': { + case "edit_file": + case "write_file": { return } - 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 } @@ -344,15 +376,17 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA return (
- {typeof result === 'string' ? result : JSON.stringify(result)} + + {typeof result === "string" ? result : JSON.stringify(result)} +
) } 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 (
@@ -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 (
- {files} file{files !== 1 ? 's' : ''}{dirs > 0 ? `, ${dirs} folder${dirs !== 1 ? 's' : ''}` : ''} + + {files} file{files !== 1 ? "s" : ""} + {dirs > 0 ? `, ${dirs} folder${dirs !== 1 ? "s" : ""}` : ""} +
@@ -381,13 +420,15 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA return null } - case 'glob': { + case "glob": { if (Array.isArray(result)) { return (
- Found {result.length} match{result.length !== 1 ? 'es' : ''} + + Found {result.length} match{result.length !== 1 ? "es" : ""} +
@@ -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 (
- {result.length} match{result.length !== 1 ? 'es' : ''} in {fileCount} file{fileCount !== 1 ? 's' : ''} + + {result.length} match{result.length !== 1 ? "es" : ""} in {fileCount} file + {fileCount !== 1 ? "s" : ""} +
@@ -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 (
@@ -434,7 +478,7 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA
                 {output.slice(0, 500)}
-                {output.length > 500 && '...'}
+                {output.length > 500 && "..."}
               
) @@ -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 (
@@ -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 (
@@ -481,7 +525,7 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA
{result.slice(0, 500)} - {result.length > 500 && '...'} + {result.length > 500 && "..."}
) @@ -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 (
- {result.slice(0, 100)}{result.length > 100 ? '...' : ''} + + {result.slice(0, 100)} + {result.length > 100 ? "..." : ""} +
) } @@ -519,10 +566,14 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA const hasFormattedDisplay = formattedContent || formattedResult return ( -
+
{/* Header */}
) -} \ No newline at end of file +} diff --git a/src/renderer/src/components/chat/WorkspacePicker.tsx b/src/renderer/src/components/chat/WorkspacePicker.tsx index c8db25f..fa2dbbf 100644 --- a/src/renderer/src/components/chat/WorkspacePicker.tsx +++ b/src/renderer/src/components/chat/WorkspacePicker.tsx @@ -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 ( @@ -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} > - {workspacePath ? folderName : 'Select workspace'} + {workspacePath ? folderName : "Select workspace"} @@ -94,7 +90,8 @@ export function WorkspacePicker({ threadId }: WorkspacePickerProps): React.JSX.E ) : (

- 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.

- - {file.is_dir && isExpanded && children.map(child => renderNode(child, depth + 1))} + + {file.is_dir && isExpanded && children.map((child) => renderNode(child, depth + 1))}
) } // 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() {
WORKSPACE
- - {workspacePath.split('/').pop()} + + {workspacePath.split("/").pop()}
- +
{rootItems.length === 0 ? ( @@ -316,7 +327,7 @@ export function FilesystemPanel() {
) : ( - rootItems.map(file => renderNode(file)) + rootItems.map((file) => renderNode(file)) )}
diff --git a/src/renderer/src/components/panels/RightPanel.tsx b/src/renderer/src/components/panels/RightPanel.tsx index 424f286..a938902 100644 --- a/src/renderer/src/components/panels/RightPanel.tsx +++ b/src/renderer/src/components/panels/RightPanel.tsx @@ -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({ > @@ -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 (
- - {todo.content} + + {todo.content} {config.label} @@ -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"}
@@ -641,7 +641,7 @@ function FilesContent(): React.JSX.Element { No workspace files {workspacePath - ? `Linked to ${workspacePath.split('/').pop()}` + ? `Linked to ${workspacePath.split("/").pop()}` : 'Click "Link" to set a sync folder'}
@@ -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({
@@ -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 - case 'js': - case 'jsx': + case "js": + case "jsx": return - case 'json': + case "json": return - case 'md': - case 'mdx': + case "md": + case "mdx": return - case 'py': + case "py": return - case 'css': - case 'scss': - case 'sass': + case "css": + case "scss": + case "sass": return - case 'html': + case "html": return - 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 - case 'yml': - case 'yaml': + case "yml": + case "yaml": return default: return @@ -943,11 +943,11 @@ function AgentsContent(): React.JSX.Element { {agent.name} {agent.status.toUpperCase()} diff --git a/src/renderer/src/components/panels/SubagentPanel.tsx b/src/renderer/src/components/panels/SubagentPanel.tsx index 6b9eced..0953990 100644 --- a/src/renderer/src/components/panels/SubagentPanel.tsx +++ b/src/renderer/src/components/panels/SubagentPanel.tsx @@ -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 - case 'final-reviewer': + case "final-reviewer": return - case 'research': + case "research": return default: return @@ -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 (
@@ -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 ( - +
{subagent.name} {config.label} @@ -179,7 +179,7 @@ function SubagentCard({ subagent }: { subagent: Subagent }): React.JSX.Element { )} {duration && ( - {subagent.status === 'running' ? ( + {subagent.status === "running" ? ( ) : ( diff --git a/src/renderer/src/components/panels/TodoPanel.tsx b/src/renderer/src/components/panels/TodoPanel.tsx index de7a251..34095a4 100644 --- a/src/renderer/src/components/panels/TodoPanel.tsx +++ b/src/renderer/src/components/panels/TodoPanel.tsx @@ -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 (
- +
{todo.content} diff --git a/src/renderer/src/components/sidebar/ThreadSidebar.tsx b/src/renderer/src/components/sidebar/ThreadSidebar.tsx index 67eeed7..54f4184 100644 --- a/src/renderer/src/components/sidebar/ThreadSidebar.tsx +++ b/src/renderer/src/components/sidebar/ThreadSidebar.tsx @@ -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({
{ 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 - + Delete @@ -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(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 => { @@ -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 => { @@ -160,8 +151,13 @@ export function ThreadSidebar(): React.JSX.Element { return (
diff --git a/src/renderer/src/components/tabs/CodeViewer.tsx b/src/renderer/src/components/tabs/CodeViewer.tsx index 8253bd2..2f36dce 100644 --- a/src/renderer/src/components/tabs/CodeViewer.tsx +++ b/src/renderer/src/components/tabs/CodeViewer.tsx @@ -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 | null = null @@ -26,9 +26,18 @@ async function getHighlighter(): Promise { 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 = { - '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(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 (
@@ -130,17 +149,14 @@ export function CodeViewer({ filePath, content }: CodeViewerProps) { • {lineCount} lines • - {language || 'plain text'} + {language || "plain text"}
{/* File content with syntax highlighting */}
{highlightedHtml ? ( -
+
) : ( // Fallback plain text rendering
diff --git a/src/renderer/src/components/tabs/FileViewer.tsx b/src/renderer/src/components/tabs/FileViewer.tsx
index 94eadbe..02bba9d 100644
--- a/src/renderer/src/components/tabs/FileViewer.tsx
+++ b/src/renderer/src/components/tabs/FileViewer.tsx
@@ -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()
 
   // 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 (
       
     )
   }
 
-  if (fileTypeInfo.type === 'video' && binaryContent) {
+  if (fileTypeInfo.type === "video" && binaryContent) {
     return (
       
     )
   }
 
-  if (fileTypeInfo.type === 'audio' && binaryContent) {
+  if (fileTypeInfo.type === "audio" && binaryContent) {
     return (
       
     )
   }
 
-  if (fileTypeInfo.type === 'pdf' && binaryContent) {
+  if (fileTypeInfo.type === "pdf" && binaryContent) {
     return 
   }
 
-  if (fileTypeInfo.type === 'binary') {
+  if (fileTypeInfo.type === "binary") {
     return 
   }
 
diff --git a/src/renderer/src/components/tabs/ImageViewer.tsx b/src/renderer/src/components/tabs/ImageViewer.tsx
index a1bc326..e033d4f 100644
--- a/src/renderer/src/components/tabs/ImageViewer.tsx
+++ b/src/renderer/src/components/tabs/ImageViewer.tsx
@@ -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(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
             
           
 
-          
-            {zoom}%
-          
+          {zoom}%
 
           
 
-          
 
-          
         
@@ -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" }} > 100 ? 'pixelated' : 'auto' + imageRendering: zoom > 100 ? "pixelated" : "auto" }} draggable={false} /> diff --git a/src/renderer/src/components/tabs/MediaViewer.tsx b/src/renderer/src/components/tabs/MediaViewer.tsx index 965edcb..d87bb7b 100644 --- a/src/renderer/src/components/tabs/MediaViewer.tsx +++ b/src/renderer/src/components/tabs/MediaViewer.tsx @@ -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 */}
- {mediaType === 'video' ? ( + {mediaType === "video" ? ( <>
-
- @@ -43,11 +38,7 @@ export function PDFViewer({ filePath, base64Content }: PDFViewerProps): React.JS {/* PDF embed */}
- + {/* Fallback if PDF can't be displayed inline */}
diff --git a/src/renderer/src/components/tabs/TabBar.tsx b/src/renderer/src/components/tabs/TabBar.tsx index f8c62df..051787e 100644 --- a/src/renderer/src/components/tabs/TabBar.tsx +++ b/src/renderer/src/components/tabs/TabBar.tsx @@ -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 ( -
+
{/* Agent Tab - Always first and prominent */}