From ffde7a98b7b4fef3c11c2930dc8b3025f0b67364 Mon Sep 17 00:00:00 2001 From: toreleon Date: Fri, 15 May 2026 11:29:14 +0700 Subject: [PATCH 01/11] feat: add support for resolving Claude Code executable and update TypeScript configuration - Introduced `resolveClaudeCodeExecutable` function to determine the path of the Claude Code executable. - Updated `orchestrator.ts` and `runner.ts` to utilize the resolved executable path in options. - Added `pnpm-workspace.yaml` to define workspace packages. - Created `tsconfig.base.json` for shared TypeScript configuration and updated `tsconfig.json` to extend from it. - Exported `ConfigSchema` in `loader.ts` for external usage. --- .gitignore | 5 + desktop/electron-builder.yml | 25 + desktop/electron.vite.config.ts | 42 + desktop/package.json | 34 + desktop/postcss.config.js | 6 + desktop/resources/entitlements.mac.plist | 16 + desktop/src/main/consent.ts | 34 + desktop/src/main/index.ts | 49 + desktop/src/main/mainIpc.ts | 339 ++ desktop/src/main/projectContext.ts | 14 + desktop/src/main/projects.ts | 123 + desktop/src/main/sessionTail.ts | 133 + desktop/src/main/streamBus.ts | 16 + desktop/src/main/window.ts | 32 + desktop/src/preload/index.ts | 22 + desktop/src/renderer/App.tsx | 76 + .../renderer/components/ChatView/Block.tsx | 45 + .../renderer/components/ChatView/ChatView.tsx | 42 + .../components/ChatView/MarkdownContent.tsx | 227 ++ .../renderer/components/ChatView/ToolChip.tsx | 34 + .../components/Composer/AutonomyPicker.tsx | 44 + .../components/Composer/BrokerPicker.tsx | 28 + .../components/Composer/ModelPicker.tsx | 45 + .../components/Composer/PromptComposer.tsx | 248 ++ .../components/Composer/SlashSuggest.tsx | 29 + .../components/Consent/ConsentToast.tsx | 44 + .../renderer/components/Empty/EmptyState.tsx | 50 + .../components/Onboarding/Onboarding.tsx | 154 + .../components/Settings/SettingsModal.tsx | 279 ++ .../components/Sidebar/ProjectList.tsx | 86 + .../components/Sidebar/SessionList.tsx | 53 + .../renderer/components/Sidebar/Sidebar.tsx | 99 + desktop/src/renderer/index.html | 13 + desktop/src/renderer/lib/streamBridge.ts | 11 + desktop/src/renderer/lib/toolSummary.ts | 18 + desktop/src/renderer/main.tsx | 12 + desktop/src/renderer/store/chatStore.ts | 228 ++ desktop/src/renderer/styles/globals.css | 825 ++++ desktop/src/renderer/types.d.ts | 15 + desktop/src/shared/ipc.ts | 196 + desktop/src/shared/slashCommands.ts | 26 + desktop/tailwind.config.js | 39 + desktop/tsconfig.json | 7 + desktop/tsconfig.node.json | 19 + desktop/tsconfig.web.json | 22 + package.json | 6 +- pnpm-lock.yaml | 3552 ++++++++++++++++- pnpm-workspace.yaml | 3 + src/agent/claudeCodeExecutable.ts | 23 + src/agent/orchestrator.ts | 3 + src/agent/team/runner.ts | 3 + src/config/loader.ts | 2 +- tsconfig.base.json | 16 + tsconfig.json | 17 +- 54 files changed, 7475 insertions(+), 54 deletions(-) create mode 100644 desktop/electron-builder.yml create mode 100644 desktop/electron.vite.config.ts create mode 100644 desktop/package.json create mode 100644 desktop/postcss.config.js create mode 100644 desktop/resources/entitlements.mac.plist create mode 100644 desktop/src/main/consent.ts create mode 100644 desktop/src/main/index.ts create mode 100644 desktop/src/main/mainIpc.ts create mode 100644 desktop/src/main/projectContext.ts create mode 100644 desktop/src/main/projects.ts create mode 100644 desktop/src/main/sessionTail.ts create mode 100644 desktop/src/main/streamBus.ts create mode 100644 desktop/src/main/window.ts create mode 100644 desktop/src/preload/index.ts create mode 100644 desktop/src/renderer/App.tsx create mode 100644 desktop/src/renderer/components/ChatView/Block.tsx create mode 100644 desktop/src/renderer/components/ChatView/ChatView.tsx create mode 100644 desktop/src/renderer/components/ChatView/MarkdownContent.tsx create mode 100644 desktop/src/renderer/components/ChatView/ToolChip.tsx create mode 100644 desktop/src/renderer/components/Composer/AutonomyPicker.tsx create mode 100644 desktop/src/renderer/components/Composer/BrokerPicker.tsx create mode 100644 desktop/src/renderer/components/Composer/ModelPicker.tsx create mode 100644 desktop/src/renderer/components/Composer/PromptComposer.tsx create mode 100644 desktop/src/renderer/components/Composer/SlashSuggest.tsx create mode 100644 desktop/src/renderer/components/Consent/ConsentToast.tsx create mode 100644 desktop/src/renderer/components/Empty/EmptyState.tsx create mode 100644 desktop/src/renderer/components/Onboarding/Onboarding.tsx create mode 100644 desktop/src/renderer/components/Settings/SettingsModal.tsx create mode 100644 desktop/src/renderer/components/Sidebar/ProjectList.tsx create mode 100644 desktop/src/renderer/components/Sidebar/SessionList.tsx create mode 100644 desktop/src/renderer/components/Sidebar/Sidebar.tsx create mode 100644 desktop/src/renderer/index.html create mode 100644 desktop/src/renderer/lib/streamBridge.ts create mode 100644 desktop/src/renderer/lib/toolSummary.ts create mode 100644 desktop/src/renderer/main.tsx create mode 100644 desktop/src/renderer/store/chatStore.ts create mode 100644 desktop/src/renderer/styles/globals.css create mode 100644 desktop/src/renderer/types.d.ts create mode 100644 desktop/src/shared/ipc.ts create mode 100644 desktop/src/shared/slashCommands.ts create mode 100644 desktop/tailwind.config.js create mode 100644 desktop/tsconfig.json create mode 100644 desktop/tsconfig.node.json create mode 100644 desktop/tsconfig.web.json create mode 100644 pnpm-workspace.yaml create mode 100644 src/agent/claudeCodeExecutable.ts create mode 100644 tsconfig.base.json diff --git a/.gitignore b/.gitignore index a758325..a3bb9e7 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,11 @@ out/ .eslintcache .nyc_output/ +# Desktop app +desktop/node_modules/ +desktop/out/ +desktop/release/ + # Editor and OS files .DS_Store Thumbs.db diff --git a/desktop/electron-builder.yml b/desktop/electron-builder.yml new file mode 100644 index 0000000..4c6d2e7 --- /dev/null +++ b/desktop/electron-builder.yml @@ -0,0 +1,25 @@ +appId: com.toreleon.azoth +productName: Azoth +directories: + output: release + buildResources: resources +files: + - "out/**" + - "package.json" + - "!**/node_modules/*/{CHANGELOG.md,README.md,*.d.ts,*.map}" +asarUnpack: + - "**/node_modules/better-sqlite3/**" +mac: + category: public.app-category.finance + target: + - target: dmg + arch: [arm64, x64] + - target: zip + arch: [arm64, x64] + hardenedRuntime: true + gatekeeperAssess: false + entitlements: resources/entitlements.mac.plist + entitlementsInherit: resources/entitlements.mac.plist + notarize: false +dmg: + sign: false diff --git a/desktop/electron.vite.config.ts b/desktop/electron.vite.config.ts new file mode 100644 index 0000000..6203fe1 --- /dev/null +++ b/desktop/electron.vite.config.ts @@ -0,0 +1,42 @@ +import { defineConfig, externalizeDepsPlugin } from "electron-vite"; +import react from "@vitejs/plugin-react"; +import { resolve } from "node:path"; + +const coreAlias = { + "@azoth/core": resolve(__dirname, "../src"), + "@shared": resolve(__dirname, "src/shared"), +}; + +export default defineConfig({ + main: { + plugins: [externalizeDepsPlugin()], + resolve: { alias: coreAlias }, + build: { + outDir: "out/main", + rollupOptions: { + input: resolve(__dirname, "src/main/index.ts"), + }, + }, + }, + preload: { + plugins: [externalizeDepsPlugin()], + resolve: { alias: coreAlias }, + build: { + outDir: "out/preload", + rollupOptions: { + input: resolve(__dirname, "src/preload/index.ts"), + }, + }, + }, + renderer: { + root: resolve(__dirname, "src/renderer"), + plugins: [react()], + resolve: { alias: coreAlias }, + build: { + outDir: resolve(__dirname, "out/renderer"), + rollupOptions: { + input: resolve(__dirname, "src/renderer/index.html"), + }, + }, + }, +}); diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 0000000..7663bce --- /dev/null +++ b/desktop/package.json @@ -0,0 +1,34 @@ +{ + "name": "azoth-desktop", + "version": "0.1.0", + "private": true, + "main": "out/main/index.js", + "scripts": { + "dev": "electron-vite dev", + "build": "electron-vite build && electron-builder --mac", + "build:unpack": "electron-vite build && electron-builder --mac --dir", + "preview": "electron-vite preview", + "postinstall": "electron-builder install-app-deps", + "typecheck": "tsc -p tsconfig.node.json --noEmit && tsc -p tsconfig.web.json --noEmit", + "rebuild": "electron-rebuild -f -w better-sqlite3" + }, + "dependencies": { + "zustand": "^4.5.5" + }, + "devDependencies": { + "@electron/rebuild": "^3.6.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.0", + "electron": "^33.0.0", + "electron-builder": "^25.0.0", + "electron-vite": "^2.3.0", + "postcss": "^8.4.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwindcss": "^3.4.0", + "typescript": "^5.6.3", + "vite": "^5.4.0" + } +} diff --git a/desktop/postcss.config.js b/desktop/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/desktop/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/desktop/resources/entitlements.mac.plist b/desktop/resources/entitlements.mac.plist new file mode 100644 index 0000000..9b2443e --- /dev/null +++ b/desktop/resources/entitlements.mac.plist @@ -0,0 +1,16 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-write + + + diff --git a/desktop/src/main/consent.ts b/desktop/src/main/consent.ts new file mode 100644 index 0000000..f61d9d6 --- /dev/null +++ b/desktop/src/main/consent.ts @@ -0,0 +1,34 @@ +import { randomUUID } from "node:crypto"; +import { setBrokerConsentHandler, type BrokerConsentRequest } from "@azoth/core/tools/brokerConsent.js"; +import { sendStream } from "./streamBus.js"; + +const pending = new Map void>(); + +export function installConsentBridge(): void { + setBrokerConsentHandler(async (req: BrokerConsentRequest) => { + return new Promise((resolve) => { + const id = randomUUID(); + pending.set(id, resolve); + sendStream({ + kind: "consent:request", + id, + action: req.action, + detail: req.detail, + broker: req.broker, + autonomy: req.autonomy, + }); + }); + }); +} + +export function respondConsent(id: string, approved: boolean): void { + const resolve = pending.get(id); + if (!resolve) return; + pending.delete(id); + resolve(approved); +} + +export function clearConsent(): void { + for (const resolve of pending.values()) resolve(false); + pending.clear(); +} diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts new file mode 100644 index 0000000..3b7b553 --- /dev/null +++ b/desktop/src/main/index.ts @@ -0,0 +1,49 @@ +import { app, BrowserWindow } from "electron"; +import { initializeAzothRuntime } from "@azoth/core/runtime/init.js"; +import { loadConfig } from "@azoth/core/config/loader.js"; +import { ensureDefaultProject, getProject } from "./projects.js"; +import { activateProject } from "./projectContext.js"; +import { createMainWindow } from "./window.js"; +import { bindMainWindow } from "./streamBus.js"; +import { abortAllTurns, registerIpcHandlers } from "./mainIpc.js"; +import { installConsentBridge, clearConsent } from "./consent.js"; + +function boot(): void { + initializeAzothRuntime(); + try { + loadConfig(); + } catch (err) { + // Config may be incomplete on first boot; ignored — onboarding will fix it. + console.warn("[azoth] loadConfig failed during boot:", (err as Error).message); + } + const { activeId } = ensureDefaultProject(); + const project = getProject(activeId); + if (project) activateProject(project); + installConsentBridge(); + registerIpcHandlers(); + + const win = createMainWindow(); + bindMainWindow(win); +} + +app.whenReady().then(boot).catch((err) => { + console.error("[azoth] fatal boot error:", err); + app.exit(1); +}); + +app.on("window-all-closed", () => { + abortAllTurns(); + clearConsent(); + if (process.platform !== "darwin") app.quit(); +}); + +app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + const win = createMainWindow(); + bindMainWindow(win); + } +}); + +app.on("before-quit", () => { + abortAllTurns(); +}); diff --git a/desktop/src/main/mainIpc.ts b/desktop/src/main/mainIpc.ts new file mode 100644 index 0000000..f524769 --- /dev/null +++ b/desktop/src/main/mainIpc.ts @@ -0,0 +1,339 @@ +import { ipcMain } from "electron"; +import { + ActivateProjectReq, + AbortTurnReq, + ConsentRespondReq, + CreateProjectReq, + DeleteProjectReq, + HealthProbeReq, + ListSessionsReq, + ResumeSessionReq, + SaveConfigReq, + SendPromptReq, + SlashCommandReq, + StartSessionReq, + type ChatRecord, + type IpcChannelMap, + type SessionDescriptor, +} from "../shared/ipc.js"; +import { + createProject, + deleteProject, + getProject, + isOnboarded, + listProjects, + setActiveProject, + setOnboarded, +} from "./projects.js"; +import { activateProject } from "./projectContext.js"; +import { respondConsent } from "./consent.js"; +import { sendStream } from "./streamBus.js"; +import { + resumeSession, + startNewSession, + recentSessions, + runTurn, +} from "@azoth/core/agent/orchestrator.js"; +import { + listSessions, + readSessionRecords, + type SessionIndexEntry, + type SessionRecord, +} from "@azoth/core/runtime/sessionStore.js"; +import { loadConfig, saveConfig, updateConfig, type Config } from "@azoth/core/config/loader.js"; +import { collectHealth } from "@azoth/core/runtime/health.js"; + +const activeTurns = new Map void }>(); + +function toDescriptor(entry: SessionIndexEntry): SessionDescriptor { + return { + id: entry.id, + sdkSessionId: entry.sdkSessionId, + title: entry.title, + cwd: entry.cwd, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + model: entry.model, + autonomy: entry.autonomy, + }; +} + +function toRecord(r: SessionRecord): ChatRecord { + return { + type: r.type, + timestamp: r.timestamp, + sessionId: r.sessionId, + text: r.text, + toolName: r.toolName, + toolUseId: r.toolUseId, + toolInput: r.toolInput, + sdkSessionId: r.sdkSessionId, + usage: r.usage, + costUsd: r.costUsd, + model: r.model, + autonomy: r.autonomy, + title: r.title, + }; +} + +function sendRecord(turnId: string, record: ChatRecord): void { + sendStream({ + kind: "turn:record", + turnId, + sessionId: record.sessionId, + record, + }); +} + +function sendLiveBlockEvent(turnId: string, sessionId: string, message: unknown): void { + if ((message as { type?: string }).type !== "stream_event") return; + const ev = (message as { event?: any }).event; + if (ev?.type === "content_block_start") { + const cb = ev.content_block; + const blockType = + cb?.type === "thinking" + ? "thinking" + : cb?.type === "text" + ? "assistant" + : cb?.type === "tool_use" + ? "tool_use" + : undefined; + if (!blockType) return; + sendStream({ + kind: "turn:block_start", + turnId, + sessionId, + blockType, + toolName: cb?.name, + toolUseId: cb?.id, + timestamp: Date.now(), + }); + } else if (ev?.type === "content_block_delta") { + const d = ev.delta; + const delta = + d?.type === "thinking_delta" + ? d.thinking + : d?.type === "text_delta" + ? d.text + : d?.type === "input_json_delta" + ? d.partial_json + : undefined; + if (!delta) return; + sendStream({ + kind: "turn:block_delta", + turnId, + sessionId, + delta, + }); + } else if (ev?.type === "content_block_stop" || ev?.type === "message_stop") { + sendStream({ + kind: "turn:block_stop", + turnId, + sessionId, + }); + } +} + +function activateProjectById(id: string) { + const project = getProject(id); + if (!project) throw new Error(`Unknown project: ${id}`); + activateProject(project); + return project; +} + +type Handler = ( + req: IpcChannelMap[K]["req"], +) => Promise | IpcChannelMap[K]["res"]; + +function register(channel: K, handler: Handler): void { + ipcMain.handle(channel, async (_evt, raw) => handler(raw)); +} + +export function registerIpcHandlers(): void { + register("project:list", () => listProjects()); + + register("project:create", (raw) => { + const req = CreateProjectReq.parse(raw); + return createProject(req); + }); + + register("project:delete", (raw) => { + const req = DeleteProjectReq.parse(raw); + deleteProject(req.id); + return { ok: true as const }; + }); + + register("project:activate", (raw) => { + const req = ActivateProjectReq.parse(raw); + const project = setActiveProject(req.id); + activateProject(project); + return project; + }); + + register("session:list", (raw) => { + const req = ListSessionsReq.parse(raw); + activateProjectById(req.projectId); + return listSessions().map(toDescriptor); + }); + + register("session:start", (raw) => { + const req = StartSessionReq.parse(raw); + activateProjectById(req.projectId); + const entry = startNewSession(req.title); + return toDescriptor(entry); + }); + + register("session:resume", (raw) => { + const req = ResumeSessionReq.parse(raw); + activateProjectById(req.projectId); + const entry = resumeSession(req.sessionId); + if (!entry) throw new Error(`Session not found: ${req.sessionId}`); + const records = readSessionRecords(entry.id).map(toRecord); + return { session: toDescriptor(entry), records }; + }); + + register("turn:send", (raw) => { + const req = SendPromptReq.parse(raw); + const project = activateProjectById(req.projectId); + resumeSession(req.sessionId); + const controller = new AbortController(); + let streamedRecordCount = readSessionRecords(req.sessionId, project.rootPath).length; + + const drainRecords = () => { + const records = readSessionRecords(req.sessionId, project.rootPath).map(toRecord); + for (const record of records.slice(streamedRecordCount)) { + sendRecord(req.turnId, record); + } + streamedRecordCount = records.length; + }; + + activeTurns.set(req.turnId, { controller, stopTail: () => undefined }); + + void (async () => { + try { + let usage: ChatRecord["usage"] | undefined; + let costUsd: number | undefined; + let sdkSessionId: string | undefined; + for await (const message of runTurn(req.prompt, { signal: controller.signal })) { + drainRecords(); + sendLiveBlockEvent(req.turnId, req.sessionId, message); + if ((message as { type?: string }).type === "result") { + const r = message as { + session_id?: string; + total_cost_usd?: number; + usage?: { + input_tokens?: number; + output_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; + }; + }; + sdkSessionId = r.session_id; + costUsd = r.total_cost_usd; + usage = { + inputTokens: r.usage?.input_tokens, + outputTokens: r.usage?.output_tokens, + cacheReadTokens: r.usage?.cache_read_input_tokens, + cacheCreationTokens: r.usage?.cache_creation_input_tokens, + }; + } + } + drainRecords(); + sendStream({ + kind: "turn:done", + turnId: req.turnId, + sessionId: req.sessionId, + usage, + costUsd, + sdkSessionId, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + sendStream({ + kind: "turn:error", + turnId: req.turnId, + sessionId: req.sessionId, + message, + }); + } finally { + activeTurns.delete(req.turnId); + } + })(); + + return { ok: true as const }; + }); + + register("turn:abort", (raw) => { + const req = AbortTurnReq.parse(raw); + const entry = activeTurns.get(req.turnId); + if (!entry) return { ok: false }; + entry.controller.abort(); + return { ok: true }; + }); + + register("slash:run", async (raw) => { + const req = SlashCommandReq.parse(raw); + activateProjectById(req.projectId); + switch (req.name) { + case "sessions": { + const list = recentSessions(20) + .map((s) => `${s.id.slice(0, 8)} ${s.title}`) + .join("\n"); + return { ok: true as const, text: list || "(no sessions)" }; + } + case "health": { + const report = await collectHealth({ probeProviders: req.args?.includes("--probe") }); + return { ok: true as const, text: JSON.stringify(report, null, 2) }; + } + case "new": { + startNewSession(); + return { ok: true as const }; + } + default: + return { ok: true as const }; + } + }); + + register("config:get", () => loadConfig() as unknown); + + register("config:save", (raw) => { + const req = SaveConfigReq.parse(raw); + if (Object.keys(req.patch).length === 0) return loadConfig() as unknown; + return updateConfig(req.patch as Partial) as unknown; + }); + + register("broker:state", async () => { + // Best-effort; broker state is exposed via a tool, but a direct read is useful. + const cfg = loadConfig(); + return { broker: cfg.broker, autonomy: cfg.autonomy }; + }); + + register("health:probe", async (raw) => { + const req = HealthProbeReq.parse(raw); + return collectHealth({ probeProviders: req.probe }); + }); + + register("consent:respond", (raw) => { + const req = ConsentRespondReq.parse(raw); + respondConsent(req.id, req.approved); + return { ok: true as const }; + }); + + register("onboarding:status", () => ({ onboarded: isOnboarded() })); + + register("onboarding:complete", () => { + setOnboarded(true); + return { ok: true as const }; + }); +} + +export function abortAllTurns(): void { + for (const { controller, stopTail } of activeTurns.values()) { + controller.abort(); + stopTail(); + } + activeTurns.clear(); +} + +// Silence unused saveConfig warning while keeping the import explicit. +void saveConfig; diff --git a/desktop/src/main/projectContext.ts b/desktop/src/main/projectContext.ts new file mode 100644 index 0000000..5a4623e --- /dev/null +++ b/desktop/src/main/projectContext.ts @@ -0,0 +1,14 @@ +import { mkdirSync } from "node:fs"; +import type { Project } from "../shared/ipc.js"; + +let currentProjectId: string | null = null; + +export function activateProject(project: Project): void { + mkdirSync(project.rootPath, { recursive: true }); + process.chdir(project.rootPath); + currentProjectId = project.id; +} + +export function getCurrentProjectId(): string | null { + return currentProjectId; +} diff --git a/desktop/src/main/projects.ts b/desktop/src/main/projects.ts new file mode 100644 index 0000000..94e582e --- /dev/null +++ b/desktop/src/main/projects.ts @@ -0,0 +1,123 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { randomUUID } from "node:crypto"; +import { azothHome } from "@azoth/core/runtime/paths.js"; +import type { Project } from "../shared/ipc.js"; + +interface ProjectsFile { + version: 1; + onboarded: boolean; + activeId: string | null; + projects: Project[]; +} + +function projectsFilePath(): string { + return resolve(azothHome(), "projects-desktop.json"); +} + +function defaultRootFor(id: string): string { + return resolve(azothHome(), "desktop-projects", id); +} + +function emptyFile(): ProjectsFile { + return { version: 1, onboarded: false, activeId: null, projects: [] }; +} + +function readFile(): ProjectsFile { + const path = projectsFilePath(); + if (!existsSync(path)) return emptyFile(); + try { + const raw = JSON.parse(readFileSync(path, "utf8")) as Partial; + return { + version: 1, + onboarded: Boolean(raw.onboarded), + activeId: raw.activeId ?? null, + projects: Array.isArray(raw.projects) ? (raw.projects as Project[]) : [], + }; + } catch { + return emptyFile(); + } +} + +function writeFile(file: ProjectsFile): void { + mkdirSync(resolve(azothHome()), { recursive: true }); + writeFileSync(projectsFilePath(), `${JSON.stringify(file, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); +} + +export function ensureDefaultProject(): { projects: Project[]; activeId: string } { + const file = readFile(); + if (file.projects.length === 0) { + const id = randomUUID(); + const root = defaultRootFor("personal"); + mkdirSync(root, { recursive: true }); + const project: Project = { + id, + name: "Personal", + rootPath: root, + createdAt: Date.now(), + isDefault: true, + }; + file.projects.push(project); + file.activeId = id; + writeFile(file); + } else if (!file.activeId) { + file.activeId = file.projects[0]!.id; + writeFile(file); + } + return { projects: file.projects, activeId: file.activeId! }; +} + +export function listProjects(): { projects: Project[]; activeId: string | null } { + const file = readFile(); + return { projects: file.projects, activeId: file.activeId }; +} + +export function getProject(id: string): Project | undefined { + return readFile().projects.find((p) => p.id === id); +} + +export function createProject(input: { name: string; rootPath?: string }): Project { + const file = readFile(); + const id = randomUUID(); + const root = resolve(input.rootPath ?? defaultRootFor(id)); + mkdirSync(root, { recursive: true }); + const project: Project = { + id, + name: input.name, + rootPath: root, + createdAt: Date.now(), + }; + file.projects.push(project); + if (!file.activeId) file.activeId = id; + writeFile(file); + return project; +} + +export function deleteProject(id: string): void { + const file = readFile(); + file.projects = file.projects.filter((p) => p.id !== id); + if (file.activeId === id) file.activeId = file.projects[0]?.id ?? null; + writeFile(file); +} + +export function setActiveProject(id: string): Project { + const file = readFile(); + const project = file.projects.find((p) => p.id === id); + if (!project) throw new Error(`Unknown project: ${id}`); + file.activeId = id; + writeFile(file); + return project; +} + +export function isOnboarded(): boolean { + return readFile().onboarded; +} + +export function setOnboarded(value: boolean): void { + const file = readFile(); + file.onboarded = value; + writeFile(file); +} diff --git a/desktop/src/main/sessionTail.ts b/desktop/src/main/sessionTail.ts new file mode 100644 index 0000000..4fc128a --- /dev/null +++ b/desktop/src/main/sessionTail.ts @@ -0,0 +1,133 @@ +import { existsSync, statSync, createReadStream, watch, type FSWatcher } from "node:fs"; +import { sessionFile } from "@azoth/core/runtime/sessionStore.js"; +import type { ChatRecord, StreamEvent } from "../shared/ipc.js"; +import { sendStream } from "./streamBus.js"; + +interface TailHandle { + stop(): void; +} + +interface ActiveTail { + sessionId: string; + turnId: string; + offset: number; + buffer: string; + watcher: FSWatcher | null; + reading: boolean; + closed: boolean; +} + +function recordToEvent(turnId: string, record: ChatRecord): StreamEvent | null { + switch (record.type) { + case "thinking": + case "assistant": + return { + kind: "turn:record", + turnId, + sessionId: record.sessionId, + record, + }; + case "tool_use": + case "tool_result": + case "user": + case "system": + return { + kind: "turn:record", + turnId, + sessionId: record.sessionId, + record, + }; + case "result": + return { + kind: "turn:record", + turnId, + sessionId: record.sessionId, + record, + }; + default: + return null; + } +} + +async function drain(state: ActiveTail, path: string): Promise { + if (state.reading) return; + state.reading = true; + try { + if (!existsSync(path)) return; + const size = statSync(path).size; + if (size <= state.offset) return; + await new Promise((resolve, reject) => { + const stream = createReadStream(path, { + start: state.offset, + end: size - 1, + encoding: "utf8", + }); + stream.on("data", (chunk) => { + state.buffer += chunk; + }); + stream.on("error", reject); + stream.on("end", () => { + state.offset = size; + let nl = state.buffer.indexOf("\n"); + while (nl !== -1) { + const line = state.buffer.slice(0, nl).trim(); + state.buffer = state.buffer.slice(nl + 1); + if (line) { + try { + const record = JSON.parse(line) as ChatRecord; + const ev = recordToEvent(state.turnId, record); + if (ev) sendStream(ev); + } catch { + // ignore malformed line + } + } + nl = state.buffer.indexOf("\n"); + } + resolve(); + }); + }); + } finally { + state.reading = false; + } +} + +export function tailSession(opts: { + sessionId: string; + turnId: string; + cwd: string; +}): TailHandle { + const path = sessionFile(opts.sessionId, opts.cwd); + const state: ActiveTail = { + sessionId: opts.sessionId, + turnId: opts.turnId, + offset: existsSync(path) ? statSync(path).size : 0, + buffer: "", + watcher: null, + reading: false, + closed: false, + }; + try { + state.watcher = watch(path, { persistent: false }, () => { + void drain(state, path); + }); + } catch { + // file may not exist yet; fallback handled below + } + const interval = setInterval(() => { + void drain(state, path); + }, 80); + return { + stop() { + clearInterval(interval); + try { + state.watcher?.close(); + } catch { + /* noop */ + } + // Final drain. + void drain(state, path).finally(() => { + state.closed = true; + }); + }, + }; +} diff --git a/desktop/src/main/streamBus.ts b/desktop/src/main/streamBus.ts new file mode 100644 index 0000000..3ffdc28 --- /dev/null +++ b/desktop/src/main/streamBus.ts @@ -0,0 +1,16 @@ +import type { BrowserWindow } from "electron"; +import { STREAM_CHANNEL, type StreamEvent } from "../shared/ipc.js"; + +let mainWindow: BrowserWindow | null = null; + +export function bindMainWindow(win: BrowserWindow): void { + mainWindow = win; + win.on("closed", () => { + if (mainWindow === win) mainWindow = null; + }); +} + +export function sendStream(event: StreamEvent): void { + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.webContents.send(STREAM_CHANNEL, event); +} diff --git a/desktop/src/main/window.ts b/desktop/src/main/window.ts new file mode 100644 index 0000000..7f5b58f --- /dev/null +++ b/desktop/src/main/window.ts @@ -0,0 +1,32 @@ +import { BrowserWindow, shell } from "electron"; +import { resolve } from "node:path"; + +export function createMainWindow(): BrowserWindow { + const win = new BrowserWindow({ + width: 1280, + height: 820, + minWidth: 960, + minHeight: 640, + titleBarStyle: "hiddenInset", + backgroundColor: "#fafafa", + webPreferences: { + preload: resolve(__dirname, "../preload/index.js"), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + }, + }); + + win.webContents.setWindowOpenHandler(({ url }) => { + void shell.openExternal(url); + return { action: "deny" }; + }); + + const devUrl = process.env["ELECTRON_RENDERER_URL"]; + if (devUrl) { + void win.loadURL(devUrl); + } else { + void win.loadFile(resolve(__dirname, "../renderer/index.html")); + } + return win; +} diff --git a/desktop/src/preload/index.ts b/desktop/src/preload/index.ts new file mode 100644 index 0000000..df90eb6 --- /dev/null +++ b/desktop/src/preload/index.ts @@ -0,0 +1,22 @@ +import { contextBridge, ipcRenderer } from "electron"; +import { STREAM_CHANNEL, type IpcChannel, type IpcChannelMap, type StreamEvent } from "../shared/ipc.js"; + +const api = { + invoke: ( + channel: K, + req: IpcChannelMap[K]["req"], + ): Promise => { + return ipcRenderer.invoke(channel, req) as Promise; + }, + on: (handler: (event: StreamEvent) => void): (() => void) => { + const listener = (_: unknown, event: StreamEvent) => handler(event); + ipcRenderer.on(STREAM_CHANNEL, listener); + return () => { + ipcRenderer.removeListener(STREAM_CHANNEL, listener); + }; + }, +}; + +contextBridge.exposeInMainWorld("azoth", api); + +export type AzothApi = typeof api; diff --git a/desktop/src/renderer/App.tsx b/desktop/src/renderer/App.tsx new file mode 100644 index 0000000..b791007 --- /dev/null +++ b/desktop/src/renderer/App.tsx @@ -0,0 +1,76 @@ +import { useEffect } from "react"; +import { Sidebar } from "./components/Sidebar/Sidebar.js"; +import { ChatView } from "./components/ChatView/ChatView.js"; +import { EmptyState } from "./components/Empty/EmptyState.js"; +import { PromptComposer } from "./components/Composer/PromptComposer.js"; +import { Onboarding } from "./components/Onboarding/Onboarding.js"; +import { ConsentToast } from "./components/Consent/ConsentToast.js"; +import { useStreamBridge } from "./lib/streamBridge.js"; +import { useChatStore } from "./store/chatStore.js"; + +export function App() { + useStreamBridge(); + const { + activeProjectId, + activeSessionId, + projects, + sessions, + onboarded, + setProjects, + setSessions, + setOnboarded, + setConfig, + } = useChatStore(); + + useEffect(() => { + void (async () => { + const [{ projects, activeId }, status, cfg] = await Promise.all([ + window.azoth.invoke("project:list", undefined), + window.azoth.invoke("onboarding:status", undefined), + window.azoth.invoke("config:get", undefined), + ]); + setProjects(projects, activeId); + setOnboarded(status.onboarded); + setConfig(cfg); + })(); + }, [setProjects, setOnboarded, setConfig]); + + useEffect(() => { + if (!activeProjectId) return; + void (async () => { + const sessions = await window.azoth.invoke("session:list", { + projectId: activeProjectId, + }); + setSessions(sessions); + })(); + }, [activeProjectId, setSessions]); + + if (!onboarded) { + return setOnboarded(true)} />; + } + + const activeSession = sessions.find((s) => s.id === activeSessionId); + const activeProject = projects.find((p) => p.id === activeProjectId); + const windowTitle = `${activeSession?.title ?? "New chat"}${ + activeProject?.name ? ` - ${activeProject.name}` : "" + }`; + + return ( +
+
+
+ + + +
+
{windowTitle}
+
+ +
+ {activeSessionId ? : } + +
+ +
+ ); +} diff --git a/desktop/src/renderer/components/ChatView/Block.tsx b/desktop/src/renderer/components/ChatView/Block.tsx new file mode 100644 index 0000000..c0780b8 --- /dev/null +++ b/desktop/src/renderer/components/ChatView/Block.tsx @@ -0,0 +1,45 @@ +import type { ChatRecord } from "../../../shared/ipc.js"; +import { MarkdownContent } from "./MarkdownContent.js"; +import { ToolChip } from "./ToolChip.js"; + +export function Block({ record }: { record: ChatRecord }) { + switch (record.type) { + case "user": + return ( +
+
{record.text}
+
+ ); + case "assistant": + return ( +
+
+ +
+
+ ); + case "thinking": + return ( +
+
Reasoning
+ {record.text} +
+ ); + case "tool_use": + return ; + case "tool_result": + return ; + case "result": + return null; + case "error": + return ( +
+
{record.text}
+
+ ); + case "session_start": + case "system": + default: + return null; + } +} diff --git a/desktop/src/renderer/components/ChatView/ChatView.tsx b/desktop/src/renderer/components/ChatView/ChatView.tsx new file mode 100644 index 0000000..b9c5bb0 --- /dev/null +++ b/desktop/src/renderer/components/ChatView/ChatView.tsx @@ -0,0 +1,42 @@ +import { useEffect, useRef } from "react"; +import { useChatStore } from "../../store/chatStore.js"; +import { Block } from "./Block.js"; + +interface Props { + sessionId: string; +} + +export function ChatView({ sessionId }: Props) { + const records = useChatStore((s) => s.recordsBySession[sessionId] ?? []); + const liveRecords = useChatStore((s) => s.liveRecordsBySession[sessionId] ?? []); + const streaming = useChatStore((s) => s.streaming); + const scrollRef = useRef(null); + const liveTextSize = liveRecords.reduce( + (sum, record) => sum + (record.text?.length ?? 0) + (record.toolInput?.length ?? 0), + 0, + ); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 200; + if (nearBottom) el.scrollTop = el.scrollHeight; + }, [records.length, liveRecords.length, liveTextSize]); + + return ( +
+ {records.map((record, idx) => ( + + ))} + {liveRecords.map((record, idx) => ( + + ))} + {streaming && liveRecords.length === 0 && ( +
+
Reasoning
+ Thinking... +
+ )} +
+ ); +} diff --git a/desktop/src/renderer/components/ChatView/MarkdownContent.tsx b/desktop/src/renderer/components/ChatView/MarkdownContent.tsx new file mode 100644 index 0000000..1177448 --- /dev/null +++ b/desktop/src/renderer/components/ChatView/MarkdownContent.tsx @@ -0,0 +1,227 @@ +import type React from "react"; + +interface Props { + text: string; +} + +type Block = + | { type: "heading"; level: 1 | 2 | 3; text: string } + | { type: "paragraph"; text: string } + | { type: "code"; lang: string; text: string } + | { type: "blockquote"; text: string } + | { type: "list"; ordered: boolean; items: string[] } + | { type: "table"; headers: string[]; rows: string[][] }; + +export function MarkdownContent({ text }: Props) { + const blocks = parseBlocks(text); + return ( + <> + {blocks.map((block, idx) => ( + + ))} + + ); +} + +function MarkdownBlock({ block }: { block: Block }) { + switch (block.type) { + case "heading": { + const children = parseInline(block.text); + if (block.level === 1) return

{children}

; + if (block.level === 2) return

{children}

; + return

{children}

; + } + case "paragraph": + return

{parseInline(block.text)}

; + case "code": + return ( +
+          {block.lang ? (
+            
+ {block.lang} +
+ ) : null} + {block.text} +
+ ); + case "blockquote": + return
{parseInline(block.text)}
; + case "list": { + const Tag = block.ordered ? "ol" : "ul"; + return ( + + {block.items.map((item, idx) => ( +
  • {parseInline(item)}
  • + ))} +
    + ); + } + case "table": + return ( + + + + {block.headers.map((header, idx) => ( + + ))} + + + + {block.rows.map((row, rowIdx) => ( + + {block.headers.map((_, cellIdx) => ( + + ))} + + ))} + +
    {parseInline(header)}
    {parseInline(row[cellIdx] ?? "")}
    + ); + } +} + +function parseBlocks(text: string): Block[] { + const lines = text.replace(/\r\n/g, "\n").split("\n"); + const blocks: Block[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i] ?? ""; + if (!line.trim()) { + i++; + continue; + } + + const fence = line.match(/^```([\w-]*)\s*$/); + if (fence) { + const code: string[] = []; + i++; + while (i < lines.length && !/^```\s*$/.test(lines[i] ?? "")) { + code.push(lines[i] ?? ""); + i++; + } + if (i < lines.length) i++; + blocks.push({ type: "code", lang: fence[1] ?? "", text: code.join("\n") }); + continue; + } + + const heading = line.match(/^(#{1,3})\s+(.+)$/); + if (heading) { + blocks.push({ + type: "heading", + level: heading[1]!.length as 1 | 2 | 3, + text: heading[2]!, + }); + i++; + continue; + } + + if (isTableStart(lines, i)) { + const headers = splitTableRow(lines[i]!); + i += 2; + const rows: string[][] = []; + while (i < lines.length && /^\s*\|.+\|\s*$/.test(lines[i] ?? "")) { + rows.push(splitTableRow(lines[i]!)); + i++; + } + blocks.push({ type: "table", headers, rows }); + continue; + } + + if (/^>\s?/.test(line)) { + const quote: string[] = []; + while (i < lines.length && /^>\s?/.test(lines[i] ?? "")) { + quote.push((lines[i] ?? "").replace(/^>\s?/, "")); + i++; + } + blocks.push({ type: "blockquote", text: quote.join(" ") }); + continue; + } + + const ordered = /^\d+\.\s+/.test(line); + const unordered = /^[-*]\s+/.test(line); + if (ordered || unordered) { + const items: string[] = []; + const pattern = ordered ? /^\d+\.\s+/ : /^[-*]\s+/; + while (i < lines.length && pattern.test(lines[i] ?? "")) { + items.push((lines[i] ?? "").replace(pattern, "")); + i++; + } + blocks.push({ type: "list", ordered, items }); + continue; + } + + const paragraph: string[] = []; + while ( + i < lines.length && + lines[i]?.trim() && + !/^```/.test(lines[i] ?? "") && + !/^(#{1,3})\s+/.test(lines[i] ?? "") && + !/^>\s?/.test(lines[i] ?? "") && + !/^\d+\.\s+/.test(lines[i] ?? "") && + !/^[-*]\s+/.test(lines[i] ?? "") && + !isTableStart(lines, i) + ) { + paragraph.push(lines[i] ?? ""); + i++; + } + blocks.push({ type: "paragraph", text: paragraph.join(" ") }); + } + + return blocks; +} + +function isTableStart(lines: string[], index: number): boolean { + const header = lines[index] ?? ""; + const separator = lines[index + 1] ?? ""; + return ( + /^\s*\|.+\|\s*$/.test(header) && + /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(separator) + ); +} + +function splitTableRow(line: string): string[] { + return line + .trim() + .replace(/^\|/, "") + .replace(/\|$/, "") + .split("|") + .map((cell) => cell.trim()); +} + +function parseInline(text: string): React.ReactNode[] { + const nodes: React.ReactNode[] = []; + const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\))/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = pattern.exec(text)) != null) { + if (match.index > lastIndex) nodes.push(text.slice(lastIndex, match.index)); + const token = match[0]; + const key = nodes.length; + + if (token.startsWith("`")) { + nodes.push({token.slice(1, -1)}); + } else if (token.startsWith("**")) { + nodes.push({parseInline(token.slice(2, -2))}); + } else if (token.startsWith("*")) { + nodes.push({parseInline(token.slice(1, -1))}); + } else { + const link = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/); + const href = link?.[2] ?? ""; + nodes.push( + + {parseInline(link?.[1] ?? token)} + , + ); + } + lastIndex = match.index + token.length; + } + + if (lastIndex < text.length) nodes.push(text.slice(lastIndex)); + return nodes; +} + +function safeHref(href: string): string { + return /^(https?:|mailto:)/i.test(href) ? href : "#"; +} diff --git a/desktop/src/renderer/components/ChatView/ToolChip.tsx b/desktop/src/renderer/components/ChatView/ToolChip.tsx new file mode 100644 index 0000000..22f8e94 --- /dev/null +++ b/desktop/src/renderer/components/ChatView/ToolChip.tsx @@ -0,0 +1,34 @@ +import { useState } from "react"; +import type { ChatRecord } from "../../../shared/ipc.js"; +import { summarizeToolInput, toolLabel } from "../../lib/toolSummary.js"; + +interface Props { + record: ChatRecord; + state: "running" | "done"; +} + +export function ToolChip({ record, state }: Props) { + const [open, setOpen] = useState(false); + const isResult = record.type === "tool_result"; + const label = isResult ? "result" : toolLabel(record.toolName); + const summary = isResult ? "" : summarizeToolInput(record.toolName, record.toolInput); + const body = isResult ? record.text ?? "" : record.toolInput ?? ""; + + return ( +
    setOpen(e.currentTarget.open)}> + + + {label} + {summary && {summary}} + + {state === "running" ? "Running" : "Done"} + + + {open && body && ( +
    + {body} +
    + )} +
    + ); +} diff --git a/desktop/src/renderer/components/Composer/AutonomyPicker.tsx b/desktop/src/renderer/components/Composer/AutonomyPicker.tsx new file mode 100644 index 0000000..331f333 --- /dev/null +++ b/desktop/src/renderer/components/Composer/AutonomyPicker.tsx @@ -0,0 +1,44 @@ +import { useChatStore } from "../../store/chatStore.js"; + +const MODES = ["advisory", "confirm", "auto"] as const; +const LABELS: Record<(typeof MODES)[number], string> = { + advisory: "Advise", + confirm: "Approve", + auto: "Auto", +}; + +export function AutonomyPicker() { + const config = useChatStore((s) => s.config) as { autonomy?: string } | null; + const setConfig = useChatStore((s) => s.setConfig); + + async function update(autonomy: string) { + const next = await window.azoth.invoke("config:save", { patch: { autonomy } }); + setConfig(next); + } + + return ( + + ); +} + +function ChevronIcon() { + return ( + + + + ); +} diff --git a/desktop/src/renderer/components/Composer/BrokerPicker.tsx b/desktop/src/renderer/components/Composer/BrokerPicker.tsx new file mode 100644 index 0000000..51e0c3a --- /dev/null +++ b/desktop/src/renderer/components/Composer/BrokerPicker.tsx @@ -0,0 +1,28 @@ +import { useChatStore } from "../../store/chatStore.js"; + +const BROKERS = ["paper", "dnse", "fhsc"] as const; + +export function BrokerPicker() { + const config = useChatStore((s) => s.config) as { broker?: string } | null; + const setConfig = useChatStore((s) => s.setConfig); + + async function update(broker: string) { + const next = await window.azoth.invoke("config:save", { patch: { broker } }); + setConfig(next); + } + + return ( + + ); +} diff --git a/desktop/src/renderer/components/Composer/ModelPicker.tsx b/desktop/src/renderer/components/Composer/ModelPicker.tsx new file mode 100644 index 0000000..822d877 --- /dev/null +++ b/desktop/src/renderer/components/Composer/ModelPicker.tsx @@ -0,0 +1,45 @@ +import { useChatStore } from "../../store/chatStore.js"; + +const MODELS = [ + "claude-opus-4-1", + "claude-sonnet-4-5", + "claude-haiku-4-5", +]; + +export function ModelPicker() { + const config = useChatStore((s) => s.config) as { model?: string } | null; + const setConfig = useChatStore((s) => s.setConfig); + + async function update(model: string) { + const next = await window.azoth.invoke("config:save", { patch: { model } }); + setConfig(next); + } + + return ( + + ); +} + +function ChevronIcon() { + return ( + + + + ); +} diff --git a/desktop/src/renderer/components/Composer/PromptComposer.tsx b/desktop/src/renderer/components/Composer/PromptComposer.tsx new file mode 100644 index 0000000..a1ac059 --- /dev/null +++ b/desktop/src/renderer/components/Composer/PromptComposer.tsx @@ -0,0 +1,248 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { matchSlash } from "../../../shared/slashCommands.js"; +import { useChatStore } from "../../store/chatStore.js"; +import { SlashSuggest } from "./SlashSuggest.js"; +import { ModelPicker } from "./ModelPicker.js"; +import { AutonomyPicker } from "./AutonomyPicker.js"; + +function newTurnId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +export function PromptComposer() { + const [value, setValue] = useState(""); + const [suggestIdx, setSuggestIdx] = useState(0); + const [error, setError] = useState(null); + const taRef = useRef(null); + + const { + activeProjectId, + activeSessionId, + streaming, + activeTurnId, + setActiveSession, + setRecords, + setSessions, + appendRecord, + startStreaming, + stopStreaming, + } = useChatStore(); + + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (typeof detail === "string") { + setValue(detail); + taRef.current?.focus(); + } + }; + window.addEventListener("azoth:prefill", handler); + return () => window.removeEventListener("azoth:prefill", handler); + }, []); + + useEffect(() => { + const el = taRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, 200)}px`; + }, [value]); + + const send = useCallback(async () => { + const text = value.trim(); + if (!text || !activeProjectId || streaming) return; + setError(null); + + let sessionId = activeSessionId; + try { + if (!sessionId) { + const entry = await window.azoth.invoke("session:start", { + projectId: activeProjectId, + title: text.slice(0, 80), + }); + sessionId = entry.id; + setActiveSession(sessionId); + setRecords(sessionId, []); + const list = await window.azoth.invoke("session:list", { projectId: activeProjectId }); + setSessions(list); + } + + // Optimistic user record so the message appears immediately. + appendRecord(sessionId, { + type: "user", + timestamp: Date.now(), + sessionId, + text, + }); + + const turnId = newTurnId(); + startStreaming(turnId); + setValue(""); + await window.azoth.invoke("turn:send", { + projectId: activeProjectId, + sessionId, + prompt: text, + turnId, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + stopStreaming(); + if (sessionId) { + appendRecord(sessionId, { + type: "error", + timestamp: Date.now(), + sessionId, + text: message, + }); + } else { + setError(message); + } + } + }, [ + value, + activeProjectId, + activeSessionId, + streaming, + setActiveSession, + setRecords, + setSessions, + appendRecord, + startStreaming, + stopStreaming, + ]); + + async function abort() { + if (!activeTurnId) return; + await window.azoth.invoke("turn:abort", { turnId: activeTurnId }); + } + + const suggestions = matchSlash(value); + + function handleKey(e: React.KeyboardEvent) { + if (suggestions.length > 0) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSuggestIdx((i) => i + 1); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setSuggestIdx((i) => i - 1); + return; + } + if (e.key === "Tab") { + e.preventDefault(); + const sel = + ((suggestIdx % suggestions.length) + suggestions.length) % suggestions.length; + const cmd = suggestions[sel]!; + setValue(`/${cmd.name}${cmd.args ? " " : ""}`); + return; + } + } + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + void send(); + } + } + + return ( +
    +
    + setValue(`/${c.name}${c.args ? " " : ""}`)} + /> + {error && ( +
    + {error} +
    + )} +